Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,6 @@ jobs:
curl -LsSf https://astral.sh/uv/install.sh | sh
uv sync
- name: Run tests
run: uv run pytest --cov=hexometry --cov-fail-under=100 tests.py
run: uv run pytest --cov-report=term-missing --cov=hexometry --cov-fail-under=100 src/tests
- name: Lint
run: uv run ruff check
37 changes: 37 additions & 0 deletions src/hexometry/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""
**Hexometry**

A Python library for working with hexagonal grids, providing tools for coordinate manipulation, pathfinding, and grid operations.

Key Features:
- Hexagonal coordinate system conversions
- Neighbor finding and distance calculations
- Route generation between coordinates
- Pathfinding using Dijkstra's algorithm with optional penalties
- Grid management for hex-based applications
"""

from .coordinates import (
Direction,
Coord,
Route,
DecartCoord,
)
from .grids import (
Grid,
BlockageGrid,
dijkstra,
)

__version__ = '1.0.1'


__all__ = [
Direction,
Coord,
Route,
DecartCoord,
Grid,
BlockageGrid,
dijkstra,
]
13 changes: 8 additions & 5 deletions hexometry.py → src/hexometry/coordinates.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"""Hexometry module"""

import collections
import enum
import math
Expand All @@ -9,9 +7,6 @@
from typing import TypeAlias, Iterator, Callable, Self


__version__ = '1.0.1'


_FLOAT_PRECISION = 4


Expand Down Expand Up @@ -207,6 +202,14 @@ def traverse_route(start: Coord, route: Route) -> Coord:
return coord


def iterate_route(start: Coord, route: Route) -> Iterator[tuple[Direction, Coord]]:
"""Generates pairs of Direction to next Hex and its coordinates."""
coord = start
for direction in route:
coord = get_neighbour(coord, direction)
yield direction, coord


def hex_to_decart(coord: Coord, scale_factor: float) -> DecartCoord:
"""Converts a hex coordinate to a decart coordinates
assuming the (0, 0) coordinates are matched in hex grid and decart grid
Expand Down
106 changes: 106 additions & 0 deletions src/hexometry/grids.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import heapq

from typing import Callable, Iterator, TypeVar

from .coordinates import Coord, Direction, Route, get_directed_neighbours


HexValue = TypeVar('HexValue')


class Grid(dict[Coord, HexValue]):
"""Generic Hex Grid of some values."""

def __init__(self, default: HexValue | Callable[[Coord], HexValue]):
self.default: HexValue | None = None if callable(default) else default
self.get_default: Callable | None = default if callable(default) else None

def normalize(self, hex: Coord, value: HexValue) -> HexValue:
return value

def __setitem__(self, key: Coord, value: HexValue) -> None:
super().__setitem__(key, self.normalize(key, value))

def __getitem__(self, key: Coord) -> HexValue | None:
if key in self:
return super().__getitem__(key)
elif self.get_default is not None:
return self.get_default(key)

return self.default

def __repr__(self) -> str:
return f'<{self.__class__.__name__}({super().__repr__()})>'

def hexes(self) -> Iterator[Coord]:
yield from self.keys()


class BlockageGrid(Grid[float]):
"""Hex grid of float blockage values. Useful for calculating Route cost.
Blockage values are penalties from 0 to 1
where 1 means hex at this coordinates is blocked for traversing
"""

MIN_VALUE = 0.0
MAX_VALUE = 1.0

def __init__(self, default_blockage_level: float = 0.0):
super().__init__(default=default_blockage_level)

def normalize(self, hex: Coord, value: float):
if value < self.MIN_VALUE:
return self.MIN_VALUE
if value > self.MAX_VALUE:
return self.MAX_VALUE

return value


