Skip to content

Commit 4211830

Browse files
authored
Add multi_stop_search for multi-waypoint routing (#935) (#937)
Routes through N waypoints by calling a_star_search for each consecutive pair and stitching segments into a single cumulative-cost surface. Supports optional TSP-based reordering of interior waypoints (exact Held-Karp for N<=12, nearest-neighbor + 2-opt otherwise).
1 parent 9c763d0 commit 4211830

File tree

5 files changed

+624
-2
lines changed

5 files changed

+624
-2
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ In the GIS world, rasters are used for representing continuous phenomena (e.g. e
209209
| Name | Description | NumPy xr.DataArray | Dask xr.DataArray | CuPy GPU xr.DataArray | Dask GPU xr.DataArray |
210210
|:----------:|:------------|:----------------------:|:--------------------:|:-------------------:|:------:|
211211
| [A* Pathfinding](xrspatial/pathfinding.py) | Finds the least-cost path between two cells on a cost surface | ✅️ || 🔄 | 🔄 |
212+
| [Multi-Stop Search](xrspatial/pathfinding.py) | Routes through N waypoints in sequence, with optional TSP reordering | ✅️ || 🔄 | 🔄 |
212213

213214
----------
214215

docs/source/user_guide/pathfinding.ipynb

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
{
44
"cell_type": "markdown",
55
"metadata": {},
6-
"source": "## Pathfinding\n\nXarray-spatial provides A\\* pathfinding for finding optimal routes through raster surfaces.\nPaths can be computed using geometric distance alone (unweighted) or weighted by a\nfriction/cost surface, which makes the algorithm find the *least-cost* path rather\nthan the geometrically shortest one.\n\nAll four backends are supported:\n\n| Backend | Strategy |\n|---------|----------|\n| **NumPy** | Numba-jitted kernel (fast, in-memory) |\n| **Dask** | Sparse Python A\\* with LRU chunk cache — loads chunks on demand so the full grid never needs to fit in RAM |\n| **CuPy** | CPU fallback (transfers to numpy, runs the numba kernel, transfers back) |\n| **Dask+CuPy** | Same sparse A\\* as Dask, with automatic cupy-to-numpy chunk conversion |\n\n**Note:** `snap_start` and `snap_goal` are not supported with Dask-backed arrays\nbecause the brute-force nearest-pixel scan is O(h*w). Ensure the start and goal\npixels are valid before calling `a_star_search`.\n\n- [Unweighted A\\*](#Unweighted-A*): find the shortest geometric path through a line network\n- [Weighted A\\*](#Weighted-A*): find the least-cost path through a friction surface\n- [Dask (out-of-core)](#Dask-(out-of-core)): pathfinding on datasets that don't fit in RAM"
6+
"source": "## Pathfinding\n\nXarray-spatial provides A\\* pathfinding for finding optimal routes through raster surfaces.\nPaths can be computed using geometric distance alone (unweighted) or weighted by a\nfriction/cost surface, which makes the algorithm find the *least-cost* path rather\nthan the geometrically shortest one.\n\n`multi_stop_search` extends A\\* to visit a sequence of waypoints, stitching segments\ninto a single cumulative-cost surface. It can optionally reorder interior waypoints\nto minimize total travel cost (TSP).\n\nAll four backends are supported:\n\n| Backend | Strategy |\n|---------|----------|\n| **NumPy** | Numba-jitted kernel (fast, in-memory) |\n| **Dask** | Sparse Python A\\* with LRU chunk cache — loads chunks on demand so the full grid never needs to fit in RAM |\n| **CuPy** | CPU fallback (transfers to numpy, runs the numba kernel, transfers back) |\n| **Dask+CuPy** | Same sparse A\\* as Dask, with automatic cupy-to-numpy chunk conversion |\n\n**Note:** `snap_start` and `snap_goal` are not supported with Dask-backed arrays\nbecause the brute-force nearest-pixel scan is O(h*w). Ensure the start and goal\npixels are valid before calling `a_star_search`.\n\n- [Unweighted A\\*](#Unweighted-A*): find the shortest geometric path through a line network\n- [Weighted A\\*](#Weighted-A*): find the least-cost path through a friction surface\n- [Multi-Stop Search](#Multi-Stop-Search): route through multiple waypoints with optional reordering\n- [Dask (out-of-core)](#Dask-(out-of-core)): pathfinding on datasets that don't fit in RAM"
77
},
88
{
99
"cell_type": "markdown",
@@ -17,7 +17,7 @@
1717
"execution_count": null,
1818
"metadata": {},
1919
"outputs": [],
20-
"source": "import numpy as np\nimport pandas as pd\nimport xarray as xr\n\nimport datashader as ds\nfrom datashader.transfer_functions import shade\nfrom datashader.transfer_functions import stack\nfrom datashader.transfer_functions import dynspread\nfrom datashader.transfer_functions import set_background\nfrom datashader.colors import Elevation\n\nimport xrspatial\nfrom xrspatial import a_star_search, generate_terrain, slope, cost_distance"
20+
"source": "import numpy as np\nimport pandas as pd\nimport xarray as xr\n\nimport datashader as ds\nfrom datashader.transfer_functions import shade\nfrom datashader.transfer_functions import stack\nfrom datashader.transfer_functions import dynspread\nfrom datashader.transfer_functions import set_background\nfrom datashader.colors import Elevation\n\nimport xrspatial\nfrom xrspatial import a_star_search, multi_stop_search, generate_terrain, slope, cost_distance"
2121
},
2222
{
2323
"cell_type": "markdown",
@@ -252,6 +252,35 @@
252252
"print(f\"Match: {np.isclose(cd_val, astar_val, rtol=1e-5)}\")"
253253
]
254254
},
255+
{
256+
"cell_type": "markdown",
257+
"source": "### Multi-Stop Search\n\n`multi_stop_search` routes through a list of waypoints in order, calling A\\* for\neach consecutive pair and stitching the segments into one cumulative-cost surface.\n\nSet `optimize_order=True` to let the solver reorder the interior waypoints (keeping\nthe first and last fixed) to minimize total travel cost. For up to 12 waypoints it\nuses exact Held-Karp; beyond that it falls back to nearest-neighbor + 2-opt.",
258+
"metadata": {}
259+
},
260+
{
261+
"cell_type": "markdown",
262+
"source": "#### Route through three waypoints\n\nWe pick three waypoints across the terrain and let `multi_stop_search` find\nthe least-cost route visiting them in order.",
263+
"metadata": {}
264+
},
265+
{
266+
"cell_type": "code",
267+
"source": "# Three waypoints across the terrain (re-using the terrain and friction from above)\nwp_start = (400.0, 50.0)\nwp_mid = (250.0, 250.0)\nwp_end = (100.0, 450.0)\n\n# Naive order\npath_multi = multi_stop_search(\n terrain, [wp_start, wp_mid, wp_end], friction=friction\n)\n\nprint(f\"Segment costs: {path_multi.attrs['segment_costs']}\")\nprint(f\"Total cost: {path_multi.attrs['total_cost']:.2f}\")\n\n# Visualise the multi-stop path\nterrain_shaded = shade(terrain, cmap=Elevation, how=\"linear\", alpha=180)\nmulti_shaded = dynspread(\n shade(path_multi, cmap=[\"cyan\", \"cyan\"], how=\"linear\", min_alpha=255),\n threshold=1, max_px=2,\n)\nstack(terrain_shaded, multi_shaded)",
268+
"metadata": {},
269+
"execution_count": null,
270+
"outputs": []
271+
},
272+
{
273+
"cell_type": "markdown",
274+
"source": "#### Optimize waypoint order\n\nWith four or more waypoints, the given order may not be the cheapest.\n`optimize_order=True` solves a TSP over the pairwise A\\* costs, keeping the\nfirst and last waypoints fixed.",
275+
"metadata": {}
276+
},
277+
{
278+
"cell_type": "code",
279+
"source": "# Four waypoints in a deliberately suboptimal order (zigzag)\nwp_a = (400.0, 50.0) # start (fixed)\nwp_b = (100.0, 450.0) # far corner\nwp_c = (350.0, 200.0) # back near start\nwp_d = (100.0, 350.0) # end (fixed)\n\nwaypoints = [wp_a, wp_b, wp_c, wp_d]\n\n# Without optimization\npath_naive = multi_stop_search(terrain, waypoints, friction=friction)\n\n# With optimization\npath_opt = multi_stop_search(\n terrain, waypoints, friction=friction, optimize_order=True\n)\n\nprint(f\"Naive order: {path_naive.attrs['waypoint_order']}\")\nprint(f\"Naive cost: {path_naive.attrs['total_cost']:.2f}\")\nprint(f\"Optimized order: {path_opt.attrs['waypoint_order']}\")\nprint(f\"Optimized cost: {path_opt.attrs['total_cost']:.2f}\")\nprint(f\"Savings: {path_naive.attrs['total_cost'] - path_opt.attrs['total_cost']:.2f}\")",
280+
"metadata": {},
281+
"execution_count": null,
282+
"outputs": []
283+
},
255284
{
256285
"cell_type": "markdown",
257286
"metadata": {},

xrspatial/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from xrspatial.multispectral import savi # noqa
5050
from xrspatial.multispectral import sipi # noqa
5151
from xrspatial.pathfinding import a_star_search # noqa
52+
from xrspatial.pathfinding import multi_stop_search # noqa
5253
from xrspatial.perlin import perlin # noqa
5354
from xrspatial.proximity import allocation # noqa
5455
from xrspatial.proximity import direction # noqa

xrspatial/pathfinding.py

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1147,3 +1147,324 @@ def a_star_search(surface: xr.DataArray,
11471147
attrs=surface.attrs)
11481148

11491149
return path_agg
1150+
1151+
1152+
# ---------------------------------------------------------------------------
1153+
# Multi-stop routing
1154+
# ---------------------------------------------------------------------------
1155+
1156+
def _held_karp(dist, start, end):
1157+
"""Exact TSP with fixed start and end via Held-Karp bitmask DP.
1158+
1159+
Parameters
1160+
----------
1161+
dist : 2-D array-like, shape (N, N)
1162+
Pairwise costs. ``dist[i][j]`` is the cost from city *i* to *j*.
1163+
start, end : int
1164+
Indices that must be first and last in the tour.
1165+
1166+
Returns
1167+
-------
1168+
(order, total_cost) : (list[int], float)
1169+
"""
1170+
n = len(dist)
1171+
if n == 2:
1172+
return [start, end], dist[start][end]
1173+
1174+
# Cities to visit between start and end
1175+
mid = [i for i in range(n) if i != start and i != end]
1176+
nm = len(mid)
1177+
INF = float('inf')
1178+
1179+
# dp[(mask, city_idx_in_mid)] = min cost to reach city from start
1180+
# visiting exactly the cities indicated by mask
1181+
dp = [[INF] * nm for _ in range(1 << nm)]
1182+
parent = [[-1] * nm for _ in range(1 << nm)]
1183+
1184+
# Base: start -> each mid city
1185+
for j, c in enumerate(mid):
1186+
dp[1 << j][j] = dist[start][c]
1187+
1188+
for mask in range(1, 1 << nm):
1189+
for j in range(nm):
1190+
if not (mask & (1 << j)):
1191+
continue
1192+
if dp[mask][j] == INF:
1193+
continue
1194+
for k in range(nm):
1195+
if mask & (1 << k):
1196+
continue
1197+
new_mask = mask | (1 << k)
1198+
new_cost = dp[mask][j] + dist[mid[j]][mid[k]]
1199+
if new_cost < dp[new_mask][k]:
1200+
dp[new_mask][k] = new_cost
1201+
parent[new_mask][k] = j
1202+
1203+
# Close tour to end
1204+
full = (1 << nm) - 1
1205+
best_cost = INF
1206+
best_last = -1
1207+
for j in range(nm):
1208+
cost = dp[full][j] + dist[mid[j]][end]
1209+
if cost < best_cost:
1210+
best_cost = cost
1211+
best_last = j
1212+
1213+
# Reconstruct
1214+
order_mid = []
1215+
mask = full
1216+
cur = best_last
1217+
while cur != -1:
1218+
order_mid.append(mid[cur])
1219+
prev = parent[mask][cur]
1220+
mask ^= (1 << cur)
1221+
cur = prev
1222+
order_mid.reverse()
1223+
1224+
return [start] + order_mid + [end], best_cost
1225+
1226+
1227+
def _nearest_neighbor_2opt(dist, start, end):
1228+
"""Heuristic TSP for large N: nearest-neighbor + 2-opt with fixed endpoints.
1229+
1230+
Parameters
1231+
----------
1232+
dist : 2-D array-like, shape (N, N)
1233+
Pairwise costs.
1234+
start, end : int
1235+
Fixed first and last indices.
1236+
1237+
Returns
1238+
-------
1239+
(order, total_cost) : (list[int], float)
1240+
"""
1241+
n = len(dist)
1242+
remaining = set(range(n)) - {start, end}
1243+
1244+
# Greedy nearest-neighbor construction
1245+
tour = [start]
1246+
cur = start
1247+
while remaining:
1248+
nearest = min(remaining, key=lambda c: dist[cur][c])
1249+
tour.append(nearest)
1250+
remaining.remove(nearest)
1251+
cur = nearest
1252+
tour.append(end)
1253+
1254+
# 2-opt local search (only swap interior segment, keep endpoints fixed)
1255+
def _tour_cost(t):
1256+
return sum(dist[t[i]][t[i + 1]] for i in range(len(t) - 1))
1257+
1258+
improved = True
1259+
while improved:
1260+
improved = False
1261+
for i in range(1, len(tour) - 2):
1262+
for j in range(i + 1, len(tour) - 1):
1263+
# Reverse segment tour[i:j+1]
1264+
new_tour = tour[:i] + tour[i:j + 1][::-1] + tour[j + 1:]
1265+
if _tour_cost(new_tour) < _tour_cost(tour):
1266+
tour = new_tour
1267+
improved = True
1268+
1269+
return tour, _tour_cost(tour)
1270+
1271+
1272+
def _optimize_waypoint_order(surface, waypoints, barriers, x, y,
1273+
connectivity, snap, friction, search_radius):
1274+
"""Build pairwise cost matrix and solve TSP with fixed endpoints.
1275+
1276+
Returns reordered waypoints list.
1277+
"""
1278+
n = len(waypoints)
1279+
INF = float('inf')
1280+
dist = [[INF] * n for _ in range(n)]
1281+
1282+
for i in range(n):
1283+
for j in range(n):
1284+
if i == j:
1285+
dist[i][j] = 0.0
1286+
continue
1287+
seg = a_star_search(
1288+
surface, waypoints[i], waypoints[j],
1289+
barriers=barriers, x=x, y=y,
1290+
connectivity=connectivity,
1291+
snap_start=snap, snap_goal=snap,
1292+
friction=friction, search_radius=search_radius,
1293+
)
1294+
seg_data = seg.data
1295+
if hasattr(seg_data, 'get'):
1296+
seg_vals = seg_data.get()
1297+
else:
1298+
seg_vals = np.asarray(seg.values)
1299+
goal_py, goal_px = _get_pixel_id(waypoints[j], surface, x, y)
1300+
goal_cost = seg_vals[goal_py, goal_px]
1301+
if np.isfinite(goal_cost):
1302+
dist[i][j] = goal_cost
1303+
1304+
# Fixed endpoints: first=0, last=n-1
1305+
if n <= 12:
1306+
order, _ = _held_karp(dist, 0, n - 1)
1307+
else:
1308+
order, _ = _nearest_neighbor_2opt(dist, 0, n - 1)
1309+
1310+
return [waypoints[i] for i in order]
1311+
1312+
1313+
def multi_stop_search(surface: xr.DataArray,
1314+
waypoints: list,
1315+
barriers: list = [],
1316+
x: Optional[str] = 'x',
1317+
y: Optional[str] = 'y',
1318+
connectivity: int = 8,
1319+
snap: bool = False,
1320+
friction: xr.DataArray = None,
1321+
search_radius: Optional[int] = None,
1322+
optimize_order: bool = False) -> xr.DataArray:
1323+
"""Find the least-cost path visiting a sequence of waypoints in order.
1324+
1325+
Wraps :func:`a_star_search` to route through *N* waypoints,
1326+
stitching segments into a single cumulative-cost surface. When
1327+
``optimize_order=True``, the interior waypoints are reordered to
1328+
minimize total travel cost (TSP), keeping the first and last
1329+
waypoints fixed.
1330+
1331+
Parameters
1332+
----------
1333+
surface : xr.DataArray
1334+
2-D elevation / cost surface.
1335+
waypoints : list of array-like
1336+
Sequence of ``(y, x)`` coordinate pairs to visit. Must contain
1337+
at least two points.
1338+
barriers : list, default=[]
1339+
Surface values that are impassable.
1340+
x, y : str, default ``'x'`` / ``'y'``
1341+
Coordinate dimension names.
1342+
connectivity : int, default=8
1343+
4 or 8 connectivity.
1344+
snap : bool, default=False
1345+
Snap each waypoint to the nearest valid pixel. Not supported
1346+
with dask-backed arrays.
1347+
friction : xr.DataArray, optional
1348+
Friction surface (same shape as *surface*).
1349+
search_radius : int, optional
1350+
Passed to each :func:`a_star_search` call.
1351+
optimize_order : bool, default=False
1352+
Reorder interior waypoints to minimize total cost. Uses exact
1353+
Held-Karp when N <= 12, nearest-neighbor + 2-opt otherwise.
1354+
1355+
Returns
1356+
-------
1357+
xr.DataArray
1358+
Cumulative path cost surface. Attributes include
1359+
``waypoint_order``, ``segment_costs``, and ``total_cost``.
1360+
1361+
Raises
1362+
------
1363+
ValueError
1364+
If the surface is not 2-D, fewer than two waypoints are given,
1365+
waypoints fall outside the surface bounds, or a segment is
1366+
unreachable.
1367+
"""
1368+
# --- Input validation ---
1369+
if surface.ndim != 2:
1370+
raise ValueError("input `surface` must be 2D")
1371+
1372+
if len(waypoints) < 2:
1373+
raise ValueError("at least 2 waypoints are required")
1374+
1375+
for idx, wp in enumerate(waypoints):
1376+
if len(wp) != 2:
1377+
raise ValueError(
1378+
f"waypoint {idx} must have exactly 2 elements (y, x)")
1379+
1380+
h, w = surface.shape
1381+
for idx, wp in enumerate(waypoints):
1382+
py, px = _get_pixel_id(wp, surface, x, y)
1383+
if not _is_inside(py, px, h, w):
1384+
raise ValueError(
1385+
f"waypoint {idx} ({wp}) is outside the surface bounds")
1386+
1387+
if friction is not None and friction.shape != surface.shape:
1388+
raise ValueError("friction must have the same shape as surface")
1389+
1390+
surface_data = surface.data
1391+
_is_dask = da is not None and isinstance(surface_data, da.Array)
1392+
if snap and _is_dask:
1393+
raise ValueError(
1394+
"snap is not supported with dask-backed arrays; "
1395+
"ensure waypoints are valid before calling multi_stop_search")
1396+
1397+
if optimize_order:
1398+
if len(waypoints) < 3:
1399+
warnings.warn(
1400+
"optimize_order has no effect with fewer than 3 waypoints",
1401+
stacklevel=2,
1402+
)
1403+
else:
1404+
waypoints = _optimize_waypoint_order(
1405+
surface, list(waypoints), barriers, x, y,
1406+
connectivity, snap, friction, search_radius,
1407+
)
1408+
1409+
# --- Segment-by-segment routing ---
1410+
path_data = np.full(surface.shape, np.nan, dtype=np.float64)
1411+
cumulative_cost = 0.0
1412+
segment_costs = []
1413+
1414+
# Pre-compute pixel coords for all waypoints
1415+
waypoint_pixels = [_get_pixel_id(wp, surface, x, y) for wp in waypoints]
1416+
1417+
for i in range(len(waypoints) - 1):
1418+
seg = a_star_search(
1419+
surface, waypoints[i], waypoints[i + 1],
1420+
barriers=barriers, x=x, y=y,
1421+
connectivity=connectivity,
1422+
snap_start=snap, snap_goal=snap,
1423+
friction=friction, search_radius=search_radius,
1424+
)
1425+
seg_data = seg.data
1426+
if hasattr(seg_data, 'get'):
1427+
seg_vals = seg_data.get() # cupy -> numpy
1428+
else:
1429+
seg_vals = np.asarray(seg.values)
1430+
1431+
goal_py, goal_px = waypoint_pixels[i + 1]
1432+
1433+
# If snap is on, the actual goal pixel may differ from the
1434+
# requested one. Find the pixel with maximum finite cost
1435+
# (the true goal of this segment).
1436+
if snap and not np.isfinite(seg_vals[goal_py, goal_px]):
1437+
finite = np.isfinite(seg_vals)
1438+
if finite.any():
1439+
max_idx = np.nanargmax(seg_vals)
1440+
goal_py, goal_px = np.unravel_index(max_idx, seg_vals.shape)
1441+
waypoint_pixels[i + 1] = (goal_py, goal_px)
1442+
1443+
seg_goal_cost = seg_vals[goal_py, goal_px]
1444+
1445+
if not np.isfinite(seg_goal_cost):
1446+
raise ValueError(
1447+
f"no path between waypoints {i} and {i + 1}")
1448+
1449+
mask = np.isfinite(seg_vals)
1450+
if i > 0:
1451+
# Don't overwrite the junction pixel (set by previous segment)
1452+
sp_y, sp_x = waypoint_pixels[i]
1453+
mask[sp_y, sp_x] = False
1454+
1455+
path_data[mask] = seg_vals[mask] + cumulative_cost
1456+
segment_costs.append(float(seg_goal_cost))
1457+
cumulative_cost += seg_goal_cost
1458+
1459+
path_agg = xr.DataArray(
1460+
path_data,
1461+
coords=surface.coords,
1462+
dims=surface.dims,
1463+
attrs={
1464+
'waypoint_order': [tuple(wp) for wp in waypoints],
1465+
'segment_costs': segment_costs,
1466+
'total_cost': cumulative_cost,
1467+
},
1468+
)
1469+
1470+
return path_agg

0 commit comments

Comments
 (0)