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
@@ -1,5 +1,6 @@
module,last_inspected,issue,severity_max,categories_found,notes
aspect,2026-06-02,2742;2829,HIGH,3;4,"#2742: degenerate shapes (1x1/Nx1/1xN) + geodesic boundary modes; tests added all 4 backends, GPU-validated. #2829: northness/eastness method='geodesic' branch was untested (planar only); added correctness (diagonal surface where planar!=geodesic) + 4-backend parity, GPU-validated. all-NaN planar/geodesic returns all-NaN (correct). Inf input -> silent -1/flat on spike cell: possible source bug, out of scope for test-only sweep, not filed. Dedup: rectangular-cell oracle #2781 + cell-size #2780 already merged, not duplicated."
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)."
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)."
Expand Down
112 changes: 112 additions & 0 deletions xrspatial/tests/test_classify.py
Original file line number Diff line number Diff line change
Expand Up @@ -1080,6 +1080,118 @@ def test_percentiles_dask_no_unknown_chunks():
)


# ===================================================================
# Geometric edge cases: 1x1 single pixel and Nx1 strip
# ===================================================================
# These classifiers are kernel-free, but the per-pixel bin search and the
# bin-edge computation both have degenerate behaviour on a one-cell or
# one-column raster (zero-width interval, a single unique value triggering
# the "not enough unique values" fallback). These shapes were previously
# untested for every classifier.

def _single_finite_class_zero(fn, agg, **kwargs):
"""Run fn and assert the lone finite pixel maps to class 0."""
import warnings
with warnings.catch_warnings():
warnings.simplefilter('ignore')
result = fn(agg, **kwargs)
assert result.shape == agg.shape
finite = result.data[np.isfinite(result.data)]
assert np.all(finite == 0)


def test_classify_single_pixel():
agg = xr.DataArray(np.array([[5.0]]))
_single_finite_class_zero(equal_interval, agg, k=3)
_single_finite_class_zero(std_mean, agg)
_single_finite_class_zero(head_tail_breaks, agg)
_single_finite_class_zero(percentiles, agg)
_single_finite_class_zero(box_plot, agg)
# k>=2 classifiers fall back to a single bin on one unique value
_single_finite_class_zero(quantile, agg, k=2)
_single_finite_class_zero(natural_breaks, agg, k=2)
_single_finite_class_zero(maximum_breaks, agg, k=2)


def test_binary_single_pixel():
# A pixel that matches a target value maps to class 1; a non-match to 0.
match = binary(xr.DataArray(np.array([[5.0]])), [5])
assert match.shape == (1, 1)
assert match.data[0, 0] == 1
no_match = binary(xr.DataArray(np.array([[5.0]])), [9])
assert no_match.data[0, 0] == 0


def test_reclassify_single_pixel():
# The lone pixel falls in the first bin and takes that bin's new value.
result = reclassify(xr.DataArray(np.array([[5.0]])), bins=[10], new_values=[7])
assert result.shape == (1, 1)
assert result.data[0, 0] == 7


def test_classify_nx1_strip():
# 4x1 column strip with increasing values.
strip = xr.DataArray(np.array([[1.], [2.], [3.], [4.]]))
for fn, kwargs in [
(equal_interval, dict(k=3)),
(std_mean, {}),
(head_tail_breaks, {}),
(percentiles, {}),
(box_plot, {}),
(quantile, dict(k=3)),
(natural_breaks, dict(k=3)),
(maximum_breaks, dict(k=3)),
]:
import warnings
with warnings.catch_warnings():
warnings.simplefilter('ignore')
result = fn(strip, **kwargs)
assert result.shape == strip.shape
finite = result.data[np.isfinite(result.data)]
# Monotonic input -> classes are non-decreasing down the column.
assert np.all(np.diff(finite) >= 0)


def test_classify_1xn_strip():
# 1x4 row strip; mirror of the column case to catch axis-specific bugs.
strip = xr.DataArray(np.array([[1., 2., 3., 4.]]))
result = equal_interval(strip, k=3)
assert result.shape == strip.shape
finite = result.data[np.isfinite(result.data)]
assert np.all(np.diff(finite) >= 0)


# ===================================================================
# Error paths: k below the validated minimum
# ===================================================================
# _validate_scalar enforces a minimum k for the parametric classifiers.
# Only reclassify's length-mismatch guard and the "not enough unique
# values" path were previously exercised; these min_val guards were not.

def test_quantile_k_below_min_raises():
agg = input_data()
with pytest.raises(ValueError, match='must be >= 2'):
quantile(agg, k=1)


def test_natural_breaks_k_below_min_raises():
agg = input_data()
with pytest.raises(ValueError, match='must be >= 2'):
natural_breaks(agg, k=1)


def test_maximum_breaks_k_below_min_raises():
agg = input_data()
with pytest.raises(ValueError, match='must be >= 2'):
maximum_breaks(agg, k=1)


def test_equal_interval_k_below_min_raises():
agg = input_data()
with pytest.raises(ValueError, match='must be >= 1'):
equal_interval(agg, k=0)


# ===================================================================
# Regression test: large-array sampler must be O(num_sample) in memory
# ===================================================================
Expand Down
Loading