From b301458f98927e531b85d25b42ab3f5229f361c0 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 20 Jun 2026 02:53:02 -0700 Subject: [PATCH] Add classify edge-case and error-path test coverage Tests only; classify.py is untouched. The backend matrix was already complete for all ten classifiers across the four backends, so the gaps were elsewhere. New tests: - 1x1 single-pixel and Nx1 / 1xN strip shapes for every classifier (these degenerate shapes were untested) - the k-below-minimum validation guards (quantile/natural_breaks/ maximum_breaks require k>=2, equal_interval requires k>=1), which previously had no error-path test All new tests run and pass on a CUDA host; the full file reports 98 passed. Deep-sweep test-coverage, module: classify. --- .claude/sweep-test-coverage-state.csv | 1 + xrspatial/tests/test_classify.py | 112 ++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 9bc639431..60a5645a3 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -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=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)." 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." diff --git a/xrspatial/tests/test_classify.py b/xrspatial/tests/test_classify.py index 950409e5c..f248cf6d1 100644 --- a/xrspatial/tests/test_classify.py +++ b/xrspatial/tests/test_classify.py @@ -1078,3 +1078,115 @@ def test_percentiles_dask_no_unknown_chunks(): dask_result.data.compute(), equal_nan=True, ) + + +# =================================================================== +# 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)