Repository navigation
docs(astar): explain the heuristic, the weight and the constructor arguments - #139
Conversation
|
Gentle nudge on this one — it's docs only (comments and the README section for the A* heuristic weight), so it should be a quick read whenever you have a moment. Happy to rebase or trim it down if you'd rather have a smaller change. |
|
Hi @qwist1233-cpu, thank you for this PR, and sorry for the slow review! This is exactly what #102 asked for. The Two requests before merging:
Optional suggestions:
Also, your last comment mentions a README section, but the PR only touches Thanks again! |
…ent with heuristic() gif_name=None now skips the visualization and a given path only saves when --save-gif is passed. Also state that 1.0 does not keep the shortest-path guarantee on this grid, define h(n) once without the weight, note that path_filename is effectively required, and leave the random-grid figures in the pull request.
9e17b5e to
9bdb1e9
Compare
|
Thanks for the careful read — and for re-running the numbers. Rebased on main and pushed; all five points are in. 1. 2.
3. One notation for h. 4. Random-grid statistics. Out of the docstring — it keeps the The harness is a matplotlib-free replica of measure_weight.pyimport heapq, json
import numpy as np
NB = [(-1, 0), (1, 0), (0, -1), (0, 1), (1, 1), (-1, -1), (1, -1), (-1, 1)]
def search(grid, start_idx, goal_idx, w):
"""Replica of AStarPathPlanner.search(): expansions and path length."""
rows, cols = grid.shape
open_list = [(0, start_idx)]
came_from, cost = {}, {start_idx: 0}
expanded = 0
h = lambda a, b: w * (abs(a[0] - b[0]) + abs(a[1] - b[1]))
while open_list:
_, cur = heapq.heappop(open_list)
expanded += 1
if cur == goal_idx:
n, path = cur, [cur]
while n != start_idx:
n = came_from[n]
path.append(n)
return expanded, len(path)
for dx, dy in NB:
nb = (cur[0] + dx, cur[1] + dy)
if 0 <= nb[0] < cols and 0 <= nb[1] < rows and grid[nb[1], nb[0]] == 0:
nc = cost[cur] + 1
if nb not in cost or nc < cost[nb]:
cost[nb] = nc
came_from[nb] = cur
heapq.heappush(open_list, (nc + h(nb, goal_idx), nb))
return expanded, None
# control: the simulation's own map
grid = np.array(json.load(open("src/simulations/path_planning/astar_path_planning/map.json")))
x_min, x_max, y_min = -5, 55, -20
res = (x_max - x_min) / grid.shape[1]
idx = lambda p: (int((p[0] - x_min) / res), int((p[1] - y_min) / res))
s, g = idx((0, 0)), idx((50, -10))
for w in (0.0, 1.0, 5.0):
print(f"map.json w={w}: {search(grid, s, g, w)}")
# random grids
rng = np.random.default_rng(20261108)
N, SIZE, OCC = 300, 30, 0.25
solvable, longer, worst = 0, {1.0: 0, 5.0: 0}, {1.0: 1.0, 5.0: 1.0}
for _ in range(N):
gr = (rng.random((SIZE, SIZE)) < OCC).astype(int)
gr[0, 0] = gr[SIZE - 1, SIZE - 1] = 0
base = search(gr, (0, 0), (SIZE - 1, SIZE - 1), 0.0)[1]
if base is None:
continue
solvable += 1
for w in (1.0, 5.0):
ln = search(gr, (0, 0), (SIZE - 1, SIZE - 1), w)[1]
if ln > base:
longer[w] += 1
worst[w] = max(worst[w], ln / base)
print(f"{solvable} solvable of {N}")
for w in (1.0, 5.0):
print(f" w={w}: longer on {longer[w]}/{solvable}, worst {worst[w]:.2f}x")5. On the README: no, that was my mistake in the ping — the PR only ever touched |
|
Thanks a lot for this, @qwist1233-cpu! The constructor docstring now matches the real signature and behaviour (I checked each argument against the code: the map formats, the resolution derived from x_lim and reused on y, One request before merging:
Suggestions:
Minor:
🤖 Generated with Claude Code |
…to heuristic() once
|
Thanks for checking every argument against the code. All four are in (33f57c9):
|
|
Thanks, @qwist1233-cpu! I checked 33f57c9: all four points are addressed. The f(n) line now matches where 🤖 Generated with Claude Code |
What
Documents the A* planner in
src/components/plan/astar/astar_path_planner.py. Documentation only, no behaviour change: the search, the path file and the GIF come out identical.heuristic()gets the equations it implements,f(n) = g(n) + w * h(n)andh(n) = w * (|dx| + |dy|), and an explanation of what the weight buys and what it costs.search()gets a step-by-step docstring, each step naming the block of the loop below it: world coordinates to cell indices, thef-ordered heap, the goal test and path reconstruction,g(n') = g(n) + 1over the 8 neighbours, and the empty-open-list case.obstacle_parameters,resolutionandvisualize, none of which exist, and was missingmap_file,path_filenameandgif_name. It now lists the real arguments with their units, and notes thatx_lim/y_limare required despite theNonedefault because the cell size is derived from them.Why the admissibility note
search()charges a cost of 1 for every one of the 8 moves, diagonals included, so the cheapest obstacle-free cost between two cells is the Chebyshev distancemax(|dx|, |dy|), not|dx| + |dy|. The Manhattan heuristic therefore overestimates, and an overestimating heuristic is not admissible, so the returned path is not guaranteed to be the shortest. The simulation'sweight=5.0widens that gap.How it was verified
The numbers in the docstring are measured, not quoted. I re-ran this planner's own search loop (same 8-connected moves, same unit cost, only the heuristic swapped) on the shipped
map.jsonwithstart = (0, 0)andgoal = (50, -10):h = 0w = 1.0w = 5.0(the simulation)w = 1.0(admissible)On this map every variant happens to return an optimal 100-step path, so I did not want to claim suboptimality from the shipped map alone. Over 283 solvable random 30x30 grids with 25% occupied cells, the same loop returned a path longer than Dijkstra's on 219 grids with
w = 1.0(worst case 1.32x) and on 229 withw = 5.0(worst 1.39x) — the guarantee really is gone, it just does not show up on this particular map.The ratio in the docstring is exact rather than measured-approximate: the largest value of
(|dx| + |dy|) / max(|dx|, |dy|)over the whole 90x120 grid is 2.0, reached on pure diagonals.Tests:
test_astar_path_planning.pypasses (72 s), andtest_astar_bidirectional_path_planning.py+test_astar_hybrid_path_planning.pypass too (125 s), since they share the components directory. Python 3.12 on Windows.Closes #102