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 @@ -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."
150 changes: 150 additions & 0 deletions xrspatial/geotiff/tests/read/test_degenerate_shapes.py
Original file line number Diff line number Diff line change
@@ -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))
Loading