Skip to content

Commit 41715c2

Browse files
committed
Cover read_vrt GPU/dask+GPU paths and a few VRT parameter combos (#1619)
Sweep 3 of the geotiff deep-sweep test-coverage pass (2026-05-11). The prior two same-day passes (#1565, #1586, #1596) closed dask streaming codec coverage and the specific accuracy bugs that surfaced. This file closes the remaining backend-coverage holes: * ``read_vrt(gpu=True)`` returns cupy (no prior test). * ``read_vrt(gpu=True, chunks=N)`` returns dask Array with cupy ``_meta`` and the requested chunk shape (no prior test). * ``open_geotiff('.vrt', gpu=True[, chunks=N])`` routes through ``read_vrt`` and surfaces cupy data unchanged. * ``read_vrt(dtype='float64')`` safe widening; ``dtype='int32'`` rejected for a float source (mirrors the ``open_geotiff`` gate). * ``read_vrt(name='custom')`` override; default-from-stem fallback. * ``open_geotiff(BytesIO, gpu=True)`` and ``open_geotiff(BytesIO, chunks=N)`` raise ``ValueError`` -- the file-like rejection branches were unreached by any prior test. 11 new tests, all passing locally on a CUDA host. GPU tests use the same cupy availability skip predicate as the rest of the geotiff suite, so non-GPU CI cleanly skips them. Test-only change. State CSV updated to record sweep 3 outcome. Refs #1619.
1 parent 0088dfe commit 41715c2

2 files changed

Lines changed: 248 additions & 3 deletions

File tree

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
module,last_inspected,issue,severity_max,categories_found,notes
2-
geotiff,2026-05-11,,MEDIUM,4,"Sweep 2 (2026-05-11): added test_streaming_codecs_2026_05_11.py covering dask streaming write + LERC (lossless/lossy/eager-parity), LZ4, packbits, and COG overview_resampling='cubic' (scipy code path). Prior HIGH gaps from PR #1565 remain closed (dask+planar multiband, dask+overview_level, predictor=2 int8 CPU+GPU, dask chunk-vs-tile misalignment). No HIGH gaps remain."
3-
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."
1+
module,last_inspected,issue,severity_max,categories_found,notes
2+
geotiff,2026-05-11,,MEDIUM,1;4,"Sweep 3 (2026-05-11 r3): added test_vrt_backend_coverage_2026_05_11.py closing read_vrt(gpu=True) + read_vrt(gpu=True, chunks=N) (Cat 1, dask+cupy backend), read_vrt(dtype=) safe-widening and float->int validation (Cat 4), read_vrt(name=) override (Cat 4), and open_geotiff(BytesIO, gpu=True) / open_geotiff(BytesIO, chunks=N) error-path coverage (Cat 4). 11 tests, all passing on GPU host. No HIGH gaps remain."
3+
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: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
"""Backend / parameter coverage for the VRT read path.
2+
3+
The non-VRT read backends (``open_geotiff`` / ``read_geotiff_dask`` /
4+
``read_geotiff_gpu``) all have dedicated multi-backend coverage; the
5+
VRT route through ``read_vrt`` historically lacked it. The eager
6+
numpy path has dense coverage, but the GPU and dask+GPU paths the
7+
``read_vrt`` body explicitly handles (the ``if gpu: cupy.asarray``
8+
and trailing ``result.chunk(...)`` blocks) were only reachable
9+
indirectly via ``open_geotiff('.vrt', gpu=True)`` / ``..., chunks=N)``
10+
and went untested.
11+
12+
The error-rejection branches for file-like sources combined with
13+
``gpu=True`` / ``chunks=N`` on ``open_geotiff`` were likewise covered
14+
only by inspection.
15+
16+
Test coverage gap sweep 2026-05-11 (pass 3): close the VRT backend
17+
coverage gap and the file-like-rejection parameter gaps.
18+
"""
19+
from __future__ import annotations
20+
21+
import importlib.util
22+
import io
23+
import os
24+
25+
import numpy as np
26+
import pytest
27+
import xarray as xr
28+
29+
from xrspatial.geotiff import open_geotiff, read_vrt, to_geotiff
30+
from xrspatial.geotiff._vrt import write_vrt as _write_vrt_internal
31+
32+
33+
# ---------------------------------------------------------------------------
34+
# CUDA gating: GPU tests share a skip predicate with the rest of the
35+
# geotiff test suite. The token "cuda-unavailable" in the state CSV
36+
# notes column flags a re-run on a GPU host when a non-CUDA sweep
37+
# added these tests.
38+
# ---------------------------------------------------------------------------
39+
40+
def _cuda_available() -> bool:
41+
if importlib.util.find_spec("cupy") is None:
42+
return False
43+
try:
44+
import cupy
45+
return bool(cupy.cuda.is_available())
46+
except Exception:
47+
return False
48+
49+
50+
_HAS_CUDA = _cuda_available()
51+
_gpu_only = pytest.mark.skipif(not _HAS_CUDA, reason="cupy + CUDA required")
52+
53+
54+
# ---------------------------------------------------------------------------
55+
# Fixtures
56+
# ---------------------------------------------------------------------------
57+
58+
@pytest.fixture
59+
def single_tile_vrt(tmp_path):
60+
"""A trivial single-tile VRT plus its source array.
61+
62+
Float32 source so the VRT band advertises Float32 and the eager
63+
numpy read returns float32 (lets dtype-cast tests assert a real
64+
type change).
65+
"""
66+
arr = np.arange(16, dtype=np.float32).reshape(4, 4)
67+
tile_path = str(tmp_path / 'tile.tif')
68+
to_geotiff(arr, tile_path)
69+
vrt_path = str(tmp_path / 'mosaic.vrt')
70+
_write_vrt_internal(vrt_path, [tile_path])
71+
return vrt_path, arr
72+
73+
74+
# ---------------------------------------------------------------------------
75+
# Cat 1: read_vrt backend coverage (GPU + dask+GPU)
76+
# ---------------------------------------------------------------------------
77+
78+
@_gpu_only
79+
class TestReadVrtGpuBackend:
80+
"""``read_vrt(gpu=True)`` returns a CuPy-backed DataArray.
81+
82+
The eager VRT decode runs on the CPU (the VRT internal reader
83+
walks SimpleSources and assembles via windowed reads) then the
84+
final ``if gpu: arr = cupy.asarray(arr)`` block transfers to GPU.
85+
A regression that dropped the transfer block would have shipped
86+
a numpy DataArray instead of a CuPy one; this test pins that.
87+
"""
88+
89+
def test_read_vrt_gpu_returns_cupy(self, single_tile_vrt):
90+
import cupy
91+
92+
vrt_path, arr = single_tile_vrt
93+
da = read_vrt(vrt_path, gpu=True)
94+
assert isinstance(da.data, cupy.ndarray), (
95+
f"expected cupy.ndarray, got {type(da.data).__name__}"
96+
)
97+
np.testing.assert_array_equal(da.data.get(), arr)
98+
99+
def test_read_vrt_gpu_chunks_returns_dask_cupy(self, single_tile_vrt):
100+
"""``read_vrt(gpu=True, chunks=N)`` is the documented dask+cupy
101+
VRT entry point. The trailing ``result.chunk(...)`` block has
102+
to wrap the cupy backing without falling back to numpy.
103+
"""
104+
import cupy
105+
import dask.array as da_mod
106+
107+
vrt_path, arr = single_tile_vrt
108+
result = read_vrt(vrt_path, gpu=True, chunks=2)
109+
110+
assert isinstance(result.data, da_mod.Array), (
111+
f"expected dask Array, got {type(result.data).__name__}"
112+
)
113+
# _meta tells distributed Dask the underlying array is cupy.
114+
# A numpy meta here would cause optimizers to silently move
115+
# data back to host.
116+
assert isinstance(result.data._meta, cupy.ndarray), (
117+
f"expected cupy._meta, got "
118+
f"{type(result.data._meta).__module__}."
119+
f"{type(result.data._meta).__name__}"
120+
)
121+
# Chunks honour the spatial spec; the band axis (absent here)
122+
# would chunk as a single block.
123+
assert result.data.chunks == ((2, 2), (2, 2))
124+
125+
computed = result.compute()
126+
assert isinstance(computed.data, cupy.ndarray)
127+
np.testing.assert_array_equal(computed.data.get(), arr)
128+
129+
def test_open_geotiff_vrt_gpu_routes_through(self, single_tile_vrt):
130+
"""``open_geotiff('.vrt', gpu=True)`` dispatches to ``read_vrt``
131+
and surfaces the cupy data unchanged. The dispatcher branch
132+
is one line in ``open_geotiff`` but a refactor that dropped
133+
``gpu=gpu`` from the forwarded kwargs would silently produce
134+
a numpy DataArray.
135+
"""
136+
import cupy
137+
138+
vrt_path, arr = single_tile_vrt
139+
da = open_geotiff(vrt_path, gpu=True)
140+
assert isinstance(da.data, cupy.ndarray)
141+
np.testing.assert_array_equal(da.data.get(), arr)
142+
143+
def test_open_geotiff_vrt_gpu_chunks(self, single_tile_vrt):
144+
"""``open_geotiff('.vrt', gpu=True, chunks=N)`` is the combined
145+
dask+cupy entry point. Same dispatch test as the gpu-only
146+
variant but also pins the chunk forwarding.
147+
"""
148+
import cupy
149+
import dask.array as da_mod
150+
151+
vrt_path, arr = single_tile_vrt
152+
result = open_geotiff(vrt_path, gpu=True, chunks=2)
153+
154+
assert isinstance(result.data, da_mod.Array)
155+
assert isinstance(result.data._meta, cupy.ndarray)
156+
assert result.data.chunks == ((2, 2), (2, 2))
157+
158+
computed = result.compute()
159+
np.testing.assert_array_equal(computed.data.get(), arr)
160+
161+
162+
# ---------------------------------------------------------------------------
163+
# Cat 4: read_vrt parameter coverage (dtype / name)
164+
# ---------------------------------------------------------------------------
165+
166+
class TestReadVrtDtypeKwarg:
167+
"""``read_vrt(dtype=...)`` casts after decode and validates the cast."""
168+
169+
def test_safe_widening_cast(self, single_tile_vrt):
170+
"""float32 -> float64 is permitted; values survive bit-for-bit."""
171+
vrt_path, arr = single_tile_vrt
172+
da = read_vrt(vrt_path, dtype='float64')
173+
assert da.dtype == np.float64
174+
np.testing.assert_array_equal(da.values, arr.astype(np.float64))
175+
176+
def test_float_to_int_rejected(self, single_tile_vrt):
177+
"""Float-to-int is lossy and refused with a descriptive error.
178+
Mirrors ``open_geotiff(dtype=...)`` behaviour so callers see the
179+
same gate on both entry points.
180+
"""
181+
vrt_path, _ = single_tile_vrt
182+
with pytest.raises(ValueError, match="Cannot cast float"):
183+
read_vrt(vrt_path, dtype='int32')
184+
185+
186+
class TestReadVrtNameKwarg:
187+
"""``read_vrt(name='custom')`` overrides the file-stem derivation."""
188+
189+
def test_explicit_name_used(self, single_tile_vrt):
190+
vrt_path, _ = single_tile_vrt
191+
da = read_vrt(vrt_path, name='custom_name')
192+
assert da.name == 'custom_name'
193+
194+
def test_default_name_from_stem(self, single_tile_vrt):
195+
vrt_path, _ = single_tile_vrt
196+
da = read_vrt(vrt_path)
197+
# mosaic.vrt -> mosaic
198+
assert da.name == os.path.splitext(os.path.basename(vrt_path))[0]
199+
200+
201+
# ---------------------------------------------------------------------------
202+
# Cat 4: open_geotiff file-like + backend kwarg rejection
203+
# ---------------------------------------------------------------------------
204+
205+
class TestOpenGeotiffFileLikeKwargRejection:
206+
"""File-like sources reject ``gpu=True`` and ``chunks=N`` up front.
207+
208+
The check sits in ``open_geotiff`` (not the underlying readers)
209+
because both downstream paths re-open the source by path from
210+
worker tasks. A buffer passed through would either raise deep
211+
inside dask graph construction or silently behave as if the
212+
buffer were a string path.
213+
"""
214+
215+
@staticmethod
216+
def _buf_with_tiff(tmp_path):
217+
arr = np.zeros((4, 4), dtype=np.float32)
218+
path = str(tmp_path / 'src.tif')
219+
to_geotiff(arr, path)
220+
with open(path, 'rb') as fh:
221+
return io.BytesIO(fh.read())
222+
223+
def test_gpu_with_file_like_raises(self, tmp_path):
224+
buf = self._buf_with_tiff(tmp_path)
225+
with pytest.raises(ValueError, match="gpu=True is not supported"):
226+
open_geotiff(buf, gpu=True)
227+
228+
def test_chunks_with_file_like_raises(self, tmp_path):
229+
buf = self._buf_with_tiff(tmp_path)
230+
with pytest.raises(ValueError, match="chunks=.*file-like"):
231+
open_geotiff(buf, chunks=64)
232+
233+
def test_chunks_with_pathlib_path_still_works(self, tmp_path):
234+
"""Sanity-check: pathlib.Path is not file-like and must keep
235+
working through the dask path. Otherwise the file-like gate
236+
would also lock out Path inputs.
237+
"""
238+
arr = np.arange(16, dtype=np.float32).reshape(4, 4)
239+
path = tmp_path / 'sample.tif'
240+
to_geotiff(arr, str(path))
241+
242+
import dask.array as da_mod
243+
result = open_geotiff(path, chunks=2)
244+
assert isinstance(result.data, da_mod.Array)
245+
np.testing.assert_array_equal(np.asarray(result.data), arr)

0 commit comments

Comments
 (0)