Skip to content

Commit 977968f

Browse files
authored
Add test coverage for overview_resampling min/max/median modes (#1639)
* Add test coverage for overview_resampling min/max/median modes The to_geotiff and write_geotiff_gpu overview_resampling parameter accepts six modes (mean, nearest, mode, cubic, min, max, median) but the last three had no test coverage on either the CPU block reducer (_block_reduce_2d) or the GPU block reducer (_block_reduce_2d_gpu). A regression in any of those branches would ship undetected. This commit closes a Cat 4 HIGH parameter-coverage gap with 26 new tests: * CPU and GPU unit tests for the block reducer on finite input. * CPU and GPU unit tests for partial-NaN input verifying the nan-aware reductions skip NaN cells. * End-to-end COG writes for to_geotiff(cog=True) and write_geotiff_gpu(cog=True) for each of the three modes. * CPU/GPU parity check for to_geotiff(gpu=True) overview output. * CPU nodata-sentinel regression (issue 1613 path, here extended to the min/max/median branches that 1613 did not test). * Error path: ValueError on an unknown method name for both backends. All 26 tests pass on a GPU-enabled host. Pass 6 of the test-coverage sweep on the geotiff module. State CSV notes column updated. * Address Copilot review on #1639: clarify prior coverage scope - Fix "six" -> "seven" reductions count (mean, nearest, min, max, median, mode, cubic). - Reword module docstring: CPU end-to-end paths for min/max/median were already covered by test_cog_overview_nodata_1613; the gap this file closes is GPU end-to-end + direct CPU/GPU block-reducer branches. - Mirror the clarification in the sweep-test-coverage state CSV row.
1 parent d9e1a5b commit 977968f

2 files changed

Lines changed: 318 additions & 1 deletion

File tree

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
module,last_inspected,issue,severity_max,categories_found,notes
2-
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)."
2+
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. CPU end-to-end paths were already covered by test_cog_overview_nodata_1613::test_cpu_cog_overview_aggregations_ignore_sentinel; the GPU end-to-end paths and the direct CPU+GPU block-reducer branches had no targeted tests, so a regression on those code paths 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)."
33
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."
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
"""Parameter coverage for the ``overview_resampling`` modes
2+
``'min'``, ``'max'``, and ``'median'``.
3+
4+
The CPU writer (``xrspatial.geotiff._writer._block_reduce_2d``) and the
5+
GPU writer (``xrspatial.geotiff._gpu_decode._block_reduce_2d_gpu``) both
6+
implement seven resampling reductions for COG overview generation:
7+
8+
* ``mean`` -- covered by ``test_cog_overview_nodata_1613`` and
9+
``test_features``.
10+
* ``nearest`` -- covered by ``test_features`` and the same suite.
11+
* ``mode`` -- covered by ``test_mode_overview_perf``.
12+
* ``cubic`` -- covered by ``test_cog_cubic_overview_nodata_1623``.
13+
* ``min`` / ``max`` / ``median`` -- CPU end-to-end paths covered by
14+
``test_cog_overview_nodata_1613::test_cpu_cog_overview_aggregations_ignore_sentinel``,
15+
but the GPU end-to-end paths and the direct CPU/GPU block-reducer
16+
branches had no targeted tests prior to this file.
17+
18+
Test coverage gap sweep 2026-05-11 (pass 6) closes a Cat 4 (parameter
19+
coverage) HIGH gap: the GPU end-to-end paths and the direct CPU+GPU
20+
block-reducer branches for ``overview_resampling='min'/'max'/'median'``
21+
had no targeted tests, so a regression on those code paths would ship
22+
undetected.
23+
24+
The tests cover:
25+
26+
* CPU writer (``to_geotiff(cog=True, overview_resampling='min'/'max'/'median')``)
27+
with finite data, and with a nodata sentinel so the nan-aware
28+
reductions (``nanmin`` / ``nanmax`` / ``nanmedian``) get exercised.
29+
* GPU writer (``write_geotiff_gpu`` and ``to_geotiff(gpu=True, ...)``)
30+
for the three modes, verifying the cupy implementation matches the
31+
CPU implementation byte-for-byte.
32+
* The ``cog=True`` overview-level read path round-trips the resampled
33+
data so the full write/read pipeline is exercised.
34+
"""
35+
from __future__ import annotations
36+
37+
import importlib.util
38+
39+
import numpy as np
40+
import pytest
41+
import xarray as xr
42+
43+
from xrspatial.geotiff import open_geotiff, to_geotiff
44+
from xrspatial.geotiff._writer import _block_reduce_2d
45+
46+
47+
def _gpu_available() -> bool:
48+
"""True if cupy is importable and CUDA is initialised."""
49+
if importlib.util.find_spec("cupy") is None:
50+
return False
51+
try:
52+
import cupy
53+
return bool(cupy.cuda.is_available())
54+
except Exception:
55+
return False
56+
57+
58+
_HAS_GPU = _gpu_available()
59+
_gpu_only = pytest.mark.skipif(
60+
not _HAS_GPU,
61+
reason="cupy + CUDA required",
62+
)
63+
64+
65+
# ---------------------------------------------------------------------------
66+
# Fixtures: 4x4 rasters with deterministic values so the 2x decimated
67+
# overview has a closed-form min / max / median for every 2x2 block.
68+
# ---------------------------------------------------------------------------
69+
70+
def _arr_4x4_ramp() -> np.ndarray:
71+
"""4x4 float32 ramp.
72+
73+
Block layout (top-left 2x2, top-right 2x2, ...):
74+
75+
[ 1 2 | 3 4 ]
76+
[ 5 6 | 7 8 ]
77+
--------------
78+
[ 9 10 |11 12 ]
79+
[13 14 |15 16 ]
80+
81+
Per-block reductions:
82+
* min: [[1, 3], [9, 11]]
83+
* max: [[6, 8], [14, 16]]
84+
* median: [[3.5, 5.5], [11.5, 13.5]] (mean of the two middle values)
85+
"""
86+
return np.arange(1, 17, dtype=np.float32).reshape(4, 4)
87+
88+
89+
def _arr_4x4_with_nan() -> np.ndarray:
90+
"""4x4 float32 ramp with one NaN per top-row 2x2 block.
91+
92+
Block layout:
93+
94+
[NaN 2 | 3 NaN]
95+
[ 5 6 | 7 8 ]
96+
--------------
97+
[ 9 10 |11 12 ]
98+
[ 13 14 |15 16 ]
99+
100+
Per-block reductions (NaN ignored):
101+
* min: [[2, 3], [9, 11]]
102+
* max: [[6, 8], [14, 16]]
103+
* median: the four-cell median ignoring NaN. For top-left, finite
104+
cells are {2, 5, 6} -> median 5. Top-right finite {3, 7, 8} -> 7.
105+
Bottom rows are unchanged ramp medians.
106+
"""
107+
arr = _arr_4x4_ramp()
108+
arr[0, 0] = np.nan
109+
arr[0, 3] = np.nan
110+
return arr
111+
112+
113+
# Expected outputs are computed via numpy.nan* once at module import so
114+
# they double as a CPU-impl correctness check.
115+
116+
_RAMP_EXPECTED_MIN = np.array([[1.0, 3.0], [9.0, 11.0]], dtype=np.float32)
117+
_RAMP_EXPECTED_MAX = np.array([[6.0, 8.0], [14.0, 16.0]], dtype=np.float32)
118+
_RAMP_EXPECTED_MEDIAN = np.array([[3.5, 5.5], [11.5, 13.5]], dtype=np.float32)
119+
120+
121+
# ---------------------------------------------------------------------------
122+
# Cat 4 HIGH: CPU writer overview_resampling=min/max/median (block reducer).
123+
# ---------------------------------------------------------------------------
124+
125+
@pytest.mark.parametrize("method, expected", [
126+
('min', _RAMP_EXPECTED_MIN),
127+
('max', _RAMP_EXPECTED_MAX),
128+
('median', _RAMP_EXPECTED_MEDIAN),
129+
])
130+
def test_block_reduce_2d_cpu(method, expected):
131+
"""``_block_reduce_2d`` returns the documented reduction per 2x2 block."""
132+
arr = _arr_4x4_ramp()
133+
out = _block_reduce_2d(arr, method)
134+
np.testing.assert_allclose(out, expected)
135+
136+
137+
@pytest.mark.parametrize("method", ['min', 'max', 'median'])
138+
def test_block_reduce_2d_cpu_skips_nan(method):
139+
"""``_block_reduce_2d`` uses nan-aware reductions so partial-NaN
140+
blocks aggregate over the finite cells only."""
141+
arr = _arr_4x4_with_nan()
142+
out = _block_reduce_2d(arr, method)
143+
assert np.all(np.isfinite(out)), (
144+
f"method={method!r} returned NaN for a partial-NaN block")
145+
146+
# Recompute expected via numpy nan-aware ops on the same 2x2 reshape.
147+
blocks = arr.reshape(2, 2, 2, 2)
148+
flat = blocks.transpose(0, 2, 1, 3).reshape(2, 2, 4)
149+
if method == 'min':
150+
expected = np.nanmin(flat, axis=2)
151+
elif method == 'max':
152+
expected = np.nanmax(flat, axis=2)
153+
else:
154+
expected = np.nanmedian(flat, axis=2)
155+
np.testing.assert_allclose(out, expected.astype(np.float32))
156+
157+
158+
@pytest.mark.parametrize("method, expected", [
159+
('min', _RAMP_EXPECTED_MIN),
160+
('max', _RAMP_EXPECTED_MAX),
161+
('median', _RAMP_EXPECTED_MEDIAN),
162+
])
163+
def test_to_geotiff_cog_overview_resampling_cpu(tmp_path, method, expected):
164+
"""End-to-end: ``to_geotiff(cog=True, overview_resampling=method)``
165+
writes a COG whose overview level 1 matches the closed-form 2x2
166+
reduction."""
167+
arr = _arr_4x4_ramp()
168+
da = xr.DataArray(arr, dims=['y', 'x'])
169+
p = str(tmp_path / f'cog_{method}.tif')
170+
to_geotiff(da, p, cog=True, compression='deflate', tiled=True,
171+
tile_size=2, overview_levels=[1],
172+
overview_resampling=method)
173+
174+
ov = open_geotiff(p, overview_level=1)
175+
np.testing.assert_allclose(np.asarray(ov.data), expected)
176+
177+
178+
@pytest.mark.parametrize("method", ['min', 'max', 'median'])
179+
def test_to_geotiff_cog_overview_resampling_cpu_nodata(tmp_path, method):
180+
"""CPU writer: nan-aware reductions skip the sentinel when ``nodata``
181+
is set (the regression that motivated issue #1613, here covering the
182+
min/max/median branches that #1613 did not test)."""
183+
arr = _arr_4x4_with_nan()
184+
da = xr.DataArray(arr, dims=['y', 'x'])
185+
p = str(tmp_path / f'cog_{method}_nodata.tif')
186+
to_geotiff(da, p, nodata=-9999.0, cog=True, compression='deflate',
187+
tiled=True, tile_size=2, overview_levels=[1],
188+
overview_resampling=method)
189+
190+
ov = open_geotiff(p, overview_level=1)
191+
out = np.asarray(ov.data)
192+
193+
# Recompute expected from the same nan-aware reduction on the source.
194+
blocks = arr.reshape(2, 2, 2, 2)
195+
flat = blocks.transpose(0, 2, 1, 3).reshape(2, 2, 4)
196+
if method == 'min':
197+
expected = np.nanmin(flat, axis=2)
198+
elif method == 'max':
199+
expected = np.nanmax(flat, axis=2)
200+
else:
201+
expected = np.nanmedian(flat, axis=2)
202+
np.testing.assert_allclose(out, expected.astype(np.float32))
203+
204+
205+
# ---------------------------------------------------------------------------
206+
# Cat 4 HIGH: GPU writer overview_resampling=min/max/median.
207+
# ---------------------------------------------------------------------------
208+
209+
@_gpu_only
210+
@pytest.mark.parametrize("method, expected", [
211+
('min', _RAMP_EXPECTED_MIN),
212+
('max', _RAMP_EXPECTED_MAX),
213+
('median', _RAMP_EXPECTED_MEDIAN),
214+
])
215+
def test_block_reduce_2d_gpu(method, expected):
216+
"""``_block_reduce_2d_gpu`` returns the same reduction as the CPU
217+
block reducer for finite input."""
218+
import cupy
219+
220+
from xrspatial.geotiff._gpu_decode import _block_reduce_2d_gpu
221+
222+
arr_cpu = _arr_4x4_ramp()
223+
arr_gpu = cupy.asarray(arr_cpu)
224+
out = _block_reduce_2d_gpu(arr_gpu, method)
225+
np.testing.assert_allclose(cupy.asnumpy(out), expected)
226+
227+
228+
@_gpu_only
229+
@pytest.mark.parametrize("method", ['min', 'max', 'median'])
230+
def test_block_reduce_2d_gpu_matches_cpu_with_nan(method):
231+
"""GPU nan-aware reductions match CPU nan-aware reductions for a
232+
partial-NaN block."""
233+
import cupy
234+
235+
from xrspatial.geotiff._gpu_decode import _block_reduce_2d_gpu
236+
237+
arr_cpu = _arr_4x4_with_nan()
238+
cpu_out = _block_reduce_2d(arr_cpu, method)
239+
gpu_out = _block_reduce_2d_gpu(cupy.asarray(arr_cpu), method)
240+
np.testing.assert_allclose(cupy.asnumpy(gpu_out), cpu_out)
241+
242+
243+
@_gpu_only
244+
@pytest.mark.parametrize("method, expected", [
245+
('min', _RAMP_EXPECTED_MIN),
246+
('max', _RAMP_EXPECTED_MAX),
247+
('median', _RAMP_EXPECTED_MEDIAN),
248+
])
249+
def test_write_geotiff_gpu_cog_overview_resampling(tmp_path, method, expected):
250+
"""End-to-end: ``write_geotiff_gpu(cog=True, overview_resampling=method)``
251+
writes a COG whose overview level 1 matches the closed-form 2x2
252+
reduction. Exercises the GPU make-overview path including the dispatch
253+
on ``method``."""
254+
import cupy
255+
256+
from xrspatial.geotiff import write_geotiff_gpu
257+
258+
arr = _arr_4x4_ramp()
259+
arr_gpu = cupy.asarray(arr)
260+
da = xr.DataArray(arr_gpu, dims=['y', 'x'])
261+
p = str(tmp_path / f'cog_{method}_gpu.tif')
262+
write_geotiff_gpu(da, p, cog=True, compression='deflate', tiled=True,
263+
tile_size=2, overview_levels=[1],
264+
overview_resampling=method)
265+
266+
ov = open_geotiff(p, overview_level=1)
267+
np.testing.assert_allclose(np.asarray(ov.data), expected)
268+
269+
270+
@_gpu_only
271+
@pytest.mark.parametrize("method", ['min', 'max', 'median'])
272+
def test_to_geotiff_gpu_cog_overview_matches_cpu(tmp_path, method):
273+
"""``to_geotiff(gpu=True, ..., overview_resampling=method)`` produces
274+
overview bytes that round-trip to the same values as the CPU writer."""
275+
import cupy
276+
277+
arr = _arr_4x4_ramp()
278+
da_cpu = xr.DataArray(arr, dims=['y', 'x'])
279+
p_cpu = str(tmp_path / f'cog_{method}_cpu.tif')
280+
to_geotiff(da_cpu, p_cpu, cog=True, compression='deflate', tiled=True,
281+
tile_size=2, overview_levels=[1],
282+
overview_resampling=method)
283+
284+
da_gpu = xr.DataArray(cupy.asarray(arr), dims=['y', 'x'])
285+
p_gpu = str(tmp_path / f'cog_{method}_gpu_via_to_geotiff.tif')
286+
to_geotiff(da_gpu, p_gpu, gpu=True, cog=True, compression='deflate',
287+
tiled=True, tile_size=2, overview_levels=[1],
288+
overview_resampling=method)
289+
290+
ov_cpu = np.asarray(open_geotiff(p_cpu, overview_level=1).data)
291+
ov_gpu = np.asarray(open_geotiff(p_gpu, overview_level=1).data)
292+
np.testing.assert_allclose(ov_gpu, ov_cpu)
293+
294+
295+
# ---------------------------------------------------------------------------
296+
# Error path: unknown method names raise ValueError on both backends.
297+
# ---------------------------------------------------------------------------
298+
299+
def test_block_reduce_2d_cpu_unknown_method_raises():
300+
"""The CPU block reducer raises ``ValueError`` on an unknown method
301+
name. Exercises the else-branch that lists the valid methods."""
302+
arr = _arr_4x4_ramp()
303+
with pytest.raises(ValueError, match="Unknown overview resampling"):
304+
_block_reduce_2d(arr, 'bogus')
305+
306+
307+
@_gpu_only
308+
def test_block_reduce_2d_gpu_unknown_method_raises():
309+
"""The GPU block reducer raises ``ValueError`` on an unknown method
310+
name. The CPU equivalent already raises for parity."""
311+
import cupy
312+
313+
from xrspatial.geotiff._gpu_decode import _block_reduce_2d_gpu
314+
315+
arr_gpu = cupy.asarray(_arr_4x4_ramp())
316+
with pytest.raises(ValueError, match="Unknown GPU overview resampling"):
317+
_block_reduce_2d_gpu(arr_gpu, 'bogus')

0 commit comments

Comments
 (0)