Skip to content
Merged
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 @@ -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<min_val guards were never exercised: quantile/natural_breaks/maximum_breaks 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)."
Expand Down
178 changes: 178 additions & 0 deletions xrspatial/tests/test_corridor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
# -----------------------------------------------------------------------
Expand Down
Loading