diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/README.md b/README.md index 44485db..a7714c9 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,20 @@ Particulary this image gives a good idea of a choosen coordinates for hexagon gr ![hex grid coordinates example](https://catlikecoding.com/unity/tutorials/hex-map/part-1/hexagonal-coordinates/cube-diagram.png) +### Features + 1. Hex cell coordinates: + - find neighbour hexes + - calculate distance between hexes + - calculate coordinates in decart grid (do draw on regular x:y plot) respecting scale factor + - calculate the shortest `Route` between two hex coordinates + - move hex in any direction or along the `Route` + 2. Direction — Enum of 6 possible values `↗→↘↙←↖` matched to the sides of the world from NE to NW + 3. Route — list of `Directions`, that is a result of any path search + 4. Grids: + - `Grid` — base grid class that represents a grid of hex coordinates with some values aggigned to each + - `BlockageGrid` — hex grid of float blockage values from 0.0 to 1.0 that can be used in finding optimal paths + - `dijkstra` and `a_star` algorythms of path finding on a blockage grids respecting penalties on hexes + ## Dependencies This library has no dependencies apart from python stdlib, but it requires Python 3.11 or higher. There is no problem to make it work with Python 3.7 — 3.11, but the original intention was to try fancy features of modern Python. diff --git a/src/hexometry/grids.py b/src/hexometry/grids.py index 7e115f0..8b07f11 100644 --- a/src/hexometry/grids.py +++ b/src/hexometry/grids.py @@ -2,7 +2,7 @@ from typing import Callable, Iterator, TypeVar -from .coordinates import Coord, Direction, Route, get_directed_neighbours +from .coordinates import Coord, Direction, Route, get_directed_neighbours, get_distance HexValue = TypeVar('HexValue') @@ -37,16 +37,22 @@ def hexes(self) -> Iterator[Coord]: 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 + """Hex grid of float blockage values. + + Useful for calculating Route cost. + Blockage values are penalties from 0.0 to 1.0 + where 1.0 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 __init__(self, radius: int, default_blockage_level: float = MIN_VALUE): + if radius <= 0: + raise ValueError('BlockageGrid radius should be positive integer value') + self.grid_radius = radius + self.default_blockage_level = default_blockage_level + super().__init__(default=self._get_blocked_areas) def normalize(self, hex: Coord, value: float): if value < self.MIN_VALUE: @@ -56,11 +62,32 @@ def normalize(self, hex: Coord, value: float): return value + def _get_blocked_areas(self, hex: Coord | tuple[int, int]) -> float: + """For hexes out of the grid_radius scope return maximum blockage value. + This needed to limit traversing route finding algorythms + to not go far away from center into empty fields, looking for less penalties. + """ + if isinstance(hex, tuple): + hex = Coord(*hex) + + z_value = 0 - hex.x - hex.y + + if abs(hex.x) >= self.grid_radius: + return self.MAX_VALUE + if abs(hex.y) >= self.grid_radius: + return self.MAX_VALUE + if abs(z_value) >= self.grid_radius: + return self.MAX_VALUE + return self.default_blockage_level + + def __repr__(self) -> str: + return f'<{self.__class__.__name__}[R={self.grid_radius}, Size={len(self)}]({dict(self)})>' -def dijkstra(start: Coord, end: Coord, penalties: BlockageGrid | None = None, step_penalty=0.00001) -> Route: + +def dijkstra(start: Coord, end: Coord, penalties: BlockageGrid | None = None, step_penalty=0.001) -> 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 []. + 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. @@ -69,11 +96,11 @@ def dijkstra(start: Coord, end: Coord, penalties: BlockageGrid | None = None, st 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 + # no penalties grid provided, assuming all hexes field is available + # fallback to cheapest default route calculation + return start >> end - queue = [(0, start)] + queue = [(0, start)] # at the starting hex we have 0 penalties distances: dict[Coord, float] = {start: 0.0} previous: dict[Coord, Coord] = {} directions: dict[Coord, Direction] = {} @@ -104,3 +131,64 @@ def dijkstra(start: Coord, end: Coord, penalties: BlockageGrid | None = None, st path.reverse() return path + + +def a_star(start: Coord, end: Coord, penalties: BlockageGrid | None = None, step_penalty=0.001) -> Route: + """ + A* pathfinding algorithm for hexagonal grids. + 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. + + Unlike Dijkstra, A* uses a heuristic to prioritize paths that seem to lead toward the destination. + This makes it more efficient for finding routes between distant points. + """ + 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 + + def heuristic(hex: Coord) -> float: + return get_distance(hex, end) * (penalties.default_blockage_level + step_penalty) + + open_set = [(heuristic(start), 0, start)] # (f_score, g_score, hex) + g_score: dict[Coord, float] = {start: 0.0} # cost of the cheapest path from start + f_score: dict[Coord, float] = {start: g_score[start] + heuristic(start)} + + previous: dict[Coord, Coord] = {} + directions: dict[Coord, Direction] = {} + while open_set: + _, current_g_score, current_hex = heapq.heappop(open_set) + + # If we've reached the end, reconstruct and return the path + if current_hex == end: + path = Route() + current = end + while current in previous: + path.append(directions[current]) + current = previous[current] + path.reverse() + return path + + for direction, neighbor in get_directed_neighbours(current_hex): + if penalties[neighbor] >= BlockageGrid.MAX_VALUE: # hex is blocked + continue + + tentative_g_score = current_g_score + step_penalty + penalties[neighbor] + + if neighbor not in g_score or tentative_g_score < g_score[neighbor]: + previous[neighbor] = current_hex + directions[neighbor] = direction + g_score[neighbor] = tentative_g_score + new_f_score = tentative_g_score + heuristic(neighbor) + f_score[neighbor] = new_f_score + + heapq.heappush(open_set, (new_f_score, tentative_g_score, neighbor)) + + return Route() diff --git a/src/tests/geometry_test.py b/src/tests/geometry_test.py index bbeca37..3304d61 100644 --- a/src/tests/geometry_test.py +++ b/src/tests/geometry_test.py @@ -51,7 +51,6 @@ def test_direction_turns(): assert ~Direction.SW == Direction.NE - def test_direction_multiplication(): for d in Direction: assert d * 3 == [d] * 3 == [d, d, d] == Route([d] * 3) @@ -68,24 +67,54 @@ def test_direction_repr(): 0: [], 1: [(0, 1), (1, 0), (1, -1), (0, -1), (-1, 0), (-1, 1)], 2: [ - (0, 2), (1, 1), (2, 0), (2, -1), (2, -2), (1, -2), - (0, -2), (-1, -1), (-2, 0), (-2, 1), (-2, 2), (-1, 2), + (0, 2), + (1, 1), + (2, 0), + (2, -1), + (2, -2), + (1, -2), + (0, -2), + (-1, -1), + (-2, 0), + (-2, 1), + (-2, 2), + (-1, 2), ], }, (12, 34): { 0: [], 1: [(12, 35), (13, 34), (13, 33), (12, 33), (11, 34), (11, 35)], 2: [ - (12, 36), (13, 35), (14, 34), (14, 33), (14, 32), (13, 32), - (12, 32), (11, 33), (10, 34), (10, 35), (10, 36), (11, 36), + (12, 36), + (13, 35), + (14, 34), + (14, 33), + (14, 32), + (13, 32), + (12, 32), + (11, 33), + (10, 34), + (10, 35), + (10, 36), + (11, 36), ], }, (-10, 30): { 0: [], 1: [(-10, 31), (-9, 30), (-9, 29), (-10, 29), (-11, 30), (-11, 31)], 2: [ - (-10, 32), (-9, 31), (-8, 30), (-8, 29), (-8, 28), (-9, 28), - (-10, 28), (-11, 29), (-12, 30), (-12, 31), (-12, 32), (-11, 32), + (-10, 32), + (-9, 31), + (-8, 30), + (-8, 29), + (-8, 28), + (-9, 28), + (-10, 28), + (-11, 29), + (-12, 30), + (-12, 31), + (-12, 32), + (-11, 32), ], }, } @@ -145,8 +174,8 @@ def test_get_directed_neighbours(): neighbours = get_directed_neighbours(c) assert isinstance(neighbours, types.GeneratorType) - for d, n in neighbours: - assert c + d == n + for direction, neighbour in neighbours: + assert c + direction == neighbour def test_empty_route(): @@ -201,7 +230,7 @@ def test_get_distance(): assert get_distance(c1, c2) == get_distance(c2, c1) assert get_distance(c1, c2) == len(c1 >> c2) - assert get_distance(c1, c2) == c1 - c2 + assert get_distance(c1, c2) == c1 - c2 == c2 - c1 assert get_distance(c1, c2) == 17 assert c1 - c2 == c2 - c1 @@ -245,28 +274,14 @@ def test_coord_from_decart(): assert Coord.from_decart(184.9, -683.3) == Coord(123, -456) assert Coord.from_decart(185, -683) == Coord(123, -456) - assert Coord.from_decart(-579.60675, 2146.568238, - scale_factor=3.14) == Coord(-123, 456) + assert Coord.from_decart(-579.60675, 2146.568238, scale_factor=3.14) == Coord(-123, 456) hex_corners_coordinates_test_cases = [ # hex_xy, scale_factor, expected_xy - ((0, 0), 1, [(0.5, 0.866), (1.0, 0.0), (0.5, -0.866), - (-0.5, -0.866), (-1.0, 0.0), (-0.5, 0.866)]), - ((0, 0), 2, [(1.0, 1.7321), (2.0, 0.0), (1.0, -1.7321), - (-1.0, -1.7321), (-2.0, 0.0), (-1.0, 1.7321)]), - ( - (-4, 3), - 1, - [ - (-5.5, 2.5981), - (-5.0, 1.7321), - (-5.5, 0.8661), - (-6.5, 0.8661), - (-7.0, 1.7321), - (-6.5, 2.5981) - ] - ), + ((0, 0), 1, [(0.5, 0.866), (1.0, 0.0), (0.5, -0.866), (-0.5, -0.866), (-1.0, 0.0), (-0.5, 0.866)]), + ((0, 0), 2, [(1.0, 1.7321), (2.0, 0.0), (1.0, -1.7321), (-1.0, -1.7321), (-2.0, 0.0), (-1.0, 1.7321)]), + ((-4, 3), 1, [(-5.5, 2.5981), (-5.0, 1.7321), (-5.5, 0.8661), (-6.5, 0.8661), (-7.0, 1.7321), (-6.5, 2.5981)]), ( (-4, 3), -3.27, diff --git a/src/tests/grids_test.py b/src/tests/grids_test.py index 31c6136..7678304 100644 --- a/src/tests/grids_test.py +++ b/src/tests/grids_test.py @@ -1,7 +1,11 @@ +import itertools import pytest from hexometry.coordinates import Coord, Direction, Route -from hexometry.grids import Grid, BlockageGrid, dijkstra +from hexometry.grids import Grid, BlockageGrid, dijkstra, a_star + + +find_path_algorythms = [a_star, dijkstra] def test_grid_repr(): @@ -9,6 +13,11 @@ def test_grid_repr(): assert repr(grid) == '' +def test_blockage_grid_repr(): + grid = BlockageGrid(radius=15, default_blockage_level=BlockageGrid.MIN_VALUE) + assert repr(grid) == '' + + def test_grid_initialization_with_default_value(): grid = Grid(default=0) assert grid.default == 0 @@ -71,20 +80,29 @@ def test_grid_hexes_iterator(): 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 + blockage_grid = BlockageGrid(radius=15, default_blockage_level=0.5) + assert blockage_grid.grid_radius == 15 + assert blockage_grid.default_blockage_level == 0.5 + assert blockage_grid.get_default == blockage_grid._get_blocked_areas + + +def test_blockagegrid_negative_radius(): + with pytest.raises(ValueError): + BlockageGrid(radius=0) + + with pytest.raises(ValueError): + BlockageGrid(radius=-15) def test_blockagegrid_normalize_clamps_values(): - blockage_grid = BlockageGrid() + blockage_grid = BlockageGrid(15) 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 = BlockageGrid(radius=100, default_blockage_level=0.5) blockage_grid[Coord(1, 2)] = -0.1 assert blockage_grid[Coord(1, 2)] == 0.0 @@ -92,10 +110,11 @@ def test_blockagegrid_getitem_returns_clamped_values(): assert blockage_grid[Coord(3, 4)] == 1.0 -def test_dijkstra_without_penalties_fallbacks_to_default_route_calcilation(): +@pytest.mark.parametrize('get_route', find_path_algorythms) +def test_get_route_without_penalties_fallbacks_to_default_route_calcilation(get_route): start = Coord(0, 0) end = Coord(2, 2) - route = dijkstra(start, end) + route = get_route(start, end) expected_route = start >> end assert route == expected_route @@ -105,42 +124,60 @@ def test_dijkstra_without_penalties_fallbacks_to_default_route_calcilation(): # penalties grid: {value: [(x, y), ...]} # expected route: [] ( - (0, 0), (4, 0), - {1.0: [(0,1), (1, 0), (3, -1), (3, 0)]}, + ((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)]}, ['←', '↖', '↗', '→', '→', '↘'], ), ( - (0, 0), (1, 1), + ((0, 0), (1, 1)), { - 1.0: [(-1,1), (0, 1), (1, 0), (1, -1), (-1, 0)], + 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)]}, + ((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() + +@pytest.mark.parametrize('get_route', find_path_algorythms) +@pytest.mark.parametrize('start_end, penalties, expected', penalties_grids_test_cases) +def test_get_route_with_blockage_grid(get_route, start_end: tuple[Coord], penalties: BlockageGrid, expected: Route): + start, end = start_end + penalties_map = BlockageGrid(radius=100) for penalty_value, coordinates in penalties.items(): for coord in coordinates: penalties_map[coord] = penalty_value - route = dijkstra(start, end, penalties=penalties_map) + route = get_route(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(): +@pytest.mark.parametrize('get_route', find_path_algorythms) +def test_get_route_with_negative_step_penalty(get_route): with pytest.raises(ValueError): - dijkstra((0,0), (100, 100), penalties=BlockageGrid(), step_penalty=-0.5) + get_route((0, 0), (100, 100), penalties=BlockageGrid(1000), step_penalty=-0.5) + + +def test_get_blocked_areas_in_blockage_grid(): + """Test that all hexes out of grid radius are set to the maximum blockage value""" + grid_radius = 30 + default_blockage_value = 0.2 + grid = BlockageGrid(radius=grid_radius, default_blockage_level=default_blockage_value) + assert len(grid) == 0 + center = Coord(0, 0) + for x, y in itertools.product(range(100), range(100)): + if Coord(x, y) - center >= grid_radius: + assert grid[x, y] == grid.MAX_VALUE + else: + assert grid[x, y] == default_blockage_value