Skip to content
Merged
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 .claude/sweep-error-handling-state.csv
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ convolution,2026-07-02,,HIGH,1;2;3;4,"convolve_2d/convolution_2d skipped kernel
edge_detection,2026-07-18,3676,MEDIUM,3,"All 5 funcs validate via _validate_raster; convolve_2d validates boundary/kernel; observed failure messages name func/param/offending value, consistent across siblings. MEDIUM(fixed #3676): NaN input propagates to 3x3 footprint, undocumented in docstrings -> added Notes sections + exact-footprint test. LOW(unfixed): (0,0) raster diverges by backend (numpy empty result; dask ValueError 'overlapping depth 1 is larger than your array 0'; cupy CudaAPIError INVALID_VALUE) - engine-level in convolve_2d, adversarial input. LOW(unfixed): int->float32 promotion documented only in convolve_2d docstring. Battery executed on numpy/dask+numpy/cupy/dask+cupy (CUDA present)."
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."
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."
surface_distance,2026-08-16,3711,HIGH,1;2;3;4,"HIGH: max_distance=nan silently backend-divergent - numpy/dask rejection guard (cost_u > max_distance) falls through and runs unbounded (36/36 finite), cupy acceptance guard (best <= max_distance) rejects everything (1/36 finite); no error, no warning. MEDIUM: max_distance<0 all-NaN on numpy/cupy but ValueError 'length should not be negative' from map_overlap on dask. MEDIUM: target_values=1 (scalar) reaches _seed_sources as 0-d -> ~100-line numba TypingError on numpy/dask, 'len() of unsized object' on cupy; neither names the parameter. All three fixed in _compute() reusing proximity()'s guard wording; 26 new parametrized error-path tests over all 4 backends, 63/63 pass. LOW (not fixed, shared utils): 1xN / Nx1 / 1x1 raster -> ZeroDivisionError from utils.calc_res; 0-size raster -> bottleneck nanmin error; elevation checked for shape only so transposed dims or misaligned coords are used silently; proximity(target_values=1) has the same 0-d rough edge. CUDA available, cupy and dask+cupy executed for real."
20 changes: 20 additions & 0 deletions xrspatial/surface_distance.py
Original file line number Diff line number Diff line change
Expand Up @@ -1316,7 +1316,27 @@ def _compute(raster, elevation, x, y, target_values, max_distance,
cellsize_y = abs(float(cellsize_y))

target_values = np.asarray(target_values, dtype=np.float64)
if target_values.ndim != 1:
raise ValueError(
"target_values must be a 1-D sequence of numbers, got "
"{0}-D input {1!r}.".format(target_values.ndim,
target_values.tolist())
)

max_distance_f = float(max_distance)
# NaN defeats both bound tests: the numba kernel's `cost_u >
# max_distance` break and the CUDA kernel's `best <= max_distance`
# accept. The first falls through and searches without a bound, the
# second rejects every relaxation, so the same call returns a full
# distance surface on numpy and a seeds-only raster on cupy. A
# negative budget masks even the zero-distance sources on numpy/cupy
# and reaches dask as a negative map_overlap depth. Reject both,
# matching proximity()'s guard.
if np.isnan(max_distance_f) or max_distance_f < 0:
raise ValueError(
"max_distance must be non-negative, got {0!r}.".format(
max_distance)
)

# Build neighbour offsets
if connectivity == 8:
Expand Down
30 changes: 30 additions & 0 deletions xrspatial/tests/test_surface_distance.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,36 @@ def test_invalid_method():
surface_distance(source, elev, method='fast')


@pytest.mark.parametrize("backend", ['numpy', 'dask+numpy', 'cupy',
'dask+cupy'])
@pytest.mark.parametrize("func", [surface_distance, surface_allocation,
surface_direction])
@pytest.mark.parametrize("bad", [np.nan, -5.0])
def test_invalid_max_distance(backend, func, bad):
"""NaN / negative max_distance must raise, not diverge by backend.

Before this check NaN slipped past the numpy kernel's `cost_u >
max_distance` break (full unbounded surface) but blocked the CUDA
kernel's `best <= max_distance` accept (seeds only), and a negative
budget reached dask as a negative map_overlap depth. See issue #3711.
"""
data = np.zeros((4, 4))
data[0, 0] = 1.0
source = _make_raster(data, backend=backend)
elev = _make_raster(np.zeros((4, 4)), backend=backend)
with pytest.raises(ValueError, match="max_distance must be non-negative"):
func(source, elev, max_distance=bad)


@pytest.mark.parametrize("bad", [1.0, [[1.0, 2.0], [3.0, 4.0]]])
def test_invalid_target_values_shape(bad):
"""Non-1D target_values must be rejected before reaching numba."""
source = _make_raster(np.ones((3, 3)))
elev = _make_raster(np.zeros((3, 3)))
with pytest.raises(ValueError, match="target_values must be a 1-D"):
surface_distance(source, elev, target_values=bad)


# ---------------------------------------------------------------------------
# Tests — dask-specific
# ---------------------------------------------------------------------------
Expand Down
Loading