Skip to content

Commit c9f7a84

Browse files
authored
Add edge_detection tests for Inf, boundary NaN, degenerate shapes, and multi-chunk dask (#3685)
1 parent cd09e9c commit c9f7a84

2 files changed

Lines changed: 190 additions & 0 deletions

File tree

.claude/sweep-test-coverage-state.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ corridor,2026-06-22,,HIGH,1;3;4;5,,"Deep-sweep 2026-06-22 test-coverage on a CUD
77
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)."
88
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)."
99
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)."
10+
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"
1011
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."
1112
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."
1213
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."

xrspatial/tests/test_edge_detection.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,195 @@ def test_numpy_equals_dask_cupy(self, ramp_data, func):
348348
assert_numpy_equals_dask_cupy(np_agg, dcu_agg, func)
349349

350350

351+
# ---------------------------------------------------------------------------
352+
# Multi-chunk dask (#3682) -- the tests above use a single chunk equal to
353+
# the full array, which never exercises chunk-boundary stitching in the
354+
# map_overlap path.
355+
# ---------------------------------------------------------------------------
356+
357+
@dask_array_available
358+
class TestDaskMultiChunk:
359+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
360+
def test_numpy_equals_dask_multichunk(self, func):
361+
data = np.random.RandomState(3682).rand(9, 11).astype(np.float64)
362+
np_agg = create_test_raster(data, backend='numpy')
363+
da_agg = create_test_raster(data, backend='dask+numpy', chunks=(4, 5))
364+
assert_numpy_equals_dask_numpy(np_agg, da_agg, func)
365+
366+
@cuda_and_cupy_available
367+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
368+
def test_numpy_equals_dask_cupy_multichunk(self, func):
369+
from xrspatial.tests.general_checks import assert_numpy_equals_dask_cupy
370+
data = np.random.RandomState(3682).rand(9, 11).astype(np.float64)
371+
np_agg = create_test_raster(data, backend='numpy')
372+
dcu_agg = create_test_raster(data, backend='dask+cupy', chunks=(4, 5))
373+
assert_numpy_equals_dask_cupy(np_agg, dcu_agg, func)
374+
375+
376+
# ---------------------------------------------------------------------------
377+
# NaN at the raster edge, on every backend and boundary mode (#3682) --
378+
# earlier NaN tests only place a NaN at an interior cell on the numpy
379+
# backend. NaN-input backend divergence has shipped before (kde, #3628).
380+
# ---------------------------------------------------------------------------
381+
382+
def _nan_edge_data():
383+
data = np.random.RandomState(42).rand(8, 10).astype(np.float64)
384+
data[0, 0] = np.nan # corner: interacts with boundary padding
385+
data[3, 4] = np.nan # interior
386+
return data
387+
388+
389+
class TestNanAtBoundary:
390+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
391+
@pytest.mark.parametrize('boundary', ['nan', 'nearest', 'reflect', 'wrap'])
392+
def test_corner_nan_propagates(self, func, boundary):
393+
result = func(create_test_raster(_nan_edge_data()), boundary=boundary)
394+
# the corner NaN reaches its kernel neighborhood
395+
assert np.isnan(result.data[1, 1])
396+
# cells outside both NaN neighborhoods stay finite
397+
assert np.isfinite(result.data[6, 7])
398+
399+
@dask_array_available
400+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
401+
@pytest.mark.parametrize('boundary', ['nan', 'nearest', 'reflect', 'wrap'])
402+
def test_numpy_equals_dask_multichunk(self, func, boundary):
403+
data = _nan_edge_data()
404+
expected = func(create_test_raster(data), boundary=boundary)
405+
da_agg = create_test_raster(data, backend='dask+numpy', chunks=(3, 4))
406+
result = func(da_agg, boundary=boundary)
407+
np.testing.assert_allclose(
408+
expected.data, result.data.compute(), equal_nan=True)
409+
410+
@cuda_and_cupy_available
411+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
412+
@pytest.mark.parametrize('boundary', ['nan', 'nearest', 'reflect', 'wrap'])
413+
def test_numpy_equals_cupy(self, func, boundary):
414+
data = _nan_edge_data()
415+
expected = func(create_test_raster(data), boundary=boundary)
416+
cu_agg = create_test_raster(data, backend='cupy')
417+
result = func(cu_agg, boundary=boundary)
418+
np.testing.assert_allclose(
419+
expected.data, result.data.get(), equal_nan=True)
420+
421+
@cuda_and_cupy_available
422+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
423+
@pytest.mark.parametrize('boundary', ['nan', 'nearest', 'reflect', 'wrap'])
424+
def test_numpy_equals_dask_cupy(self, func, boundary):
425+
data = _nan_edge_data()
426+
expected = func(create_test_raster(data), boundary=boundary)
427+
dcu_agg = create_test_raster(data, backend='dask+cupy', chunks=(3, 4))
428+
result = func(dcu_agg, boundary=boundary)
429+
np.testing.assert_allclose(
430+
expected.data, result.data.compute().get(), equal_nan=True)
431+
432+
433+
# ---------------------------------------------------------------------------
434+
# Inf handling (#3682) -- an inf cell propagates +/-inf where the kernel
435+
# weight has a single sign and NaN where inf meets -inf (or a zero weight).
436+
# ---------------------------------------------------------------------------
437+
438+
# per-operator expected 3x3 neighborhood around a +inf cell in a field of
439+
# ones: +/-inf where the kernel weight over the inf cell has a single sign,
440+
# NaN where the weight is zero (0 * inf) or opposing infs meet.
441+
INF_NEIGHBORHOODS = [
442+
(sobel_x, [[np.inf, np.nan, -np.inf]] * 3),
443+
(sobel_y, [[np.inf] * 3, [np.nan] * 3, [-np.inf] * 3]),
444+
(prewitt_x, [[np.inf, np.nan, -np.inf]] * 3),
445+
(prewitt_y, [[np.inf] * 3, [np.nan] * 3, [-np.inf] * 3]),
446+
(laplacian, [[np.nan, np.inf, np.nan],
447+
[np.inf, -np.inf, np.inf],
448+
[np.nan, np.inf, np.nan]]),
449+
]
450+
451+
452+
class TestInfHandling:
453+
@pytest.mark.parametrize('func,expected_block', INF_NEIGHBORHOODS)
454+
def test_inf_neighborhood(self, func, expected_block):
455+
data = np.ones((5, 6), dtype=np.float64)
456+
data[2, 3] = np.inf
457+
result = func(create_test_raster(data), boundary='reflect')
458+
# 3x3 neighborhood centred on the inf cell
459+
np.testing.assert_array_equal(result.data[1:4, 2:5], expected_block)
460+
# cells outside the neighborhood are unaffected: constant field -> 0
461+
assert result.data[0, 0] == 0
462+
assert result.data[4, 0] == 0
463+
464+
@pytest.mark.parametrize('func,expected_block', INF_NEIGHBORHOODS)
465+
def test_negative_inf(self, func, expected_block):
466+
data = np.ones((5, 6), dtype=np.float64)
467+
data[2, 3] = -np.inf
468+
result = func(create_test_raster(data), boundary='reflect')
469+
# a -inf cell produces the +inf expected block negated (NaN unchanged)
470+
np.testing.assert_array_equal(
471+
result.data[1:4, 2:5], np.negative(expected_block))
472+
assert result.data[0, 0] == 0
473+
474+
475+
# ---------------------------------------------------------------------------
476+
# Degenerate shapes (#3682) -- 1x1, strips, and empty rasters.
477+
# ---------------------------------------------------------------------------
478+
479+
class TestDegenerateShapes:
480+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
481+
def test_1x1_nan_boundary(self, func):
482+
agg = create_test_raster(np.array([[5.0]]))
483+
result = func(agg)
484+
assert result.shape == (1, 1)
485+
assert np.isnan(result.data[0, 0])
486+
487+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
488+
def test_1x1_reflect(self, func):
489+
agg = create_test_raster(np.array([[5.0]]))
490+
result = func(agg, boundary='reflect')
491+
# reflected padding makes the neighborhood constant -> zero response
492+
np.testing.assert_array_equal(result.data, [[0.0]])
493+
494+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
495+
def test_row_strip(self, func):
496+
data = np.arange(6, dtype=np.float64).reshape(1, 6)
497+
result_nan = func(create_test_raster(data))
498+
assert result_nan.shape == (1, 6)
499+
assert np.all(np.isnan(result_nan.data))
500+
result_reflect = func(create_test_raster(data), boundary='reflect')
501+
assert not np.any(np.isnan(result_reflect.data))
502+
503+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
504+
def test_column_strip(self, func):
505+
data = np.arange(6, dtype=np.float64).reshape(6, 1)
506+
result = func(create_test_raster(data), boundary='reflect')
507+
assert result.shape == (6, 1)
508+
assert not np.any(np.isnan(result.data))
509+
510+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
511+
def test_empty_raster(self, func):
512+
agg = xr.DataArray(np.zeros((0, 5), dtype=np.float64), dims=['y', 'x'])
513+
result = func(agg)
514+
assert result.shape == (0, 5)
515+
516+
517+
# ---------------------------------------------------------------------------
518+
# Invalid boundary and dim-name propagation (#3682)
519+
# ---------------------------------------------------------------------------
520+
521+
class TestBoundaryValidation:
522+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
523+
def test_invalid_boundary_raises(self, func):
524+
agg = create_test_raster(np.ones((5, 6), dtype=np.float64))
525+
with pytest.raises(ValueError, match='boundary must be one of'):
526+
func(agg, boundary='invalid')
527+
528+
529+
class TestDimNamePropagation:
530+
@pytest.mark.parametrize('func', [sobel_x, sobel_y, laplacian, prewitt_x, prewitt_y])
531+
def test_custom_dims_preserved(self, func):
532+
agg = create_test_raster(np.ones((5, 6), dtype=np.float64),
533+
dims=['lat', 'lon'])
534+
result = func(agg)
535+
assert result.dims == ('lat', 'lon')
536+
np.testing.assert_allclose(result['lat'].data, agg['lat'].data)
537+
np.testing.assert_allclose(result['lon'].data, agg['lon'].data)
538+
539+
351540
# ---------------------------------------------------------------------------
352541
# Wide-integer precision (issue-3680)
353542
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)