def dijkstra(start: Coord, end: Coord, penalties: BlockageGrid | None = None, step_penalty=0.00001) -> Route:
"""
Returns a route from `start` coordinates to `end`, respecting `penalties` grid if provided.
If there is no route (penalties grid blocks any route) will return empty route [].
`step_penalty` — minimal penalty to each step to track
how far from start we get and look for the shortest way.
Could be useful to balance between minimizing distance or sum of penalties on it.
"""
if step_penalty <= 0:
raise ValueError('step_penalty should be positive float number') # to avoid infinite loops

if penalties is None:
# no penalties grid provided, assuming all hexes field is available
# fallback to cheapest default route calculation
return start >> end

queue = [(0, start)]
distances: dict[Coord, float] = {start: 0.0}
previous: dict[Coord, Coord] = {}
directions: dict[Coord, Direction] = {}

while queue:
current_distance, current_hex = heapq.heappop(queue)

if current_hex == end:
break

for direction, neighbour in get_directed_neighbours(current_hex):
if penalties[neighbour] >= BlockageGrid.MAX_VALUE: # hex is blocked
continue

distance_to_neighbour = current_distance + step_penalty + penalties[neighbour]

if neighbour not in distances or distance_to_neighbour < distances[neighbour]:
distances[neighbour] = distance_to_neighbour # best value
directions[neighbour] = direction # how we get there
previous[neighbour] = current_hex # where we made last step from
heapq.heappush(queue, (distance_to_neighbour, neighbour))

# Reconstruct the path
path = Route()
while end in previous:
path.append(directions[end])
end = previous[end]

path.reverse()
return path
Empty file added src/tests/__init__.py
Empty file.
33 changes: 30 additions & 3 deletions tests.py → src/tests/geometry_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@

import pytest

