From 95b31a5ea2be1da832bd152b418fbe0522404aeb Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 18 Jul 2026 12:04:29 -0400 Subject: [PATCH 1/3] Add edge_detection coverage: Inf, boundary NaN on all backends, degenerate shapes, multi-chunk dask (#3682) --- xrspatial/tests/test_edge_detection.py | 184 +++++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/xrspatial/tests/test_edge_detection.py b/xrspatial/tests/test_edge_detection.py index a73e1285f..d4ef09289 100644 --- a/xrspatial/tests/test_edge_detection.py +++ b/xrspatial/tests/test_edge_detection.py @@ -271,3 +271,187 @@ def test_numpy_equals_dask_cupy(self, ramp_data, func): np_agg = create_test_raster(ramp_data, backend='numpy') dcu_agg = create_test_raster(ramp_data, backend='dask+cupy', chunks=(5, 6)) assert_numpy_equals_dask_cupy(np_agg, dcu_agg, func) + + +# --------------------------------------------------------------------------- +# Multi-chunk dask (#3682) -- the tests above use a single chunk equal to +# the full array, which never exercises chunk-boundary stitching in the +# map_overlap path. +# --------------------------------------------------------------------------- + +@dask_array_available +class TestDaskMultiChunk: + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + def test_numpy_equals_dask_multichunk(self, func): + data = np.random.RandomState(3682).rand(9, 11).astype(np.float64) + np_agg = create_test_raster(data, backend='numpy') + da_agg = create_test_raster(data, backend='dask+numpy', chunks=(4, 5)) + assert_numpy_equals_dask_numpy(np_agg, da_agg, func) + + @cuda_and_cupy_available + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + def test_numpy_equals_dask_cupy_multichunk(self, func): + from xrspatial.tests.general_checks import assert_numpy_equals_dask_cupy + data = np.random.RandomState(3682).rand(9, 11).astype(np.float64) + np_agg = create_test_raster(data, backend='numpy') + dcu_agg = create_test_raster(data, backend='dask+cupy', chunks=(4, 5)) + assert_numpy_equals_dask_cupy(np_agg, dcu_agg, func) + + +# --------------------------------------------------------------------------- +# NaN at the raster edge, on every backend and boundary mode (#3682) -- +# earlier NaN tests only place a NaN at an interior cell on the numpy +# backend. NaN-input backend divergence has shipped before (kde, #3628). +# --------------------------------------------------------------------------- + +def _nan_edge_data(): + data = np.random.RandomState(42).rand(8, 10).astype(np.float64) + data[0, 0] = np.nan # corner: interacts with boundary padding + data[3, 4] = np.nan # interior + return data + + +class TestNanAtBoundary: + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + @pytest.mark.parametrize('boundary', ['nan', 'nearest', 'reflect', 'wrap']) + def test_corner_nan_propagates(self, func, boundary): + result = func(create_test_raster(_nan_edge_data()), boundary=boundary) + # the corner NaN reaches its kernel neighborhood + assert np.isnan(result.data[1, 1]) + # cells outside both NaN neighborhoods stay finite + assert np.isfinite(result.data[6, 7]) + + @dask_array_available + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + @pytest.mark.parametrize('boundary', ['nan', 'nearest', 'reflect', 'wrap']) + def test_numpy_equals_dask_multichunk(self, func, boundary): + data = _nan_edge_data() + expected = func(create_test_raster(data), boundary=boundary) + da_agg = create_test_raster(data, backend='dask+numpy', chunks=(3, 4)) + result = func(da_agg, boundary=boundary) + np.testing.assert_allclose( + expected.data, result.data.compute(), equal_nan=True) + + @cuda_and_cupy_available + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + @pytest.mark.parametrize('boundary', ['nan', 'nearest', 'reflect', 'wrap']) + def test_numpy_equals_cupy(self, func, boundary): + data = _nan_edge_data() + expected = func(create_test_raster(data), boundary=boundary) + cu_agg = create_test_raster(data, backend='cupy') + result = func(cu_agg, boundary=boundary) + np.testing.assert_allclose( + expected.data, result.data.get(), equal_nan=True) + + @cuda_and_cupy_available + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + @pytest.mark.parametrize('boundary', ['nan', 'nearest', 'reflect', 'wrap']) + def test_numpy_equals_dask_cupy(self, func, boundary): + data = _nan_edge_data() + expected = func(create_test_raster(data), boundary=boundary) + dcu_agg = create_test_raster(data, backend='dask+cupy', chunks=(3, 4)) + result = func(dcu_agg, boundary=boundary) + np.testing.assert_allclose( + expected.data, result.data.compute().get(), equal_nan=True) + + +# --------------------------------------------------------------------------- +# Inf handling (#3682) -- an inf cell propagates +/-inf where the kernel +# weight has a single sign and NaN where inf meets -inf (or a zero weight). +# --------------------------------------------------------------------------- + +inf = np.inf +nan = np.nan + + +class TestInfHandling: + @pytest.mark.parametrize('func,expected_block', [ + (sobel_x, [[inf, nan, -inf], [inf, nan, -inf], [inf, nan, -inf]]), + (sobel_y, [[inf, inf, inf], [nan, nan, nan], [-inf, -inf, -inf]]), + (prewitt_x, [[inf, nan, -inf], [inf, nan, -inf], [inf, nan, -inf]]), + (prewitt_y, [[inf, inf, inf], [nan, nan, nan], [-inf, -inf, -inf]]), + (laplacian, [[nan, inf, nan], [inf, -inf, inf], [nan, inf, nan]]), + ]) + def test_inf_neighborhood(self, func, expected_block): + data = np.ones((5, 6), dtype=np.float64) + data[2, 3] = np.inf + result = func(create_test_raster(data), boundary='reflect') + # 3x3 neighborhood centred on the inf cell + np.testing.assert_array_equal(result.data[1:4, 2:5], expected_block) + # cells outside the neighborhood are unaffected: constant field -> 0 + assert result.data[0, 0] == 0 + assert result.data[4, 0] == 0 + + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + def test_negative_inf(self, func): + data = np.ones((5, 6), dtype=np.float64) + data[2, 3] = -np.inf + result = func(create_test_raster(data), boundary='reflect') + block = result.data[1:4, 2:5] + assert np.all(np.isinf(block) | np.isnan(block)) + assert result.data[0, 0] == 0 + + +# --------------------------------------------------------------------------- +# Degenerate shapes (#3682) -- 1x1, strips, and empty rasters. +# --------------------------------------------------------------------------- + +class TestDegenerateShapes: + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + def test_1x1_nan_boundary(self, func): + agg = create_test_raster(np.array([[5.0]])) + result = func(agg) + assert result.shape == (1, 1) + assert np.isnan(result.data[0, 0]) + + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + def test_1x1_reflect(self, func): + agg = create_test_raster(np.array([[5.0]])) + result = func(agg, boundary='reflect') + # reflected padding makes the neighborhood constant -> zero response + np.testing.assert_array_equal(result.data, [[0.0]]) + + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + def test_row_strip(self, func): + data = np.arange(6, dtype=np.float64).reshape(1, 6) + result_nan = func(create_test_raster(data)) + assert result_nan.shape == (1, 6) + assert np.all(np.isnan(result_nan.data)) + result_reflect = func(create_test_raster(data), boundary='reflect') + assert not np.any(np.isnan(result_reflect.data)) + + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + def test_column_strip(self, func): + data = np.arange(6, dtype=np.float64).reshape(6, 1) + result = func(create_test_raster(data), boundary='reflect') + assert result.shape == (6, 1) + assert not np.any(np.isnan(result.data)) + + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + def test_empty_raster(self, func): + agg = xr.DataArray(np.zeros((0, 5), dtype=np.float64), dims=['y', 'x']) + result = func(agg) + assert result.shape == (0, 5) + + +# --------------------------------------------------------------------------- +# Invalid boundary and dim-name propagation (#3682) +# --------------------------------------------------------------------------- + +class TestBoundaryValidation: + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + def test_invalid_boundary_raises(self, func): + agg = create_test_raster(np.ones((5, 6), dtype=np.float64)) + with pytest.raises(ValueError, match='boundary must be one of'): + func(agg, boundary='invalid') + + +class TestDimNamePropagation: + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + def test_custom_dims_preserved(self, func): + agg = create_test_raster(np.ones((5, 6), dtype=np.float64), + dims=['lat', 'lon']) + result = func(agg) + assert result.dims == ('lat', 'lon') + np.testing.assert_allclose(result['lat'].data, agg['lat'].data) + np.testing.assert_allclose(result['lon'].data, agg['lon'].data) From ff26b4c937af16f51177683175e334a23659e3ef Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 18 Jul 2026 12:04:48 -0400 Subject: [PATCH 2/3] Update test-coverage sweep state for edge_detection (#3682) --- .claude/sweep-test-coverage-state.csv | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 35a3980e1..c57ee8e5c 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -7,6 +7,7 @@ corridor,2026-06-22,,HIGH,1;3;4;5,,"Deep-sweep 2026-06-22 test-coverage on a CUD cost_distance,2026-06-16,3367,MEDIUM,1;2,,"Pass (2026-06-16 deep-sweep test-coverage, CUDA host). cost_distance is heavily tested: 1122 test lines for 1354 src lines, all 4 backends parametrized + regression tests for #1191/#880/#1252/#1262/#3340/#3341/#3343/#3344. Found one MEDIUM Cat 1+2 gap: _cost_distance_dask f_min<=0 early return (all-impassable friction, finite max_cost -> da.full NaN preserving chunks) was unreached -- numpy equiv covered by test_source_on_impassable_cell, iterative dask by test_iterative_narrow_corridor, but the bounded map_overlap wrapper shortcut was not. Filed #3367, added test_dask_all_impassable_friction_returns_nan (all-zero friction, dask+numpy chunks(3,3), max_cost=5; asserts all-NaN, dask-backed, npartitions>1). RAN + PASSED; -W error::UserWarning confirms early return taken (no iterative warning). Full file 85 passed on CUDA host. LOW (documented, not fixed): non-square cellsize numeric correctness untested (_make_meta_raster uses res=(2,3) but test_metadata_preserved checks metadata only)." dasymetric,2026-06-20,3407;3406,HIGH,2;3;4;5,,"deep-sweep test-coverage on a CUDA host (CUDA available, GPU tests ran). Module is well covered (813 test loc / 834 src): 4-backend equivalence for disaggregate weighted+binary, conservation, NaN/nodata/negative-weight, limiting_variable + cupy/dask NotImplemented guards, pycnophylactic numpy+cupy+dask-raises, validate_disaggregation all backends, memory guards (#1261). Filed #3407 (test-only) for real gaps and added 4 new test classes (11 passed, 2 xfailed). Cat5 HIGH: metadata (attrs res/crs + coords) never asserted -> TestMetadataPreservation (numpy/dask). Cat3 HIGH: true 1x1 raster untested (only 1x2 strip) -> TestSinglePixel for disaggregate weighted/binary + pycnophylactic (degenerate no-shift smoothing) + dask parity. Cat2 MEDIUM: Inf weight collapses zone total to 0 (silent conservation break) -> TestInfWeight pins current behaviour. Cat4 MEDIUM: 3-class limiting_variable (multi-break + per-class caps) untested despite docstring -> TestLimitingVariableThreeClass. SOURCE BUG found (filed #3406, NOT fixed - test-only sweep): pycnophylactic raises ValueError (np.nanmax on zero-size array) when no pixel is valid for smoothing (all-NaN zones or no zone id in values); disaggregate handles same input gracefully (all-NaN). Pinned with TestPycnophylacticEmptyValid xfail(strict, raises=ValueError) -> flips red when #3406 fixed. LOW (documented, not fixed): non-square cellsize never exercised (all tests use res 0.5/0.5); disaggregate cupy/dask+cupy 1x1 + metadata not separately added (eager numpy gap was the real one, GPU dispatch already covered by TestCrossBackend)." diffusion,2026-06-20,3422,HIGH,1;2;3;4,,"Pass 1 (2026-06-20, deep-sweep test-coverage, CUDA host). diffuse() dispatch table registers all 4 backends but test_diffusion.py only exercised numpy + dask+numpy. Cat 1 HIGH: cupy (_diffuse_cupy/_diffuse_step_gpu) and dask+cupy (_diffuse_dask_cupy/_diffuse_chunk_cupy) registered but never invoked -- no test ran them. Cat 4 HIGH: boundary accepts nan/nearest/reflect/wrap; only nearest+wrap tested, reflect had none. Cat 3 HIGH: 1x1 single-pixel and Nx1/1xN strip rasters never tested. Cat 2 MEDIUM: NaN tested numpy-only; Inf and all-NaN inputs untested. Filed #3422, added 14 tests (PR #3424, test-only, source untouched): cupy/dask+cupy parity vs numpy (incl. spatially-varying alpha + NaN propagation), reflect boundary across all 4 backends, 1x1 + Nx1 + 1xN (numpy + chunked dask strip), all-NaN stays NaN, Inf contamination smoke test. All 14 RAN+PASSED on a CUDA host; the 4 cupy/dask+cupy tests genuinely executed (not skipped); full file 39 passed. All paths verified correct before the tests were added -- coverage gap, not a bug. LOW (documented, not fixed): non-square cellsize (res[0]!=res[1]) never exercised -- diffuse uses res[0] as dx and assumes square cells; empty 0-row/0-col raster untested; asv benchmark absent; 'nan' boundary-mode edge=NaN behaviour not directly asserted on diffuse (covered indirectly via wrap/nearest)." +edge_detection,2026-07-18,3682,HIGH,1;2;3;4;5,100,"no source bugs; input-space gaps only: Inf untested, NaN only interior/numpy, no 1x1/strip/empty, invalid-boundary error path, dask single-chunk only, dim-name passthrough; 135 tests added (87->222), GPU paths executed locally; benchmarks gap deferred to sibling #3672" fire,2026-06-25,,HIGH,2,,"Deep-sweep 2026-06-25 test-coverage on a CUDA host. Backend matrix already complete: all 7 public funcs (dnbr/rdnbr/burn_severity_class/fireline_intensity/flame_length/rate_of_spread/kbdi) x 4 backends present and green (Cat 1 no gap). NaN covered (per-func nan_propagation + #3394 dtype parity). Cat 4 covered: rate_of_spread tests all 13 fuel models + invalid 0/14; kbdi annual_precip invalid 0/-100; fireline heat_content default+custom. Cat 5 covered via general_output_checks on every func. Found one gap: Cat 2 +Inf/-Inf inputs were untested on every function. Probed all 4 backends live: behavior is fully consistent and well-defined (no divergence, no bug) -- e.g. dnbr inf-inf->nan, burn_severity_class +inf->7/-inf->1, kbdi prev=inf clamps to 800, rate_of_spread slope=inf->nan. Added test-only regression: per-func numpy Inf contract (locks exact values) + 4-backend Inf parity (28 new tests, all RAN and PASSED on GPU). No source change; the kernels' only finite guard is v!=v so these lock that contract. Cat 3 1x1/strip: per-pixel kernels (no neighborhood window) so no degeneracy risk, and 1x1/1xN already exercised by kbdi/rdnbr/flame tests -> LOW, not added." flood,2026-06-25,,MEDIUM,1,,"Deep-sweep 2026-06-25 test-coverage on a CUDA host. Module is densely tested (1051 test LOC vs 966 source). Backend matrix nearly complete: all 7 public funcs x 4 backends present and green EXCEPT vegetation_roughness mode='ndvi' on dask+cupy -- _veg_roughness_ndvi_dask_cupy was dispatched (flood.py:585) but never invoked by any test (nlcd dask+cupy, ndvi cupy, ndvi dask all tested). Cat 1 MEDIUM: added TestVegRoughnessDaskCuPy::test_ndvi_numpy_equals_dask_cupy mirroring the nlcd case; GPU-validated locally (passed, full file 89 passed). Cat 2 NaN well covered per-func incl #1104 (NaN curve_number) and #1437 (mannings_n DataArray) regressions; Inf inputs untested but low-risk (HAND/rainfall Inf -> NaN), not flagged. Cat 3 1xN strips + 1x1 covered for several funcs. Cat 5 metadata preserved is asserted on every backend test via general_output_checks (verify_attrs defaults True), so inundation/curve_number_runoff lacking a dedicated coords test is NOT a real gap. No source bugs found." focal,2026-06-10,3220;3219;3225,HIGH,1;2;3;4,,"Deep-sweep 2026-06-10 on CUDA host, all 4 backends executed. Filed #3220 (coverage) and added 36 tests in PR branch: Inf inputs for mean/focal_stats (HIGH Cat2 - no Inf test existed anywhere), mean NaN input (HIGH Cat2 - default excludes=[nan] semantics never asserted), 1x1 + 1xN/Nx1 strips (HIGH Cat3), empty 0-row raster numpy-only (MEDIUM Cat3), mean passes=2 == mean(mean) and excludes sentinel -9999 behavioral tests (MEDIUM Cat4), dask+cupy non-default boundary modes for mean/apply/focal_stats (MEDIUM Cat1/4). Bugs surfaced, filed separately (NOT fixed here): #3219 hotspots silently returns all zeros on Inf input (nan global std passes the std==0 guard, all 4 backends); #3225 empty raster works on numpy but crashes cupy (raw CudaAPIError) and dask (map_overlap depth ValueError). hotspots+Inf and non-numpy empty behavior left unpinned until those are fixed. Backend matrix for the 4 public funcs was already solid (all 4 backends + parity); boundary modes covered except dask+cupy. Siblings filed #3214-3217 same day (dtype/docstring/apply-default-func) - no overlap." From 65687d4a4cd52b209c1fedd430172ce0537f7012 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 18 Jul 2026 12:07:17 -0400 Subject: [PATCH 3/3] Address review: exact -inf expectations, int32 promotion test, drop inf/nan aliases (#3682) --- xrspatial/tests/test_edge_detection.py | 42 ++++++++++++++++++-------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/xrspatial/tests/test_edge_detection.py b/xrspatial/tests/test_edge_detection.py index d4ef09289..d406995ac 100644 --- a/xrspatial/tests/test_edge_detection.py +++ b/xrspatial/tests/test_edge_detection.py @@ -360,18 +360,22 @@ def test_numpy_equals_dask_cupy(self, func, boundary): # weight has a single sign and NaN where inf meets -inf (or a zero weight). # --------------------------------------------------------------------------- -inf = np.inf -nan = np.nan +# per-operator expected 3x3 neighborhood around a +inf cell in a field of +# ones: +/-inf where the kernel weight over the inf cell has a single sign, +# NaN where the weight is zero (0 * inf) or opposing infs meet. +INF_NEIGHBORHOODS = [ + (sobel_x, [[np.inf, np.nan, -np.inf]] * 3), + (sobel_y, [[np.inf] * 3, [np.nan] * 3, [-np.inf] * 3]), + (prewitt_x, [[np.inf, np.nan, -np.inf]] * 3), + (prewitt_y, [[np.inf] * 3, [np.nan] * 3, [-np.inf] * 3]), + (laplacian, [[np.nan, np.inf, np.nan], + [np.inf, -np.inf, np.inf], + [np.nan, np.inf, np.nan]]), +] class TestInfHandling: - @pytest.mark.parametrize('func,expected_block', [ - (sobel_x, [[inf, nan, -inf], [inf, nan, -inf], [inf, nan, -inf]]), - (sobel_y, [[inf, inf, inf], [nan, nan, nan], [-inf, -inf, -inf]]), - (prewitt_x, [[inf, nan, -inf], [inf, nan, -inf], [inf, nan, -inf]]), - (prewitt_y, [[inf, inf, inf], [nan, nan, nan], [-inf, -inf, -inf]]), - (laplacian, [[nan, inf, nan], [inf, -inf, inf], [nan, inf, nan]]), - ]) + @pytest.mark.parametrize('func,expected_block', INF_NEIGHBORHOODS) def test_inf_neighborhood(self, func, expected_block): data = np.ones((5, 6), dtype=np.float64) data[2, 3] = np.inf @@ -382,13 +386,14 @@ def test_inf_neighborhood(self, func, expected_block): assert result.data[0, 0] == 0 assert result.data[4, 0] == 0 - @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) - def test_negative_inf(self, func): + @pytest.mark.parametrize('func,expected_block', INF_NEIGHBORHOODS) + def test_negative_inf(self, func, expected_block): data = np.ones((5, 6), dtype=np.float64) data[2, 3] = -np.inf result = func(create_test_raster(data), boundary='reflect') - block = result.data[1:4, 2:5] - assert np.all(np.isinf(block) | np.isnan(block)) + # a -inf cell produces the +inf expected block negated (NaN unchanged) + np.testing.assert_array_equal( + result.data[1:4, 2:5], np.negative(expected_block)) assert result.data[0, 0] == 0 @@ -446,6 +451,17 @@ def test_invalid_boundary_raises(self, func): func(agg, boundary='invalid') +class TestIntegerDtype: + @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) + def test_int32_promotes_to_float32(self, func): + data = np.arange(30, dtype=np.int32).reshape(5, 6) + result = func(create_test_raster(data)) + assert result.dtype == np.float32 + # values match the float64-input result for these small integers + expected = func(create_test_raster(data.astype(np.float64))) + np.testing.assert_allclose(result.data, expected.data, equal_nan=True) + + class TestDimNamePropagation: @pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y]) def test_custom_dims_preserved(self, func):