diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 5c0b8d97e..aa9b0d49e 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -2,6 +2,7 @@ module,last_inspected,issue,severity_max,categories_found,notes aspect,2026-06-21,3439,MEDIUM,2,"#3439 (Cat 2): Inf/-Inf elevation input and all-NaN raster were untested. Added Inf-input finite-neighbors + 4-backend parity, and all-NaN -> all-NaN shape-preserved for aspect/northness/eastness on all 4 backends; GPU-validated (CUDA available). Both behaviors defined and consistent across backends -> coverage gap, not a bug (supersedes prior 'Inf possible source bug, not filed' note). Prior: #2742 degenerate shapes + geodesic boundary modes; #2829 northness/eastness geodesic branch -- all still covered." classify,2026-06-20,,MEDIUM,3;4,"Deep-sweep 2026-06-20 test-coverage on a CUDA host. Backend matrix was already complete: all 10 public classifiers (binary/reclassify/quantile/natural_breaks/equal_interval/std_mean/head_tail_breaks/percentiles/maximum_breaks/box_plot) x 4 backends present and green (Cat 1 no gap). Cat 2 (NaN/Inf) covered: input_data() fixture embeds -inf/nan/+inf at corners on every numpy/dask/cupy test, plus all-nan/all-inf/all-same dedicated tests. Cat 5 covered via general_output_checks(verify_attrs=True) asserting attrs/dims/coords on every backend test. Found two MEDIUM gaps: Cat 3 (no 1x1 single-pixel, no Nx1/1xN strip tests for any classifier) and Cat 4 (the _validate_scalar k=2, equal_interval k>=1). Probed live: single-pixel and strip shapes all work correctly (no source bug -- lone finite pixel -> class 0; binary match -> 1; reclassify -> bin's new_value), so these are untested-but-passing paths. Added test-only (numpy, since the gaps are in shared CPU-side validation + bin-edge logic and the 4-backend dispatch is already fully covered): test_classify_single_pixel, test_binary_single_pixel, test_reclassify_single_pixel, test_classify_nx1_strip, test_classify_1xn_strip, and 4 k-below-min ValueError tests. All RAN and PASSED on the CUDA host; full file 98 passed. classify.py untouched. LOW (documented, not fixed): empty raster (0 rows) raises on numpy equal_interval (zero-size reduction) and differs across backends -- degenerate input, not pinned. Non-square cellsize not exercised (classifiers ignore cellsize, so out of scope)." contour,2026-06-08,2704;2710;3044,MEDIUM,1;2,"Pass 2 (2026-06-08, deep-sweep test-coverage): re-swept on a CUDA host. Verified issue #2704 is CLOSED (fixed 2026-06-01 via #2749, kernel now uses np.isfinite at contour.py:73-74); the two prior xfail(strict) #2704 pins were already flipped to plain passing assertions in TestInfHandling (test_inf_corner_no_nan_coords / test_neg_inf_corner_no_nan_coords) -- no stale xfail remained. Found one MEDIUM Cat 1+2 gap: no cross-backend parity test fed NaN input -- TestBackendEquivalence uses elevation_raster_no_nans and test_partial_nan is numpy-only, so numpy-interior NaN-skip vs dask NaN-halo (da.overlap) parity at chunk edges was unpinned. Filed #3044, added TestNaNBackendParity (3 tests: dask, cupy, dask+cupy each assert _segments_by_level equality vs numpy on a partial-NaN ramp with a NaN edge row + interior NaN cell inside a non-edge chunk). All 3 RAN and PASSED on a CUDA host; full file 89 passed, 0 skipped. Probed and verified now-resolved: the prior-pass LOW items are no longer real gaps -- the levels=None all-NaN early-return IS asserted (result==[]) on all 4 backends (numpy L148/dask L163/cupy L178/dask+cupy L194) plus geopandas (test_geopandas_all_nan_keeps_crs). LOW (documented, NOT fixed): non-square cellsize (res[0]!=res[1]) still never exercised -- all tests use create_test_raster res (0.5,0.5); probed live that anisotropic coords transform correctly (y scaled 2.0, x scaled 0.5 -> crossing x=1.25, y spans 0..8), works, so it is a LOW coverage gap not a bug. Cat 3 1x1/Nx1/1xN remain rejected by the >=2x2 guard (tested). Test-only PR for #3044; contour.py untouched. | Pass 1 (2026-05-29): added TestInfHandling, TestCRSPropagation, TestNonDefaultDims to test_contour.py (5 passed + 2 strict-xfail on a CUDA host; full file 29 passed, 2 xfailed). All four backends (numpy / cupy / dask+numpy / dask+cupy) were already exercised with cross-backend segment-equality assertions (TestBackendEquivalence), and ran green locally on the CUDA host -- Cat 1 well covered, no new backend tests needed. Cat 2 HIGH (Inf): the marching-squares NaN-skip guard at contour.py:67 uses x!=x which does not catch infinity, so a finite level near a +/-inf corner leaks NaN coordinates into the output. Filed source bug #2704 and added two xfail(strict=True) tests pinning it (+inf and -inf) plus test_inf_far_level_no_crossing covering the safe path where the inf quad classifies as all-above (idx 15) and is skipped before any interpolation. Cat 5 MEDIUM: no test asserted gdf.crs propagation from agg.attrs['crs'] (contour.py:660) -- added test_geopandas_crs_from_attrs (to_epsg()==5070) + test_geopandas_no_crs_attr. Cat 5 MEDIUM: the index-to-coordinate transform (contour.py:644-654) reads agg.dims[0]/[1] coords but no test used non-y/x dims -- added test_lat_lon_dims_coordinate_transform + test_lat_lon_matches_yx_equivalent. PR #2710 (test-only, source untouched). LOW (documented, not fixed): non-square cellsize (cellsize_x != cellsize_y) never exercised -- all tests use res (0.5,0.5); levels=None early-return on all-NaN/all-equal works (probed) but only the explicit-levels all-NaN path is asserted. Cat 3 1x1/Nx1/1xN are rejected by the >=2x2 validation guard and that rejection is already tested (test_too_small, test_minimum_raster)." +corridor,2026-06-22,,HIGH,1;3;4;5,"Deep-sweep 2026-06-22 test-coverage on a CUDA host. least_cost_corridor is the only public function; it delegates all backend math to cost_distance (4 backends) plus pure xarray arithmetic. Backend matrix for the core paths was already complete: symmetry/optimal-path/abs-threshold/rel-threshold/barrier/unreachable each parametrized over numpy/dask+numpy/cupy/dask+cupy. Cat 2 (NaN barriers + all-NaN unreachable) covered. Found gaps (all paths probed live and work -> coverage gaps, not source bugs): Cat 3 HIGH (no Nx1/1xN strip), Cat 5 HIGH (no attrs/coords/dim-name preservation test; corridor reads res for cost_distance math), Cat 1 MEDIUM (no numpy==other-backend exact equivalence assertion -- per-backend property checks only), Cat 4 MEDIUM (connectivity=4 and finite max_cost forwarding untested at corridor level). Added test-only: test_1xn_strip, test_nx1_strip, test_numpy_matches_other_backends (dask+numpy/cupy/dask+cupy), test_connectivity_4, test_max_cost_forwarded, test_attrs_coords_preserved, test_custom_dim_names_preserved. All RAN and PASSED on the CUDA host (cupy + dask+cupy not skipped); full file 41 passed." 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)." diff --git a/xrspatial/tests/test_corridor.py b/xrspatial/tests/test_corridor.py index 23eb2a326..ff7332378 100644 --- a/xrspatial/tests/test_corridor.py +++ b/xrspatial/tests/test_corridor.py @@ -376,6 +376,184 @@ def test_single_source_in_list_raises(): least_cost_corridor(friction, sources=[src]) +# ----------------------------------------------------------------------- +# Degenerate strip shapes (Nx1 / 1xN) +# ----------------------------------------------------------------------- + + +def test_1xn_strip(): + """1xN single-row raster with sources at each end.""" + n = 5 + friction = _make_raster(np.ones((1, n))) + + src_a = np.zeros((1, n)) + src_a[0, 0] = 1.0 + src_b = np.zeros((1, n)) + src_b[0, n - 1] = 1.0 + + result = least_cost_corridor(friction, _make_raster(src_a), + _make_raster(src_b)) + out = _compute(result) + + assert out.shape == (1, n) + # Every cell lies on the only path, so all are optimal (cost 0). + np.testing.assert_allclose(out[0], 0.0, atol=1e-5) + + +def test_nx1_strip(): + """Nx1 single-column raster with sources at each end.""" + n = 5 + friction = _make_raster(np.ones((n, 1))) + + src_a = np.zeros((n, 1)) + src_a[0, 0] = 1.0 + src_b = np.zeros((n, 1)) + src_b[n - 1, 0] = 1.0 + + result = least_cost_corridor(friction, _make_raster(src_a), + _make_raster(src_b)) + out = _compute(result) + + assert out.shape == (n, 1) + np.testing.assert_allclose(out[:, 0], 0.0, atol=1e-5) + + +# ----------------------------------------------------------------------- +# Cross-backend equivalence +# ----------------------------------------------------------------------- + + +@pytest.mark.parametrize("backend", ["dask+numpy", "cupy", "dask+cupy"]) +def test_numpy_matches_other_backends(backend): + """Each non-numpy backend produces the same corridor as numpy.""" + if "cupy" in backend and not has_cuda_and_cupy(): + pytest.skip("Requires CUDA and CuPy") + + n = 7 + friction_data = np.ones((n, n)) + src_a = np.zeros((n, n)) + src_a[3, 0] = 1.0 + src_b = np.zeros((n, n)) + src_b[3, 6] = 1.0 + + numpy_out = _compute( + least_cost_corridor( + _make_raster(friction_data), + _make_raster(src_a), + _make_raster(src_b), + ) + ) + + other_out = _compute( + least_cost_corridor( + _make_raster(friction_data, backend=backend, chunks=(7, 7)), + _make_raster(src_a, backend=backend, chunks=(7, 7)), + _make_raster(src_b, backend=backend, chunks=(7, 7)), + ) + ) + + np.testing.assert_allclose(other_out, numpy_out, equal_nan=True, atol=1e-5) + + +# ----------------------------------------------------------------------- +# Forwarded cost_distance parameters (connectivity, max_cost) +# ----------------------------------------------------------------------- + + +def test_connectivity_4(): + """connectivity=4 is forwarded to cost_distance and yields a corridor.""" + n = 5 + friction = _make_raster(np.ones((n, n))) + + src_a = np.zeros((n, n)) + src_a[2, 0] = 1.0 + src_b = np.zeros((n, n)) + src_b[2, 4] = 1.0 + + result = least_cost_corridor( + friction, _make_raster(src_a), _make_raster(src_b), connectivity=4 + ) + out = _compute(result) + + assert out.shape == (n, n) + # Optimal corridor minimum is 0 after normalization. + assert np.nanmin(out) == pytest.approx(0.0, abs=1e-5) + # Row 2 is the straight cardinal-only path; all cells optimal. + for col in range(n): + assert out[2, col] == pytest.approx(0.0, abs=1e-5) + + +def test_max_cost_forwarded(): + """A finite max_cost reaches cost_distance; optimal path still resolves.""" + n = 7 + friction = _make_raster(np.ones((n, n))) + + src_a = np.zeros((n, n)) + src_a[3, 0] = 1.0 + src_b = np.zeros((n, n)) + src_b[3, 6] = 1.0 + + result = least_cost_corridor( + friction, _make_raster(src_a), _make_raster(src_b), max_cost=20.0 + ) + out = _compute(result) + + assert out.shape == (n, n) + # The straight middle row stays within budget and normalizes to 0. + for col in range(n): + assert out[3, col] == pytest.approx(0.0, abs=1e-5) + + +# ----------------------------------------------------------------------- +# Metadata / coordinate / dim-name preservation +# ----------------------------------------------------------------------- + + +def test_attrs_coords_preserved(): + """Output preserves input attrs, dims, and coordinates.""" + n = 5 + friction = _make_raster(np.ones((n, n))) + + src_a = np.zeros((n, n)) + src_a[2, 0] = 1.0 + src_b = np.zeros((n, n)) + src_b[2, 4] = 1.0 + sa = _make_raster(src_a) + sb = _make_raster(src_b) + + result = least_cost_corridor(friction, sa, sb) + + assert result.dims == friction.dims + assert result.attrs == friction.attrs + np.testing.assert_array_equal(result["y"].data, friction["y"].data) + np.testing.assert_array_equal(result["x"].data, friction["x"].data) + + +def test_custom_dim_names_preserved(): + """Non-default lat/lon dim names propagate through to the output.""" + n = 5 + friction = xr.DataArray( + np.ones((n, n), dtype=np.float64), + dims=["lat", "lon"], + attrs={"res": (1.0, 1.0)}, + ) + friction["lat"] = np.arange(n, dtype=np.float64) + friction["lon"] = np.arange(n, dtype=np.float64) + + def _src(r, c): + s = friction.copy(data=np.zeros((n, n), dtype=np.float64)) + s.data[r, c] = 1.0 + return s + + result = least_cost_corridor( + friction, _src(2, 0), _src(2, 4), x="lon", y="lat" + ) + + assert result.dims == ("lat", "lon") + np.testing.assert_array_equal(result["lat"].data, friction["lat"].data) + np.testing.assert_array_equal(result["lon"].data, friction["lon"].data) + + # ----------------------------------------------------------------------- # Metadata propagation (issue #3446) # -----------------------------------------------------------------------