from hexometry import (

from hexometry.coordinates import (
Coord,
Direction,
Route,
iterate_route,
get_route,
get_directed_neighbours,
hex_to_decart,
Expand Down Expand Up @@ -133,8 +135,8 @@ def test_coord_repr():


def test_get_neighbor_for_big_distance_works():
assert len(list(get_neighbours((123, 234), distance=3000))) == 18000 # type: ignore
assert len(list(get_neighbours((123, 234), distance=300, within=True))) == 270900 # type: ignore
assert len(list(get_neighbours((123, 234), distance=3000))) == 18000
assert len(list(get_neighbours((123, 234), distance=300, within=True))) == 270900


def test_get_directed_neighbours():
Expand Down Expand Up @@ -289,3 +291,28 @@ def test_hex_to_decart_corners(hex_xy, scale_factor, expected_corners_coordinate

if scale_factor == 1:
assert round(c) == hex_to_decart_corners(c, scale_factor=1)


def test_iterate_route():
c1 = Coord(0, 0)
route = ['↗', '→', '→', '↘', '←']
expected_coordinates = [
Coord(0, 1),
Coord(1, 1),
Coord(2, 1),
Coord(3, 0),
Coord(2, 0),
]

route_iterator = iterate_route(c1, route)
for step, (direction, coord) in enumerate(route_iterator):
assert direction == route[step]
assert coord == expected_coordinates[step]


def test_iterate_route_with_empty_route():
coord = Coord(random.randint(-100, 100), random.randint(-100, 100))
route_iterator = iterate_route(coord, Route())

with pytest.raises(StopIteration):
next(route_iterator)
146 changes: 146 additions & 0 deletions src/tests/grids_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import pytest

from hexometry.coordinates import Coord, Direction, Route
from hexometry.grids import Grid, BlockageGrid, dijkstra


def test_grid_repr():
grid = Grid(default=None)
assert repr(grid) == '<Grid({})>'


def test_grid_initialization_with_default_value():
grid = Grid(default=0)
assert grid.default == 0


def test_grid_initialization_with_callable_default():
def default(coord):
return coord.x + coord.y

grid = Grid(default=default)
assert grid.default is None
assert grid[Coord(1, 2)] == 3


def test_grid_setitem_normalizes_value():
class TestGrid(Grid[int]):
def normalize(self, hex: Coord, value: int):
return value * 2

grid = TestGrid(default=0)
grid[Coord(1, 2)] = 5
assert grid[Coord(1, 2)] == 10


def test_grid_getitem_returns_stored_value():
grid = Grid(default=0)
grid[Coord(1, 2)] = 5
assert grid[Coord(1, 2)] == 5


def test_grid_getitem_returns_default_value():
grid = Grid(default=0)
assert grid[Coord(3, 4)] == 0


def test_grid_getitem_with_callable_default():
def get_z_coord(coord):
return 0 - coord.x - coord.y

grid = Grid(default=get_z_coord)
assert grid[Coord(1, 2)] == -3


def test_grid_getitem_calls_callable_default():
def default(coord):
return coord.x + coord.y

grid = Grid(default=default)
assert grid[Coord(1, 2)] == 3


def test_grid_hexes_iterator():
grid = Grid(default=0)
grid[Coord(1, 2)] = 5
grid[Coord(3, 4)] = 10
hexes = list(grid.hexes())
assert len(hexes) == 2
assert Coord(1, 2) in hexes
assert Coord(3, 4) in hexes


def test_blockagegrid_initialization():
blockage_grid = BlockageGrid(default_blockage_level=0.5)
assert blockage_grid.default == 0.5
assert blockage_grid.get_default is None


def test_blockagegrid_normalize_clamps_values():
blockage_grid = BlockageGrid()
assert blockage_grid.normalize(Coord(1, 2), -0.1) == 0.0
assert blockage_grid.normalize(Coord(1, 2), 1.5) == 1.0
assert blockage_grid.normalize(Coord(1, 2), 0.7) == 0.7


def test_blockagegrid_getitem_returns_clamped_values():
blockage_grid = BlockageGrid(default_blockage_level=0.5)
blockage_grid[Coord(1, 2)] = -0.1
assert blockage_grid[Coord(1, 2)] == 0.0

blockage_grid[Coord(3, 4)] = 1.5
assert blockage_grid[Coord(3, 4)] == 1.0


def test_dijkstra_without_penalties_fallbacks_to_default_route_calcilation():
start = Coord(0, 0)
end = Coord(2, 2)
route = dijkstra(start, end)
expected_route = start >> end
assert route == expected_route


penalties_grids_test_cases = [
# coordinates: start, end
# penalties grid: {value: [(x, y), ...]}
# expected route: []
(
(0, 0), (4, 0),
{1.0: [(0,1), (1, 0), (3, -1), (3, 0)]},
['↘', '→', '↗', '↗', '→', '↘'],
),
(
(0, 0), (1, 1),
{1.0: [(-1,1), (0, 1), (1, 0), (1, -1)]},
['←', '↖', '↗', '→', '→', '↘'],
),
(
(0, 0), (1, 1),
{
1.0: [(-1,1), (0, 1), (1, 0), (1, -1), (-1, 0)],
0.8: [(0, -1)],
},
['↙', '↘', '→', '↗', '↗', '↖'],
),
(
(0, 0), (1, 1),
{1.0: [(-1,1), (0, 1), (1, 0), (1, -1), (0, -1), (-1, 0)]},
[],
),
]

@pytest.mark.parametrize('start, end, penalties, expected', penalties_grids_test_cases)
def test_dijkstra_with_blockage_grid(start: Coord, end: Coord, penalties: BlockageGrid, expected: Route):
penalties_map = BlockageGrid()
for penalty_value, coordinates in penalties.items():
for coord in coordinates:
penalties_map[coord] = penalty_value

route = dijkstra(start, end, penalties=penalties_map)
expected_route = Route([Direction(d) for d in expected])
assert route == expected_route


def test_dijkstra_with_negative_step_penalty():
with pytest.raises(ValueError):
dijkstra((0,0), (100, 100), penalties=BlockageGrid(), step_penalty=-0.5)