diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 728b02b83..27c7e0eb4 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -89,3 +89,4 @@ slope,2026-05-29,2697,MEDIUM,3,"PR #2703: added degenerate-shape tests (1x1/1xN/ zonal,2026-05-29,2619,MEDIUM,1,"Pass 2 (2026-05-29): one Cat 1 MEDIUM backend-coverage gap remained after pass 1 -- 3D crosstab on cupy / dask+cupy. The 3D GPU paths (_crosstab_cupy / _crosstab_dask_cupy with a 3D categorical values array, layer=, agg='count') were reachable and correct but untested; the existing 3D crosstab tests (test_crosstab_3d_count, test_crosstab_3d_agg_method, test_nodata_values_crosstab_3d) only parametrize numpy / dask+numpy. Added 3 parity tests to test_zonal_backend_coverage_2026_05_27.py (test_crosstab_3d_count_cupy_matches_numpy, test_crosstab_3d_count_dask_cupy_matches_numpy, test_crosstab_3d_nodata_cupy_matches_numpy) asserting cupy and dask+cupy results match numpy for agg='count' including a nodata_values case. All passed live on a CUDA host. Issue #2619, PR #2625. Test-only, no source change. Remaining LOW (documented, not fixed): get_full_extent has no direct unit test (exercised indirectly via suggest_zonal_canvas); non-square cellsize handling not exercised. Pass 1 (2026-05-27): added test_zonal_backend_coverage_2026_05_27.py with 32 tests, all passing on a CUDA host. Closes Cat 1 HIGH backend-coverage gaps: crosstab cupy + dask+cupy (_crosstab_cupy / _crosstab_dask_cupy were dispatched but never invoked by tests), regions cupy + dask+cupy (_regions_cupy via cupyx.scipy.ndimage + _regions_dask_cupy), trim dask+numpy + cupy + dask+cupy (_trim_bounds_dask isnan path and cupy data.get() path), crop dask+numpy + cupy + dask+cupy (_crop_bounds_dask + cupy data.get() path), apply 3D cupy + dask+cupy (per-layer kernel launch over the third axis in _apply_cupy and _apply_dask_cupy). Existing test_zonal.py covered only numpy + dask+numpy for crosstab/regions/trim/crop and 2D-only for cupy apply. Closes Cat 3 MEDIUM 1x1 / 1xN / Nx1 strip edge cases for trim, crop, and regions. Closes Cat 4 LOW pins: regions(neighborhood=6) ValueError, suggest_zonal_canvas(crs='Geographic') aspect-ratio pin and invalid-crs KeyError, crosstab cupy zone_ids/cat_ids filter, crosstab cupy agg='percentage'. Closes Cat 5 MEDIUM: regions coords/attrs propagation across numpy + dask+numpy, trim/crop name='trim'/'crop' default + attrs preservation. Also pins the documented numpy-vs-dask trim asymmetry on NaN sentinel (numpy _trim does equality which never matches NaN; dask _trim_bounds_dask has dedicated isnan branch). Mutation against the cupy.asnumpy() conversion in _crosstab_cupy flipped test_crosstab_cupy_matches_numpy red. Source untouched." focal,2026-05-29,2732,HIGH,1,"Pass (2026-05-29): added test_hotspots_dask_cupy to test_focal.py closing Cat 1 HIGH backend-coverage gap. hotspots() registers dask_cupy_func=_hotspots_dask_cupy (focal.py L1414) but no test invoked it, while mean/apply/focal_stats each have a dedicated dask+cupy test. New test compares dask+cupy vs numpy on chunk interior (matches test_apply_dask_cupy/test_focal_stats_dask_cupy style). RUN on CUDA host: passes; spy confirmed routing through _hotspots_dask_cupy; path matches numpy exactly so no source fix needed. LOW (documented not fixed): Inf/-Inf inputs untested across focal funcs; 1x1 raster not explicitly tested for mean/apply/hotspots (focal_stats 1x1 covered by test_variety_single_cell). Issue #2732." interpolate-kriging,2026-06-04,2920;2921,HIGH,1;2;3;4;5,"Single public fn kriging(); all 4 backends already had cross-backend parity tests (numpy/cupy/dask+numpy/dask+cupy) incl. cupy & dask+cupy variance -- ran green on CUDA host. Gaps closed (test-only, #2921): Cat1 dask+numpy return_variance branch (_chunk_var) was untested -> added test_dask_return_variance_matches_numpy (atol=1e-12, var ~1e-14). Cat4 nlags only default(15) tested -> added non-default nlags=5 + invalid paths (nlags=0/-1 ValueError, nlags=2.5 TypeError). Cat2/3 two-point <3-lag-bins UserWarning branch -> test_two_point_warns_few_lag_bins. Cat2 all-NaN kriging input -> test_kriging_all_nan_points (only idw covered before). Cat5 output metadata (coords/dims/attrs/name) untested -> added test_output_metadata. Single-point kriging CRASHES (zero-size array reduction in _experimental_variogram, N=1) -- real source bug filed #2920; added xfail(strict, raises=ValueError) test_single_point documenting expected graceful behavior; source fix left to #2920 (test-only PR). LOW/not filed: singular-matrix K_inv-is-None all-NaN branch is defensive and unreachable via public API. GPU-validated." +geotiff,2026-06-05,,MEDIUM,1;3,"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." diff --git a/xrspatial/geotiff/tests/read/test_degenerate_shapes.py b/xrspatial/geotiff/tests/read/test_degenerate_shapes.py new file mode 100644 index 000000000..4500df80c --- /dev/null +++ b/xrspatial/geotiff/tests/read/test_degenerate_shapes.py @@ -0,0 +1,150 @@ +"""Degenerate-shape reads across the dask and GPU backends. + +The eager numpy reader already covers 1x1 / 1xN / Nx1 sources (see +``tests/test_edge_cases.py::TestWriteBoundaryShapes``), and the dask +*streaming write* path covers writing degenerate dask rasters (see +``tests/integration/test_dask_pipeline.py``). What is missing is the +read side on the non-eager backends: + +* the windowed dask reader (``open_geotiff(..., chunks=...)``) splitting + a source with a single-pixel dimension into chunks, and +* the GPU reader (``open_geotiff(..., gpu=True)``) launching its decode + kernels on a degenerate grid (grid-size-1 launches), +* and the ``dask+gpu`` combination. + +These paths work today; this file pins them so a regression in the +window-clamp math or the GPU grid launch on a 1-pixel dimension cannot +ship undetected. Each cell asserts pixel parity against the eager +numpy read of the same on-disk file. +""" +from __future__ import annotations + +import numpy as np +import pytest +import xarray as xr + +from xrspatial.geotiff import open_geotiff, to_geotiff + +from .._helpers.markers import requires_gpu + + +# --------------------------------------------------------------------------- +# Degenerate fixture set: every shape with at least one size-1 dimension. +# --------------------------------------------------------------------------- + +_DEGENERATE_SHAPES = { + "1x1": np.array([[42.0]], dtype=np.float32), + "1xN": np.arange(10, dtype=np.float32).reshape(1, 10), + "Nx1": np.arange(10, dtype=np.float32).reshape(10, 1), +} + + +def _write_degenerate(tmp_path, shape_id): + """Write a degenerate-shape georeferenced TIFF and return its path. + + The transform is supplied explicitly via ``attrs['transform']``: the + writer cannot infer a pixel size from a single-element coord axis, so + a 1x1 / 1xN / Nx1 array with spatial coords on both axes needs the + affine spelled out (rasterio 6-tuple ``(px, 0, ox, 0, py, oy)``). + """ + arr = _DEGENERATE_SHAPES[shape_id] + height, width = arr.shape + da = xr.DataArray( + arr, + dims=["y", "x"], + coords={ + "y": np.arange(height - 1, -1, -1, dtype=np.float64), + "x": np.arange(width, dtype=np.float64), + }, + attrs={ + "crs": 4326, + # Unit pixels, origin at the (0, height) edge: x centres at + # 0..width-1, y centres descending height-1..0. + "transform": (1.0, 0.0, -0.5, 0.0, -1.0, height - 0.5), + }, + ) + path = str(tmp_path / f"degenerate_{shape_id}.tif") + to_geotiff(da, path, compression="none", tiled=False) + return path, arr + + +def _materialise(da): + """numpy view of a possibly dask/cupy-backed DataArray.""" + raw = da.data + if hasattr(raw, "compute"): + raw = raw.compute() + if hasattr(raw, "get"): + raw = raw.get() + return np.asarray(raw) + + +# --------------------------------------------------------------------------- +# Dask read: windowed chunking on a single-pixel dimension. +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("shape_id", list(_DEGENERATE_SHAPES)) +@pytest.mark.parametrize("chunks", [1, 3, 4]) +def test_dask_read_degenerate_matches_eager(tmp_path, shape_id, chunks): + """``open_geotiff(chunks=...)`` on a degenerate source equals the eager read. + + Exercises the dask window-clamp math when the chunk size meets, splits, + or exceeds a single-pixel dimension. The eager reader does one full + read and never hits this windowing path. + """ + path, arr = _write_degenerate(tmp_path, shape_id) + eager = open_geotiff(path) + lazy = open_geotiff(path, chunks=chunks) + # Graph builds and shape is correct before compute. + assert lazy.shape == arr.shape + assert lazy.dims == ("y", "x") + np.testing.assert_array_equal(_materialise(lazy), _materialise(eager)) + + +@pytest.mark.parametrize("shape_id", list(_DEGENERATE_SHAPES)) +def test_dask_read_degenerate_preserves_coords_and_crs(tmp_path, shape_id): + """Degenerate dask read keeps x/y coords, transform, and CRS attrs.""" + path, _ = _write_degenerate(tmp_path, shape_id) + eager = open_geotiff(path) + lazy = open_geotiff(path, chunks=4) + np.testing.assert_array_equal( + lazy.coords["x"].values, eager.coords["x"].values) + np.testing.assert_array_equal( + lazy.coords["y"].values, eager.coords["y"].values) + assert lazy.attrs.get("transform") == eager.attrs.get("transform") + assert lazy.attrs.get("crs") == eager.attrs.get("crs") == 4326 + + +# --------------------------------------------------------------------------- +# GPU read: decode-kernel launch on a degenerate grid. +# --------------------------------------------------------------------------- + +@requires_gpu +@pytest.mark.parametrize("shape_id", list(_DEGENERATE_SHAPES)) +def test_gpu_read_degenerate_matches_eager(tmp_path, shape_id): + """``open_geotiff(gpu=True)`` on a degenerate source equals the eager read. + + A 1x1 / 1xN / Nx1 source launches the GPU decode path with a + grid-size-1 dimension. Pins that the cupy result matches the numpy + reference byte-for-byte. + """ + path, arr = _write_degenerate(tmp_path, shape_id) + eager = open_geotiff(path) + gpu = open_geotiff(path, gpu=True) + assert gpu.shape == arr.shape + np.testing.assert_array_equal(_materialise(gpu), _materialise(eager)) + + +@requires_gpu +@pytest.mark.parametrize("shape_id", list(_DEGENERATE_SHAPES)) +def test_dask_gpu_read_degenerate_matches_eager(tmp_path, shape_id): + """``open_geotiff(gpu=True, chunks=...)`` on a degenerate source. + + The out-of-core GPU path combines the dask windowing and the GPU + decode launch; both run on a single-pixel dimension here. + """ + path, arr = _write_degenerate(tmp_path, shape_id) + eager = open_geotiff(path) + dask_gpu = open_geotiff(path, gpu=True, chunks=4) + assert dask_gpu.shape == arr.shape + np.testing.assert_array_equal( + _materialise(dask_gpu), _materialise(eager))