diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 077bd8e4a..d4aa225bb 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-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." 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)." 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." geotiff,2026-06-12,3266,MEDIUM,1,"Pass 22 (2026-06-12, deep-sweep test-coverage): delta audit of the ~20 commits since pass 21 (06-09..06-12, mostly pack/unpack fixes + #3241 GPU streaming writer + coregister #3254/#3248). Filed #3266 (tests). Cat 1 MEDIUM: pack=True gained working gpu/dask+gpu support in #3240, but three pack features were tested numpy+dask only: float32 width preservation (#3080, test_pack_float_width_3080.py), nodata kwarg fill (#3168, test_pack_nodata_kwarg_3168.py), band-subset per-band SCALE/OFFSET rewrite (#3161, test_pack_band_subset_3161.py). Live probe on this CUDA host: all six gpu/dask+gpu legs pass today (no source bug, pure coverage gap). Added one gpu/dask-gpu parametrized round-trip test per file (6 tests, requires_gpu, RUN+passing locally) and fixed two stale docstrings claiming unpack/pack is CPU-only (wrong since #3075/#3240). Verified NOT gaps this pass: #3128 int64 sentinel tests cover eager+dask+gpu; #3241 streaming writer landed with byte-identical band-first/band-last/BytesIO/small-buffer tests; #3104 scale-zero rejection has gpu legs; #3169 revived the dead compression-corpus oracle gate. Out of scope: coregister=True lives in accessor.py (excluded module); its multi-band + polar gaps are documented as experimental caveats in docs/source/reference/geotiff.rst (#3248). LOW (carried, documented not fixed): Inf as the declared nodata sentinel never tested. || PREVIOUS: Pass 21 (2026-06-09, deep-sweep test-coverage): filed #3114 (tests) + #3112 (source bug). Cat 1 HIGH: to_geotiff(pack=True) round-trip was tested only on numpy and dask+numpy (write/test_pack_3064.py); #3075 made unpack=True work on gpu and dask+gpu reads, but no test packed a GPU-read array back. Live probe on this CUDA host: BOTH GPU legs crash today -- eager gpu raises AttributeError (cupy has no astype, the known cupy 13.6/xarray 2025.12 where/astype incompat) and dask+gpu raises TypeError (numpy fill value inside cupy.where) -- both from _pack's out.fillna(nodata) in _attrs.py; _writers/gpu.py says the pre-dispatch re-pack is supposed to make every write path work. Source bug filed as #3112; test-only PR adds test_pack_round_trip_gpu (gpu + dask-gpu params, requires_gpu, xfail(strict=True) on #3112 so the fix flips them loudly) and fixes the stale module docstring claiming GPU rejects mask_and_scale. Ran on CUDA host: 13 passed, 2 xfailed. Verified NOT gaps this pass (probed before flagging): empty/zero-band writer guard is covered (test_basic.py 2075/2095 blocks incl. gpu + dask + streaming entry points); degenerate shapes covered on all 4 read backends (read/test_degenerate_shapes.py); overview_resampling all 7 modes parametrized; missing_sources raise/warn + invalid, band_nodata first/invalid, unpack on all 4 backends, masked/parse_coordinates/lock/cache/default_name/name-deprecation all exercised; attrs contract per-backend (attrs/test_contract.py). LOW (documented, not fixed): Inf as the declared nodata sentinel is never tested (only one nan+inf data round-trip in test_edge_cases.py). || PREVIOUS: Pass 20 (2026-06-06, deep-sweep test-coverage): filed #2984 and added test_writer.py degenerate-shape GPU write coverage (Cat 1 backend + Cat 3 geometric edge). Read side already covers 1x1/1xN/Nx1 on all 4 backends (read/test_degenerate_shapes.py) and the dask streaming writer covers them (integration/test_dask_pipeline.py); the GPU write path was the gap (smallest shape in gpu/test_writer.py was 2x2). Added test_write_geotiff_gpu_degenerate_round_trip (1x1/1xN/Nx1 x none/deflate) + test_to_geotiff_dask_gpu_degenerate_round_trip (dask+cupy via gpu=True). 9 new tests RUN+passing on a CUDA host. Verified paths work first (not a source bug); transform supplied explicitly via attrs. Wider tree audit (~92k test LOC vs ~33k source): rioxarray-compat (#2961), bbox NaN/Inf/rotated, 8-backend parity matrix, codec round-trips already covered -- no other real gaps. | Pass (2026-06-05 test-coverage sweep): mature module (~31k src / ~124k test LOC, 9 test dirs). Exhaustive existing coverage -- parity/test_backend_matrix.py runs all 4 backends + VRT + HTTP + fsspec; golden_corpus full-manifest parity; read_rioxarray_compat_2961 covers masked/mask_and_scale/parse_coordinates/default_name on eager+dask. Cat1+Cat3 gap found (MEDIUM): degenerate-shape READS (1x1/1xN/Nx1) were tested only on the eager numpy reader (test_edge_cases.py) and the dask streaming WRITE path (integration/test_dask_pipeline.py); the windowed dask READ (chunks=) and GPU READ (gpu=True) on a single-pixel dimension were never exercised (smallest dask-read source in read/test_tiling is 8x8/2x32, parity fixtures 32x32/64x64). Probed: paths work today, no source bug -- pure coverage gap. Added read/test_degenerate_shapes.py (18 tests): dask read x{chunks 1,3,4} x{1x1,1xN,Nx1} + coord/transform/crs parity + GPU read + dask+gpu read. GPU cells RAN and PASSED on this CUDA host (grid-size-1 launch validated). Fixture supplies explicit attrs['transform'] (writer cannot infer pixel size from a 1-element coord axis). Branch deep-sweep-test-coverage-geotiff-degenerate-read-01. NOTE: pre-existing union-merge CRLF/duplicate-record corruption in this CSV left untouched -- appended one clean record; DictReader last-write-wins picks this one." idw,2026-06-04,2919,HIGH,1;4,"cupy/dask+cupy backends untested (Cat1 HIGH); GPU k-reject error path untested (Cat4 MED). Added 6 GPU tests, validated on CUDA host. Inf-in-points (Cat2) and attrs-preservation (Cat5) are LOW, documented not fixed." diff --git a/xrspatial/tests/test_dasymetric.py b/xrspatial/tests/test_dasymetric.py index a228ffdc3..2482492b9 100644 --- a/xrspatial/tests/test_dasymetric.py +++ b/xrspatial/tests/test_dasymetric.py @@ -811,3 +811,218 @@ def test_pycnophylactic_error_mentions_n_zones(self, monkeypatch): values = {i: 1.0 for i in range(1, 51)} with pytest.raises(MemoryError, match="n_zones=50"): pycnophylactic(zones, values) + + +# --------------------------------------------------------------------------- +# TestMetadataPreservation (#3407) +# --------------------------------------------------------------------------- + +class TestMetadataPreservation: + """Output must carry the input zones' attrs and coords (#3407). + + The existing metadata test only checks dims/name/shape. These pin the + res/crs attrs and the y/x coordinate arrays so a silent drop is caught. + """ + + def test_disaggregate_preserves_attrs_and_coords( + self, simple_zones, simple_weight, simple_values + ): + result = disaggregate(simple_zones, simple_values, simple_weight) + assert result.attrs == simple_zones.attrs + assert result.attrs.get('res') == simple_zones.attrs.get('res') + assert result.attrs.get('crs') == simple_zones.attrs.get('crs') + for coord in simple_zones.coords: + np.testing.assert_allclose( + result[coord].data, simple_zones[coord].data + ) + + def test_pycnophylactic_preserves_attrs_and_coords( + self, simple_zones, simple_values + ): + result = pycnophylactic(simple_zones, simple_values) + assert result.attrs == simple_zones.attrs + for coord in simple_zones.coords: + np.testing.assert_allclose( + result[coord].data, simple_zones[coord].data + ) + + @pytest.mark.skipif(not has_dask_array(), reason="dask not available") + def test_disaggregate_dask_preserves_attrs( + self, simple_zones_data, simple_weight_data, simple_values + ): + zones = create_test_raster(simple_zones_data, backend='dask+numpy', + chunks=(2, 2)) + weight = create_test_raster(simple_weight_data, backend='dask+numpy', + chunks=(2, 2)) + result = disaggregate(zones, simple_values, weight) + assert result.attrs == zones.attrs + for coord in zones.coords: + np.testing.assert_allclose( + result[coord].data, zones[coord].data + ) + + +# --------------------------------------------------------------------------- +# TestSinglePixel (#3407) +# --------------------------------------------------------------------------- + +class TestSinglePixel: + """A true 1x1 raster is the degenerate kernel case (#3407). + + For pycnophylactic both the ``nrows > 1`` and ``ncols > 1`` guards are + skipped, so the smoothing loop runs with no neighbour shifts at all. + """ + + def test_disaggregate_1x1(self): + zones = create_test_raster(np.array([[1]], dtype=np.float64), + backend='numpy') + weight = create_test_raster(np.array([[5.0]]), backend='numpy') + result = disaggregate(zones, {1: 100.0}, weight) + assert result.shape == (1, 1) + assert result.values[0, 0] == pytest.approx(100.0) + + def test_disaggregate_1x1_binary(self): + zones = create_test_raster(np.array([[7]], dtype=np.float64), + backend='numpy') + weight = create_test_raster(np.array([[3.0]]), backend='numpy') + result = disaggregate(zones, {7: 42.0}, weight, method='binary') + assert result.values[0, 0] == pytest.approx(42.0) + + def test_pycnophylactic_1x1(self): + zones = create_test_raster(np.array([[1]], dtype=np.float64), + backend='numpy') + result = pycnophylactic(zones, {1: 100.0}) + assert result.shape == (1, 1) + # single pixel, no neighbours: keeps the full zone value + assert result.values[0, 0] == pytest.approx(100.0) + + @pytest.mark.skipif(not has_dask_array(), reason="dask not available") + def test_disaggregate_1x1_dask_matches_numpy(self): + zones_np = create_test_raster(np.array([[1]], dtype=np.float64), + backend='numpy') + weight_np = create_test_raster(np.array([[5.0]]), backend='numpy') + zones_dk = create_test_raster(np.array([[1]], dtype=np.float64), + backend='dask+numpy', chunks=(1, 1)) + weight_dk = create_test_raster(np.array([[5.0]]), + backend='dask+numpy', chunks=(1, 1)) + r_np = disaggregate(zones_np, {1: 100.0}, weight_np) + r_dk = disaggregate(zones_dk, {1: 100.0}, weight_dk) + np.testing.assert_allclose(r_np.values, r_dk.values, equal_nan=True) + + +# --------------------------------------------------------------------------- +# TestInfWeight (#3407) +# --------------------------------------------------------------------------- + +class TestInfWeight: + """An infinite weight makes the zone weight-sum infinite (#3407). + + The proportional term ``zval * w / wsum`` then evaluates to NaN for the + inf pixel and 0 for the finite pixels, so the zone total collapses to 0 + and conservation silently breaks. This pins the current behaviour; if a + future change starts handling Inf differently the test will flag it for + review. + """ + + def test_inf_weight_collapses_zone(self): + zones = create_test_raster(np.array([[1, 1, 1]], dtype=np.float64), + backend='numpy') + weight = create_test_raster(np.array([[1.0, np.inf, 2.0]]), + backend='numpy') + result = disaggregate(zones, {1: 100.0}, weight) + data = result.values + # inf pixel -> NaN, finite pixels -> 0 + assert np.isnan(data[0, 1]) + assert data[0, 0] == pytest.approx(0.0) + assert data[0, 2] == pytest.approx(0.0) + # conservation is broken by the Inf (documented limitation) + assert np.nansum(data) == pytest.approx(0.0) + + +# --------------------------------------------------------------------------- +# TestLimitingVariableThreeClass (#3407) +# --------------------------------------------------------------------------- + +class TestLimitingVariableThreeClass: + """Multi-break limiting_variable with per-class caps (#3407). + + Only the 2-class ``(0.0,)`` break was previously tested. This covers + the three-class path the docstring advertises. + """ + + def test_three_class_caps_and_overflow(self): + # breaks (1.0, 3.0): class 0 = w<=1, class 1 = 13 + zones = create_test_raster( + np.array([[1, 1, 1, 1, 1, 1]], dtype=np.float64), backend='numpy') + weight = create_test_raster( + np.array([[0.0, 0.5, 1.5, 2.0, 4.0, 5.0]]), backend='numpy') + result = disaggregate( + zones, {1: 300.0}, weight, method='limiting_variable', + class_breaks=(1.0, 3.0), density_caps=[0.0, 5.0, np.inf], + ) + data = result.values[0] + # class 0 pixels (w <= 1): cap 0 -> get nothing + assert data[0] == pytest.approx(0.0) + assert data[1] == pytest.approx(0.0) + # class 1 pixels (1 < w <= 3): capped at 5 each + assert data[2] == pytest.approx(5.0) + assert data[3] == pytest.approx(5.0) + # class 2 pixels (w > 3): absorb the remainder, (300 - 10) / 2 = 145 + assert data[4] == pytest.approx(145.0) + assert data[5] == pytest.approx(145.0) + # conservation + assert np.nansum(result.values) == pytest.approx(300.0) + + def test_three_class_conservation_only(self): + """Three classes with finite caps everywhere still conserve.""" + zones = create_test_raster( + np.array([[1, 1, 1, 1]], dtype=np.float64), backend='numpy') + weight = create_test_raster( + np.array([[0.5, 2.0, 5.0, 8.0]]), backend='numpy') + result = disaggregate( + zones, {1: 50.0}, weight, method='limiting_variable', + class_breaks=(1.0, 4.0), density_caps=[10.0, 20.0, 100.0], + ) + assert np.nansum(result.values) == pytest.approx(50.0) + + +# --------------------------------------------------------------------------- +# TestPycnophylacticEmptyValid (#3406) +# --------------------------------------------------------------------------- + +class TestPycnophylacticEmptyValid: + """pycnophylactic crashes when no pixel is valid for smoothing (#3406). + + disaggregate handles the same inputs gracefully (all-NaN output); these + are xfail(strict) until #3406 makes pycnophylactic agree. When the + source fix lands, the tests start XPASSing and strict mode flips them + red, prompting removal of the marker. + """ + + def test_disaggregate_all_nan_zones_is_all_nan(self): + """Reference: disaggregate returns all-NaN, no crash.""" + zones = create_test_raster(np.full((3, 3), np.nan), backend='numpy') + weight = create_test_raster(np.ones((3, 3)), backend='numpy') + result = disaggregate(zones, {1: 100.0}, weight) + assert np.all(np.isnan(result.values)) + + @pytest.mark.xfail( + reason="#3406: pycnophylactic raises ValueError on empty-valid input", + strict=True, + raises=ValueError, + ) + def test_pycnophylactic_all_nan_zones(self): + zones = create_test_raster(np.full((3, 3), np.nan), backend='numpy') + result = pycnophylactic(zones, {1: 100.0}) + assert np.all(np.isnan(result.values)) + + @pytest.mark.xfail( + reason="#3406: pycnophylactic raises ValueError on empty-valid input", + strict=True, + raises=ValueError, + ) + def test_pycnophylactic_no_matching_zone(self): + zones = create_test_raster( + np.array([[1, 1], [2, 2]], dtype=np.float64), backend='numpy') + result = pycnophylactic(zones, {99: 100.0}) + assert np.all(np.isnan(result.values))