Skip to content
Open
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-test-coverage-state.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
189 changes: 189 additions & 0 deletions xrspatial/tests/test_edge_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,195 @@ def test_numpy_equals_dask_cupy(self, ramp_data, func):
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).
# ---------------------------------------------------------------------------

# 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', INF_NEIGHBORHOODS)
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,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')
# 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


# ---------------------------------------------------------------------------
# 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)


# ---------------------------------------------------------------------------
# Wide-integer precision (issue-3680)
# ---------------------------------------------------------------------------
Expand Down
Loading