Skip to content

Commit ebc369b

Browse files
committed
Validate barriers, search_radius, start/goal, and dims in pathfinding (#3649)
String-typed barriers were silently ignored (numba compares float cells to unicode, always False), so a_star_search routed straight through walls the caller asked to block. Negative or float search_radius either returned a silent all-NaN "no path" or crashed deep in slicing code. multi_stop_search with mismatched dim names raised a bare KeyError 'y' where a_star_search raises ValueError, and scalar start/goal points died inside _get_pixel_id. Adds _validate_barriers, _validate_search_radius, _validate_point, and _validate_surface_dims helpers, wires them into both public functions, and improves the dims message to name the actual dims and the x=/y= parameters. Also records the sweep state-CSV row. Claude-Session: https://claude.ai/code/session_0155N4QGamQVxgpAAPbpQNq4
1 parent 3e2b35e commit ebc369b

3 files changed

Lines changed: 191 additions & 7 deletions

File tree

.claude/sweep-error-handling-state.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ module,last_inspected,issue,severity_max,categories_found,notes
22
bump,2026-07-02,,HIGH,1;2;3;4,"bump() agg template unvalidated: plain ndarray -> ArrayTypeFunctionMapping 'Unsupported Array Type'; 3D/1D DataArray -> 'too many values to unpack'. count/spread unvalidated (internal numpy errors / silent). Added _validate_raster(agg,ndim=2) + _validate_scalar for count,spread. all 4 backends verified (CUDA present)."
33
convolution,2026-07-02,,HIGH,1;2;3;4,"convolve_2d/convolution_2d skipped kernel + DataArray validation: None/1D/3D/list kernel -> numba TypingError, even kernel silently off-center (custom_kernel rejects it), numpy agg -> memoryview astype error. Fixed via _validate_kernel + _validate_raster; branch deep-sweep-error-handling-convolution-2026-07-02 pushed to fork; issue/PR create blocked by auto-mode, open from parent. MEDIUM(unfixed): annulus_kernel inner>outer -> cryptic np.pad 'index cant contain negative values'. LOW: circle_kernel cellsize=0 ZeroDivisionError, cellsize<0 cryptic linspace; calc_cellsize non-DataArray -> AttributeError attrs. cupy verified."
44
geotiff,2026-07-02,3604,MEDIUM,2;4,"to_geotiff 0D/1D DataArray raised opaque IndexError from _coords.py coords_to_transform (dims[-2]) instead of clean 'Expected 2D or 3D' ValueError; numpy path + 4D DataArray already clean. Fixed via early ndim guard before dispatch (eager/vrt/gpu) + 3 tests; PR #3604. Read-side param validation + typed-error hierarchy + allow_rotated/allow_invalid_nodata VRT+chunked opt-in threading verified clean (CUDA available, GPU paths run). gh issue create blocked by auto-mode; PR opened. Cat 2+4."
5+
pathfinding,2026-07-08,3649,CRITICAL,1;2;3;4,"CRITICAL: string barriers (['0']) silently ignored -> path crosses wall, no error/warning (numba float==unicode always False); scalar barriers=0 -> numba TypingError. HIGH: search_radius unvalidated: -1 silently all-NaN 'no path' (numpy+cupy), other geometries 'negative dimensions are not allowed'; float radius -> slice TypeError; via multi_stop -> misleading 'no path between waypoints'. MEDIUM: multi_stop_search missing dims check -> bare KeyError 'y' vs a_star ValueError; scalar start/goal -> 'float object is not subscriptable' in _get_pixel_id. All fixed: _validate_barriers/_validate_search_radius/_validate_point/_validate_surface_dims + tests. LOW (unfixed): (0,0)-size raster -> 'zero-size array to reduction' from calc_res. All 4 backends executed (CUDA present). Battery: numpy/dask/cupy/dask+cupy."

xrspatial/pathfinding.py

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,71 @@
3030
_MAX_WAYPOINTS = 1000
3131

3232

33+
def _validate_surface_dims(surface, x, y, func_name):
34+
"""Raise ValueError if *surface* dims do not match the (y, x) names."""
35+
if surface.dims != (y, x):
36+
raise ValueError(
37+
f"{func_name}(): expected `surface` to have dims ({y!r}, {x!r}), "
38+
f"got {surface.dims}. Pass the actual dimension names via the "
39+
f"`x=` and `y=` parameters."
40+
)
41+
42+
43+
def _validate_barriers(barriers):
44+
"""Coerce *barriers* to a 1-D float64 array or raise a clear error.
45+
46+
Numba compares the (float) cell value against each barrier element,
47+
so a non-numeric dtype would either fail deep inside the kernel with
48+
a TypingError (0-d input) or, worse, compare unequal everywhere and
49+
silently disable all barriers (string input).
50+
"""
51+
arr = np.asarray(barriers)
52+
if arr.ndim != 1:
53+
hint = (f" Did you mean barriers=[{barriers!r}]?"
54+
if arr.ndim == 0 else "")
55+
raise ValueError(
56+
f"barriers must be a 1-D list or array of cell values, "
57+
f"got {arr.ndim}-D input.{hint}"
58+
)
59+
if arr.size > 0 and not (
60+
np.issubdtype(arr.dtype, np.integer)
61+
or np.issubdtype(arr.dtype, np.floating)
62+
):
63+
raise TypeError(
64+
f"barriers must contain numeric cell values, "
65+
f"got dtype {arr.dtype} from {barriers!r}"
66+
)
67+
return arr.astype(np.float64)
68+
69+
70+
def _validate_search_radius(search_radius):
71+
"""Raise if *search_radius* is not None or a non-negative integer."""
72+
if search_radius is None:
73+
return
74+
if not isinstance(search_radius, (int, np.integer)):
75+
raise TypeError(
76+
f"search_radius must be a non-negative integer or None, "
77+
f"got {type(search_radius).__name__} {search_radius!r}"
78+
)
79+
if search_radius < 0:
80+
raise ValueError(
81+
f"search_radius must be non-negative, got {search_radius}"
82+
)
83+
84+
85+
def _validate_point(point, name, func_name):
86+
"""Raise ValueError if *point* is not a 2-element (y, x) pair."""
87+
try:
88+
n = len(point)
89+
except TypeError:
90+
n = None
91+
if n != 2:
92+
raise ValueError(
93+
f"{func_name}(): `{name}` must have exactly 2 elements (y, x), "
94+
f"got {point!r}"
95+
)
96+
97+
3398
def _get_pixel_id(point, raster, xdim=None, ydim=None):
3499
# get location in `raster` pixel space for `point` in y-x coordinate space
35100
# point: (y, x) - coordinates of the point
@@ -930,13 +995,15 @@ def a_star_search(surface: xr.DataArray,
930995
_validate_raster(friction, func_name='a_star_search',
931996
name='friction', ndim=2)
932997

933-
if surface.dims != (y, x):
934-
raise ValueError("`surface.coords` should be named as coordinates:"
935-
"({}, {})".format(y, x))
998+
_validate_surface_dims(surface, x, y, 'a_star_search')
936999

9371000
if connectivity != 4 and connectivity != 8:
9381001
raise ValueError("Use either 4 or 8-connectivity.")
9391002

1003+
_validate_search_radius(search_radius)
1004+
_validate_point(start, 'start', 'a_star_search')
1005+
_validate_point(goal, 'goal', 'a_star_search')
1006+
9401007
# Detect backend
9411008
surface_data = surface.data
9421009
_is_dask = da is not None and isinstance(surface_data, da.Array)
@@ -964,7 +1031,7 @@ def a_star_search(surface: xr.DataArray,
9641031
if not _is_inside(goal_py, goal_px, h, w):
9651032
raise ValueError("goal location outside the surface graph.")
9661033

967-
barriers = np.asarray(barriers)
1034+
barriers = _validate_barriers(barriers)
9681035

9691036
# --- Snap / crossability checks ---
9701037
if _is_dask:
@@ -1425,6 +1492,8 @@ def multi_stop_search(surface: xr.DataArray,
14251492
_validate_raster(friction, func_name='multi_stop_search',
14261493
name='friction', ndim=2)
14271494

1495+
_validate_surface_dims(surface, x, y, 'multi_stop_search')
1496+
14281497
if len(waypoints) < 2:
14291498
raise ValueError("at least 2 waypoints are required")
14301499

@@ -1436,9 +1505,7 @@ def multi_stop_search(surface: xr.DataArray,
14361505
)
14371506

14381507
for idx, wp in enumerate(waypoints):
1439-
if len(wp) != 2:
1440-
raise ValueError(
1441-
f"waypoint {idx} must have exactly 2 elements (y, x)")
1508+
_validate_point(wp, f'waypoint {idx}', 'multi_stop_search')
14421509

14431510
h, w = surface.shape
14441511
for idx, wp in enumerate(waypoints):

xrspatial/tests/test_pathfinding.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,3 +1195,119 @@ def test_multi_stop_caps_waypoints(self):
11951195
too_many = [(i % 10, (i * 7) % 10) for i in range(_MAX_WAYPOINTS + 1)]
11961196
with pytest.raises(ValueError, match=f"at most {_MAX_WAYPOINTS}"):
11971197
multi_stop_search(s, too_many)
1198+
1199+
1200+
class TestPathfindingErrorHandling:
1201+
"""Error-handling sweep findings: barriers, search_radius, points, dims (#3649)."""
1202+
1203+
@staticmethod
1204+
def _wall_surface():
1205+
import xarray as xr
1206+
data = np.ones((5, 9))
1207+
data[:, 4] = 0.0 # wall of zeros across the middle column
1208+
r = xr.DataArray(data, dims=('y', 'x'), attrs={'res': (1.0, 1.0)})
1209+
r['y'] = np.linspace(4, 0, 5)
1210+
r['x'] = np.linspace(0, 8, 9)
1211+
return r
1212+
1213+
# --- barriers ---
1214+
1215+
def test_string_barriers_raise_instead_of_silently_ignored(self):
1216+
# barriers=['0'] used to route straight through a wall that
1217+
# barriers=[0] blocks, with no error or warning
1218+
r = self._wall_surface()
1219+
with pytest.raises(TypeError, match="barriers must contain numeric"):
1220+
a_star_search(r, (2.0, 0.0), (2.0, 8.0), barriers=['0'])
1221+
1222+
def test_scalar_barriers_raise_with_hint(self):
1223+
# barriers=0 used to die with a numba TypingError deep in the kernel
1224+
r = self._wall_surface()
1225+
with pytest.raises(ValueError, match=r"1-D.*barriers=\[0\]"):
1226+
a_star_search(r, (2.0, 0.0), (2.0, 8.0), barriers=0)
1227+
1228+
def test_numeric_barriers_still_block(self):
1229+
r = self._wall_surface()
1230+
path = a_star_search(r, (2.0, 0.0), (2.0, 8.0),
1231+
barriers=np.array([0]))
1232+
assert not np.isfinite(path.values).any()
1233+
1234+
@pytest.mark.skipif(not has_dask_array(), reason="Requires dask.Array")
1235+
def test_string_barriers_raise_on_dask(self):
1236+
r = self._wall_surface()
1237+
r.data = da.from_array(r.data, chunks=(3, 3))
1238+
with pytest.raises(TypeError, match="barriers must contain numeric"):
1239+
a_star_search(r, (2.0, 0.0), (2.0, 8.0), barriers=['0'])
1240+
1241+
# --- search_radius ---
1242+
1243+
def test_negative_search_radius_raises(self):
1244+
# used to silently return all-NaN ("no path") though a path exists
1245+
r = self._wall_surface()
1246+
with pytest.raises(ValueError, match="search_radius must be non-negative"):
1247+
a_star_search(r, (2.0, 0.0), (2.0, 8.0), search_radius=-1)
1248+
1249+
def test_float_search_radius_raises(self):
1250+
# used to crash with "slice indices must be integers" for some
1251+
# start/goal geometries and work for others
1252+
r = self._wall_surface()
1253+
with pytest.raises(TypeError, match="search_radius must be a non-negative integer"):
1254+
a_star_search(r, (2.0, 0.0), (2.0, 8.0), search_radius=2.5)
1255+
1256+
def test_zero_search_radius_accepted(self):
1257+
r = self._wall_surface()
1258+
path = a_star_search(r, (2.0, 0.0), (2.0, 2.0), search_radius=0)
1259+
assert np.isfinite(path.values[2, 2])
1260+
1261+
def test_negative_search_radius_raises_in_multi_stop(self):
1262+
# used to surface as a misleading "no path between waypoints 0 and 1"
1263+
r = self._wall_surface()
1264+
with pytest.raises(ValueError, match="search_radius must be non-negative"):
1265+
multi_stop_search(r, [(2.0, 0.0), (2.0, 2.0)], search_radius=-1)
1266+
1267+
# --- start / goal ---
1268+
1269+
def test_scalar_start_raises(self):
1270+
# used to raise "'float' object is not subscriptable" in _get_pixel_id
1271+
r = self._wall_surface()
1272+
with pytest.raises(ValueError, match="`start` must have exactly 2 elements"):
1273+
a_star_search(r, 3.0, (2.0, 2.0))
1274+
1275+
def test_three_element_goal_raises(self):
1276+
# used to silently drop the extra element
1277+
r = self._wall_surface()
1278+
with pytest.raises(ValueError, match="`goal` must have exactly 2 elements"):
1279+
a_star_search(r, (2.0, 0.0), (2.0, 2.0, 7.0))
1280+
1281+
def test_scalar_waypoint_raises(self):
1282+
# used to raise "object of type 'float' has no len()"
1283+
r = self._wall_surface()
1284+
with pytest.raises(ValueError, match="`waypoint 1` must have exactly 2 elements"):
1285+
multi_stop_search(r, [(2.0, 0.0), 3.0])
1286+
1287+
# --- dims consistency ---
1288+
1289+
@staticmethod
1290+
def _latlon_surface():
1291+
import xarray as xr
1292+
r = xr.DataArray(np.ones((5, 5)), dims=('lat', 'lon'),
1293+
attrs={'res': (1.0, 1.0)})
1294+
r['lat'] = np.linspace(4, 0, 5)
1295+
r['lon'] = np.linspace(0, 4, 5)
1296+
return r
1297+
1298+
def test_a_star_dims_mismatch_names_actual_dims(self):
1299+
r = self._latlon_surface()
1300+
with pytest.raises(ValueError, match=r"got \('lat', 'lon'\)"):
1301+
a_star_search(r, (2.0, 0.0), (2.0, 4.0))
1302+
1303+
def test_multi_stop_dims_mismatch_raises_value_error(self):
1304+
# used to raise a bare KeyError: 'y' from inside xarray
1305+
r = self._latlon_surface()
1306+
with pytest.raises(ValueError, match="expected `surface` to have dims"):
1307+
multi_stop_search(r, [(2.0, 0.0), (2.0, 4.0)])
1308+
1309+
def test_multi_stop_custom_dims_still_work(self):
1310+
r = self._latlon_surface()
1311+
result = multi_stop_search(r, [(2.0, 0.0), (2.0, 4.0)],
1312+
x='lon', y='lat')
1313+
assert np.isfinite(result.values).any()

0 commit comments

Comments
 (0)