From a1f269469c6688212bf49939ed569146443cbb80 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 18 Jul 2026 10:36:04 -0400 Subject: [PATCH 1/2] Document NaN propagation in edge_detection docstrings (#3676) --- .claude/sweep-error-handling-state.csv | 1 + xrspatial/edge_detection.py | 35 ++++++++++++++++++++++++++ xrspatial/tests/test_edge_detection.py | 7 ++++-- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.claude/sweep-error-handling-state.csv b/.claude/sweep-error-handling-state.csv index 38d2bfd25..63471c748 100644 --- a/.claude/sweep-error-handling-state.csv +++ b/.claude/sweep-error-handling-state.csv @@ -1,5 +1,6 @@ module,last_inspected,issue,severity_max,categories_found,notes 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)." 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." +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." diff --git a/xrspatial/edge_detection.py b/xrspatial/edge_detection.py index e908800f7..51ce3af28 100644 --- a/xrspatial/edge_detection.py +++ b/xrspatial/edge_detection.py @@ -50,6 +50,13 @@ def sobel_x(agg, name='sobel_x', boundary='nan'): ------- xarray.DataArray Horizontal gradient with the same shape and backend as the input. + + Notes + ----- + NaN cells in the input propagate: every output cell whose 3x3 + neighborhood contains a NaN becomes NaN. With the default + ``boundary='nan'``, the outer one-cell ring of the output is + also NaN. """ _validate_raster(agg, func_name='sobel_x', name='agg') out = convolve_2d(agg.data, SOBEL_X, boundary) @@ -79,6 +86,13 @@ def sobel_y(agg, name='sobel_y', boundary='nan'): ------- xarray.DataArray Vertical gradient with the same shape and backend as the input. + + Notes + ----- + NaN cells in the input propagate: every output cell whose 3x3 + neighborhood contains a NaN becomes NaN. With the default + ``boundary='nan'``, the outer one-cell ring of the output is + also NaN. """ _validate_raster(agg, func_name='sobel_y', name='agg') out = convolve_2d(agg.data, SOBEL_Y, boundary) @@ -108,6 +122,13 @@ def laplacian(agg, name='laplacian', boundary='nan'): ------- xarray.DataArray Laplacian response with the same shape and backend as the input. + + Notes + ----- + NaN cells in the input propagate: every output cell whose 3x3 + neighborhood contains a NaN becomes NaN. With the default + ``boundary='nan'``, the outer one-cell ring of the output is + also NaN. """ _validate_raster(agg, func_name='laplacian', name='agg') out = convolve_2d(agg.data, LAPLACIAN_KERNEL, boundary) @@ -137,6 +158,13 @@ def prewitt_x(agg, name='prewitt_x', boundary='nan'): ------- xarray.DataArray Horizontal gradient with the same shape and backend as the input. + + Notes + ----- + NaN cells in the input propagate: every output cell whose 3x3 + neighborhood contains a NaN becomes NaN. With the default + ``boundary='nan'``, the outer one-cell ring of the output is + also NaN. """ _validate_raster(agg, func_name='prewitt_x', name='agg') out = convolve_2d(agg.data, PREWITT_X, boundary) @@ -166,6 +194,13 @@ def prewitt_y(agg, name='prewitt_y', boundary='nan'): ------- xarray.DataArray Vertical gradient with the same shape and backend as the input. + + Notes + ----- + NaN cells in the input propagate: every output cell whose 3x3 + neighborhood contains a NaN becomes NaN. With the default + ``boundary='nan'``, the outer one-cell ring of the output is + also NaN. """ _validate_raster(agg, func_name='prewitt_y', name='agg') out = convolve_2d(agg.data, PREWITT_Y, boundary) diff --git a/xrspatial/tests/test_edge_detection.py b/xrspatial/tests/test_edge_detection.py index a73e1285f..e298d5b66 100644 --- a/xrspatial/tests/test_edge_detection.py +++ b/xrspatial/tests/test_edge_detection.py @@ -162,8 +162,11 @@ def test_nan_in_input_propagates(self, func): data[2, 3] = np.nan agg = create_test_raster(data, backend='numpy') result = func(agg, boundary='reflect') - # NaN should propagate to neighbors - assert np.isnan(result.data[2, 3]) + # per the docstring: every output cell whose 3x3 neighborhood + # contains the NaN becomes NaN, and no other cell does + expected_nan = np.zeros((5, 6), dtype=bool) + expected_nan[1:4, 2:5] = True + np.testing.assert_array_equal(np.isnan(result.data), expected_nan) @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) def test_all_nan_input(self, func): From 907f8ed692a1f66620d5aa87600d9785addf82dc Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 18 Jul 2026 12:01:16 -0400 Subject: [PATCH 2/2] Address review nit: note boundary-mode extension of NaN neighborhood (#3676) --- xrspatial/edge_detection.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/xrspatial/edge_detection.py b/xrspatial/edge_detection.py index 51ce3af28..334407811 100644 --- a/xrspatial/edge_detection.py +++ b/xrspatial/edge_detection.py @@ -54,9 +54,9 @@ def sobel_x(agg, name='sobel_x', boundary='nan'): Notes ----- NaN cells in the input propagate: every output cell whose 3x3 - neighborhood contains a NaN becomes NaN. With the default - ``boundary='nan'``, the outer one-cell ring of the output is - also NaN. + neighborhood (as extended by the boundary mode) contains a NaN + becomes NaN. With the default ``boundary='nan'``, the outer + one-cell ring of the output is also NaN. """ _validate_raster(agg, func_name='sobel_x', name='agg') out = convolve_2d(agg.data, SOBEL_X, boundary) @@ -90,9 +90,9 @@ def sobel_y(agg, name='sobel_y', boundary='nan'): Notes ----- NaN cells in the input propagate: every output cell whose 3x3 - neighborhood contains a NaN becomes NaN. With the default - ``boundary='nan'``, the outer one-cell ring of the output is - also NaN. + neighborhood (as extended by the boundary mode) contains a NaN + becomes NaN. With the default ``boundary='nan'``, the outer + one-cell ring of the output is also NaN. """ _validate_raster(agg, func_name='sobel_y', name='agg') out = convolve_2d(agg.data, SOBEL_Y, boundary) @@ -126,9 +126,9 @@ def laplacian(agg, name='laplacian', boundary='nan'): Notes ----- NaN cells in the input propagate: every output cell whose 3x3 - neighborhood contains a NaN becomes NaN. With the default - ``boundary='nan'``, the outer one-cell ring of the output is - also NaN. + neighborhood (as extended by the boundary mode) contains a NaN + becomes NaN. With the default ``boundary='nan'``, the outer + one-cell ring of the output is also NaN. """ _validate_raster(agg, func_name='laplacian', name='agg') out = convolve_2d(agg.data, LAPLACIAN_KERNEL, boundary) @@ -162,9 +162,9 @@ def prewitt_x(agg, name='prewitt_x', boundary='nan'): Notes ----- NaN cells in the input propagate: every output cell whose 3x3 - neighborhood contains a NaN becomes NaN. With the default - ``boundary='nan'``, the outer one-cell ring of the output is - also NaN. + neighborhood (as extended by the boundary mode) contains a NaN + becomes NaN. With the default ``boundary='nan'``, the outer + one-cell ring of the output is also NaN. """ _validate_raster(agg, func_name='prewitt_x', name='agg') out = convolve_2d(agg.data, PREWITT_X, boundary) @@ -198,9 +198,9 @@ def prewitt_y(agg, name='prewitt_y', boundary='nan'): Notes ----- NaN cells in the input propagate: every output cell whose 3x3 - neighborhood contains a NaN becomes NaN. With the default - ``boundary='nan'``, the outer one-cell ring of the output is - also NaN. + neighborhood (as extended by the boundary mode) contains a NaN + becomes NaN. With the default ``boundary='nan'``, the outer + one-cell ring of the output is also NaN. """ _validate_raster(agg, func_name='prewitt_y', name='agg') out = convolve_2d(agg.data, PREWITT_Y, boundary)