diff --git a/.claude/sweep-error-handling-state.csv b/.claude/sweep-error-handling-state.csv index 63471c748..5a6e7e98c 100644 --- a/.claude/sweep-error-handling-state.csv +++ b/.claude/sweep-error-handling-state.csv @@ -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." diff --git a/xrspatial/surface_distance.py b/xrspatial/surface_distance.py index c91aeb13e..d91597e1f 100644 --- a/xrspatial/surface_distance.py +++ b/xrspatial/surface_distance.py @@ -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: diff --git a/xrspatial/tests/test_surface_distance.py b/xrspatial/tests/test_surface_distance.py index 74ae62fbd..c18675529 100644 --- a/xrspatial/tests/test_surface_distance.py +++ b/xrspatial/tests/test_surface_distance.py @@ -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 # ---------------------------------------------------------------------------