Skip to content
Open
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
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.13
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
112 changes: 100 additions & 12 deletions src/hexometry/grids.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand All @@ -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] = {}
Expand Down Expand Up @@ -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

Copy link
Collaborator

@maxtropets maxtropets Mar 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you're missing a check for g_score[current] == current_g_score. You shall not continue this iteration if that's not true.

Unlike Dijkstra, A* may give you a worse (by g_score) hex first, unless your heuristic function guarantees the opposite.

Without this check it may actually perform way worse.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it also may give you not the shortest path here?..

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I got you correct that condition will allways be met.
In current_g_score we have the minimum value of g-scores needed to get to the final end hex. It is guaranteed by the heap, based on the f_score, which will differ from g_score by the constant value of last step (f_score = g_score + heuristic(neighbor)). So minimal f_score means minimal g_score.

⬆️ That was not an AI comment I promise. Despite I also use . at the end of sentenses.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be happy to setup test that proves the opposite and dig inside to figure out what am I missing.

Copy link
Collaborator

@maxtropets maxtropets Apr 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So minimal f_score means minimal g_score.

Why do you need both f and g then?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because those mean different things — minimal cost to get to currect cell and estimation cost to get from current cell to target.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My claim is - it magically works because of the simplicity of the current heuristics. If you were to add some more penalties (let's say, you want the most straight (fewer turns) path), then you may end up getting wrong results.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It means that you have to sort by the score which determines what's your best result, and if you're not - than you have to check your current g_score is the best before printing result. So the check I'm talking about should happen earlier.

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()
71 changes: 43 additions & 28 deletions src/tests/geometry_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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),
],
},
}
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading