Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion .claude/sweep-test-coverage-state.csv
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module,last_inspected,issue,severity_max,categories_found,notes
geotiff,2026-05-11,,HIGH,2;3,"Pass 5 (2026-05-11): added test_degenerate_shapes_backends_2026_05_11.py covering Cat 3 HIGH geometric gaps (1x1 / 1xN / Nx1 reads on dask+numpy, GPU, dask+cupy backends; 1x1 / 1xN / Nx1 writes through write_geotiff_gpu) and Cat 2 MEDIUM NaN/Inf gaps (all-NaN read on GPU + dask+cupy, Inf / -Inf reads on all non-eager backends, NaN sentinel mask on dask read path including sentinel block split across chunk boundary). 23 tests, all passing on GPU host. Prior passes still hold: pass 4 (r4) closed read_geotiff_gpu/dask name= + max_pixels= kwargs (Cat 4), pass 3 (r3) closed read_vrt GPU/dask+GPU backend dispatch (Cat 1) and dtype/name kwargs (Cat 4)."
geotiff,2026-05-11,,HIGH,2;3;4,"Pass 6 (2026-05-11): added test_overview_resampling_min_max_median_2026_05_11.py covering Cat 4 HIGH parameter-coverage gap on overview_resampling=min/max/median. Both CPU (_block_reduce_2d) and GPU (_block_reduce_2d_gpu) reductions had branches with zero test coverage; an unnoticed regression in any of the three modes would ship undetected. 26 tests, all passing on GPU host: block-reducer unit tests (finite + partial-NaN), end-to-end COG writes for both to_geotiff and write_geotiff_gpu, CPU/GPU parity for to_geotiff(gpu=True), CPU nodata-sentinel regression check, and ValueError error-path tests for unknown method names on both backends. Pass 5 (2026-05-11): added test_degenerate_shapes_backends_2026_05_11.py covering Cat 3 HIGH geometric gaps (1x1 / 1xN / Nx1 reads on dask+numpy, GPU, dask+cupy backends; 1x1 / 1xN / Nx1 writes through write_geotiff_gpu) and Cat 2 MEDIUM NaN/Inf gaps (all-NaN read on GPU + dask+cupy, Inf / -Inf reads on all non-eager backends, NaN sentinel mask on dask read path including sentinel block split across chunk boundary). 23 tests, all passing on GPU host. Prior passes still hold: pass 4 (r4) closed read_geotiff_gpu/dask name= + max_pixels= kwargs (Cat 4), pass 3 (r3) closed read_vrt GPU/dask+GPU backend dispatch (Cat 1) and dtype/name kwargs (Cat 4)."
reproject,2026-05-10,,HIGH,1;4;5,"Added 39 tests: LiteCRS direct coverage, itrf_transform behaviour/roundtrip/array, itrf_frames, geoid_height numerical correctness + raster happy-path, vertical helpers (ellipsoidal<->orthometric/depth), reproject() lat/lon and latitude/longitude dim propagation. Note: _merge_arrays_cupy is imported but unused (no cupy merge dispatch in merge()); flagged as feature gap not test gap."
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
"""Parameter coverage for the ``overview_resampling`` modes
``'min'``, ``'max'``, and ``'median'``.

The CPU writer (``xrspatial.geotiff._writer._block_reduce_2d``) and the
GPU writer (``xrspatial.geotiff._gpu_decode._block_reduce_2d_gpu``) both
implement six resampling reductions for COG overview generation:

* ``mean`` -- covered by ``test_cog_overview_nodata_1613`` and
``test_features``.
* ``nearest`` -- covered by ``test_features`` and the same suite.
* ``mode`` -- covered by ``test_mode_overview_perf``.
* ``cubic`` -- covered by ``test_cog_cubic_overview_nodata_1623``.
* ``min`` -- NO coverage prior to this file.
* ``max`` -- NO coverage prior to this file.
* ``median`` -- NO coverage prior to this file.

Test coverage gap sweep 2026-05-11 (pass 6) closes a Cat 4 (parameter
coverage) HIGH gap: three documented public modes of the
``overview_resampling`` parameter on ``to_geotiff`` and
``write_geotiff_gpu`` had implementations but no tests, so a regression
in any of those reduction branches would ship undetected.

The tests cover:

* CPU writer (``to_geotiff(cog=True, overview_resampling='min'/'max'/'median')``)
with finite data, and with a nodata sentinel so the nan-aware
reductions (``nanmin`` / ``nanmax`` / ``nanmedian``) get exercised.
* GPU writer (``write_geotiff_gpu`` and ``to_geotiff(gpu=True, ...)``)
for the three modes, verifying the cupy implementation matches the
CPU implementation byte-for-byte.
* The ``cog=True`` overview-level read path round-trips the resampled
data so the full write/read pipeline is exercised.
"""
from __future__ import annotations

import importlib.util

import numpy as np
import pytest
import xarray as xr

from xrspatial.geotiff import open_geotiff, to_geotiff
from xrspatial.geotiff._writer import _block_reduce_2d


def _gpu_available() -> bool:
"""True if cupy is importable and CUDA is initialised."""
if importlib.util.find_spec("cupy") is None:
return False
try:
import cupy
return bool(cupy.cuda.is_available())
except Exception:
return False


_HAS_GPU = _gpu_available()
_gpu_only = pytest.mark.skipif(
not _HAS_GPU,
reason="cupy + CUDA required",
)


# ---------------------------------------------------------------------------
# Fixtures: 4x4 rasters with deterministic values so the 2x decimated
# overview has a closed-form min / max / median for every 2x2 block.
# ---------------------------------------------------------------------------

def _arr_4x4_ramp() -> np.ndarray:
"""4x4 float32 ramp.

Block layout (top-left 2x2, top-right 2x2, ...):

[ 1 2 | 3 4 ]
[ 5 6 | 7 8 ]
--------------
[ 9 10 |11 12 ]
[13 14 |15 16 ]

Per-block reductions:
* min: [[1, 3], [9, 11]]
* max: [[6, 8], [14, 16]]
* median: [[3.5, 5.5], [11.5, 13.5]] (mean of the two middle values)
"""
return np.arange(1, 17, dtype=np.float32).reshape(4, 4)


def _arr_4x4_with_nan() -> np.ndarray:
"""4x4 float32 ramp with one NaN per top-row 2x2 block.

Block layout:

[NaN 2 | 3 NaN]
[ 5 6 | 7 8 ]
--------------
[ 9 10 |11 12 ]
[ 13 14 |15 16 ]

Per-block reductions (NaN ignored):
* min: [[2, 3], [9, 11]]
* max: [[6, 8], [14, 16]]
* median: the four-cell median ignoring NaN. For top-left, finite
cells are {2, 5, 6} -> median 5. Top-right finite {3, 7, 8} -> 7.
Bottom rows are unchanged ramp medians.
"""
arr = _arr_4x4_ramp()
arr[0, 0] = np.nan
arr[0, 3] = np.nan
return arr


# Expected outputs are computed via numpy.nan* once at module import so
# they double as a CPU-impl correctness check.

_RAMP_EXPECTED_MIN = np.array([[1.0, 3.0], [9.0, 11.0]], dtype=np.float32)
_RAMP_EXPECTED_MAX = np.array([[6.0, 8.0], [14.0, 16.0]], dtype=np.float32)
_RAMP_EXPECTED_MEDIAN = np.array([[3.5, 5.5], [11.5, 13.5]], dtype=np.float32)


# ---------------------------------------------------------------------------
# Cat 4 HIGH: CPU writer overview_resampling=min/max/median (block reducer).
# ---------------------------------------------------------------------------

@pytest.mark.parametrize("method, expected", [
('min', _RAMP_EXPECTED_MIN),
('max', _RAMP_EXPECTED_MAX),
('median', _RAMP_EXPECTED_MEDIAN),
])
def test_block_reduce_2d_cpu(method, expected):
"""``_block_reduce_2d`` returns the documented reduction per 2x2 block."""
arr = _arr_4x4_ramp()
out = _block_reduce_2d(arr, method)
np.testing.assert_allclose(out, expected)


@pytest.mark.parametrize("method", ['min', 'max', 'median'])
def test_block_reduce_2d_cpu_skips_nan(method):
"""``_block_reduce_2d`` uses nan-aware reductions so partial-NaN
blocks aggregate over the finite cells only."""
arr = _arr_4x4_with_nan()
out = _block_reduce_2d(arr, method)
assert np.all(np.isfinite(out)), (
f"method={method!r} returned NaN for a partial-NaN block")

# Recompute expected via numpy nan-aware ops on the same 2x2 reshape.
blocks = arr.reshape(2, 2, 2, 2)
flat = blocks.transpose(0, 2, 1, 3).reshape(2, 2, 4)
if method == 'min':
expected = np.nanmin(flat, axis=2)
elif method == 'max':
expected = np.nanmax(flat, axis=2)
else:
expected = np.nanmedian(flat, axis=2)
np.testing.assert_allclose(out, expected.astype(np.float32))


@pytest.mark.parametrize("method, expected", [
('min', _RAMP_EXPECTED_MIN),
('max', _RAMP_EXPECTED_MAX),
('median', _RAMP_EXPECTED_MEDIAN),
])
def test_to_geotiff_cog_overview_resampling_cpu(tmp_path, method, expected):
"""End-to-end: ``to_geotiff(cog=True, overview_resampling=method)``
writes a COG whose overview level 1 matches the closed-form 2x2
reduction."""
arr = _arr_4x4_ramp()
da = xr.DataArray(arr, dims=['y', 'x'])
p = str(tmp_path / f'cog_{method}.tif')
to_geotiff(da, p, cog=True, compression='deflate', tiled=True,
tile_size=2, overview_levels=[1],
overview_resampling=method)

ov = open_geotiff(p, overview_level=1)
np.testing.assert_allclose(np.asarray(ov.data), expected)


@pytest.mark.parametrize("method", ['min', 'max', 'median'])
def test_to_geotiff_cog_overview_resampling_cpu_nodata(tmp_path, method):
"""CPU writer: nan-aware reductions skip the sentinel when ``nodata``
is set (the regression that motivated issue #1613, here covering the
min/max/median branches that #1613 did not test)."""
arr = _arr_4x4_with_nan()
da = xr.DataArray(arr, dims=['y', 'x'])
p = str(tmp_path / f'cog_{method}_nodata.tif')
to_geotiff(da, p, nodata=-9999.0, cog=True, compression='deflate',
tiled=True, tile_size=2, overview_levels=[1],
overview_resampling=method)

ov = open_geotiff(p, overview_level=1)
out = np.asarray(ov.data)

# Recompute expected from the same nan-aware reduction on the source.
blocks = arr.reshape(2, 2, 2, 2)
flat = blocks.transpose(0, 2, 1, 3).reshape(2, 2, 4)
if method == 'min':
expected = np.nanmin(flat, axis=2)
elif method == 'max':
expected = np.nanmax(flat, axis=2)
else:
expected = np.nanmedian(flat, axis=2)
np.testing.assert_allclose(out, expected.astype(np.float32))


# ---------------------------------------------------------------------------
# Cat 4 HIGH: GPU writer overview_resampling=min/max/median.
# ---------------------------------------------------------------------------

@_gpu_only
@pytest.mark.parametrize("method, expected", [
('min', _RAMP_EXPECTED_MIN),
('max', _RAMP_EXPECTED_MAX),
('median', _RAMP_EXPECTED_MEDIAN),
])
def test_block_reduce_2d_gpu(method, expected):
"""``_block_reduce_2d_gpu`` returns the same reduction as the CPU
block reducer for finite input."""
import cupy

from xrspatial.geotiff._gpu_decode import _block_reduce_2d_gpu

arr_cpu = _arr_4x4_ramp()
arr_gpu = cupy.asarray(arr_cpu)
out = _block_reduce_2d_gpu(arr_gpu, method)
np.testing.assert_allclose(cupy.asnumpy(out), expected)


@_gpu_only
@pytest.mark.parametrize("method", ['min', 'max', 'median'])
def test_block_reduce_2d_gpu_matches_cpu_with_nan(method):
"""GPU nan-aware reductions match CPU nan-aware reductions for a
partial-NaN block."""
import cupy

from xrspatial.geotiff._gpu_decode import _block_reduce_2d_gpu

arr_cpu = _arr_4x4_with_nan()
cpu_out = _block_reduce_2d(arr_cpu, method)
gpu_out = _block_reduce_2d_gpu(cupy.asarray(arr_cpu), method)
np.testing.assert_allclose(cupy.asnumpy(gpu_out), cpu_out)


@_gpu_only
@pytest.mark.parametrize("method, expected", [
('min', _RAMP_EXPECTED_MIN),
('max', _RAMP_EXPECTED_MAX),
('median', _RAMP_EXPECTED_MEDIAN),
])
def test_write_geotiff_gpu_cog_overview_resampling(tmp_path, method, expected):
"""End-to-end: ``write_geotiff_gpu(cog=True, overview_resampling=method)``
writes a COG whose overview level 1 matches the closed-form 2x2
reduction. Exercises the GPU make-overview path including the dispatch
on ``method``."""
import cupy

from xrspatial.geotiff import write_geotiff_gpu

arr = _arr_4x4_ramp()
arr_gpu = cupy.asarray(arr)
da = xr.DataArray(arr_gpu, dims=['y', 'x'])
p = str(tmp_path / f'cog_{method}_gpu.tif')
write_geotiff_gpu(da, p, cog=True, compression='deflate', tiled=True,
tile_size=2, overview_levels=[1],
overview_resampling=method)

ov = open_geotiff(p, overview_level=1)
np.testing.assert_allclose(np.asarray(ov.data), expected)


@_gpu_only
@pytest.mark.parametrize("method", ['min', 'max', 'median'])
def test_to_geotiff_gpu_cog_overview_matches_cpu(tmp_path, method):
"""``to_geotiff(gpu=True, ..., overview_resampling=method)`` produces
overview bytes that round-trip to the same values as the CPU writer."""
import cupy

arr = _arr_4x4_ramp()
da_cpu = xr.DataArray(arr, dims=['y', 'x'])
p_cpu = str(tmp_path / f'cog_{method}_cpu.tif')
to_geotiff(da_cpu, p_cpu, cog=True, compression='deflate', tiled=True,
tile_size=2, overview_levels=[1],
overview_resampling=method)

da_gpu = xr.DataArray(cupy.asarray(arr), dims=['y', 'x'])
p_gpu = str(tmp_path / f'cog_{method}_gpu_via_to_geotiff.tif')
to_geotiff(da_gpu, p_gpu, gpu=True, cog=True, compression='deflate',
tiled=True, tile_size=2, overview_levels=[1],
overview_resampling=method)

ov_cpu = np.asarray(open_geotiff(p_cpu, overview_level=1).data)
ov_gpu = np.asarray(open_geotiff(p_gpu, overview_level=1).data)
np.testing.assert_allclose(ov_gpu, ov_cpu)


# ---------------------------------------------------------------------------
# Error path: unknown method names raise ValueError on both backends.
# ---------------------------------------------------------------------------

def test_block_reduce_2d_cpu_unknown_method_raises():
"""The CPU block reducer raises ``ValueError`` on an unknown method
name. Exercises the else-branch that lists the valid methods."""
arr = _arr_4x4_ramp()
with pytest.raises(ValueError, match="Unknown overview resampling"):
_block_reduce_2d(arr, 'bogus')


@_gpu_only
def test_block_reduce_2d_gpu_unknown_method_raises():
"""The GPU block reducer raises ``ValueError`` on an unknown method
name. The CPU equivalent already raises for parity."""
import cupy

from xrspatial.geotiff._gpu_decode import _block_reduce_2d_gpu

arr_gpu = cupy.asarray(_arr_4x4_ramp())
with pytest.raises(ValueError, match="Unknown GPU overview resampling"):
_block_reduce_2d_gpu(arr_gpu, 'bogus')
Loading