diff --git a/.github/workflows/run-pytest.yml b/.github/workflows/run-pytest.yml index 11414dc..c51c765 100644 --- a/.github/workflows/run-pytest.yml +++ b/.github/workflows/run-pytest.yml @@ -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 diff --git a/src/hexometry/__init__.py b/src/hexometry/__init__.py new file mode 100644 index 0000000..de76769 --- /dev/null +++ b/src/hexometry/__init__.py @@ -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, +] diff --git a/hexometry.py b/src/hexometry/coordinates.py similarity index 96% rename from hexometry.py rename to src/hexometry/coordinates.py index 53cd51b..f608687 100644 --- a/hexometry.py +++ b/src/hexometry/coordinates.py @@ -1,5 +1,3 @@ -"""Hexometry module""" - import collections import enum import math @@ -9,9 +7,6 @@ from typing import TypeAlias, Iterator, Callable, Self -__version__ = '1.0.1' - - _FLOAT_PRECISION = 4 @@ -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 diff --git a/src/hexometry/grids.py b/src/hexometry/grids.py new file mode 100644 index 0000000..7e115f0 --- /dev/null +++ b/src/hexometry/grids.py @@ -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 diff --git a/src/tests/__init__.py b/src/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests.py b/src/tests/geometry_test.py similarity index 92% rename from tests.py rename to src/tests/geometry_test.py index d41c900..bbeca37 100644 --- a/tests.py +++ b/src/tests/geometry_test.py @@ -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, @@ -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(): @@ -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) diff --git a/src/tests/grids_test.py b/src/tests/grids_test.py new file mode 100644 index 0000000..31c6136 --- /dev/null +++ b/src/tests/grids_test.py @@ -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) == '' + + +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)