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
6 changes: 3 additions & 3 deletions .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,,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."
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."
module,last_inspected,issue,severity_max,categories_found,notes
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."
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."
245 changes: 245 additions & 0 deletions xrspatial/geotiff/tests/test_vrt_backend_coverage_2026_05_11.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
"""Backend / parameter coverage for the VRT read path.

The non-VRT read backends (``open_geotiff`` / ``read_geotiff_dask`` /
``read_geotiff_gpu``) all have dedicated multi-backend coverage; the
VRT route through ``read_vrt`` historically lacked it. The eager
numpy path has dense coverage, but the GPU and dask+GPU paths the
``read_vrt`` body explicitly handles (the ``if gpu: cupy.asarray``
and trailing ``result.chunk(...)`` blocks) were only reachable
indirectly via ``open_geotiff('.vrt', gpu=True)`` / ``..., chunks=N)``
and went untested.

The error-rejection branches for file-like sources combined with
``gpu=True`` / ``chunks=N`` on ``open_geotiff`` were likewise covered
only by inspection.

Test coverage gap sweep 2026-05-11 (pass 3): close the VRT backend
coverage gap and the file-like-rejection parameter gaps.
"""
from __future__ import annotations

import importlib.util
import io
import os

import numpy as np
import pytest
import xarray as xr

from xrspatial.geotiff import open_geotiff, read_vrt, to_geotiff
from xrspatial.geotiff._vrt import write_vrt as _write_vrt_internal


# ---------------------------------------------------------------------------
# CUDA gating: GPU tests share a skip predicate with the rest of the
# geotiff test suite. The token "cuda-unavailable" in the state CSV
# notes column flags a re-run on a GPU host when a non-CUDA sweep
# added these tests.
# ---------------------------------------------------------------------------

def _cuda_available() -> bool:
if importlib.util.find_spec("cupy") is None:
return False
try:
import cupy
return bool(cupy.cuda.is_available())
except Exception:
return False


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


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

@pytest.fixture
def single_tile_vrt(tmp_path):
"""A trivial single-tile VRT plus its source array.

Float32 source so the VRT band advertises Float32 and the eager
numpy read returns float32 (lets dtype-cast tests assert a real
type change).
"""
arr = np.arange(16, dtype=np.float32).reshape(4, 4)
tile_path = str(tmp_path / 'tile.tif')
to_geotiff(arr, tile_path)
vrt_path = str(tmp_path / 'mosaic.vrt')
_write_vrt_internal(vrt_path, [tile_path])
return vrt_path, arr


# ---------------------------------------------------------------------------
# Cat 1: read_vrt backend coverage (GPU + dask+GPU)
# ---------------------------------------------------------------------------

@_gpu_only
class TestReadVrtGpuBackend:
"""``read_vrt(gpu=True)`` returns a CuPy-backed DataArray.

The eager VRT decode runs on the CPU (the VRT internal reader
walks SimpleSources and assembles via windowed reads) then the
final ``if gpu: arr = cupy.asarray(arr)`` block transfers to GPU.
A regression that dropped the transfer block would have shipped
a numpy DataArray instead of a CuPy one; this test pins that.
"""

def test_read_vrt_gpu_returns_cupy(self, single_tile_vrt):
import cupy

vrt_path, arr = single_tile_vrt
da = read_vrt(vrt_path, gpu=True)
assert isinstance(da.data, cupy.ndarray), (
f"expected cupy.ndarray, got {type(da.data).__name__}"
)
np.testing.assert_array_equal(da.data.get(), arr)

def test_read_vrt_gpu_chunks_returns_dask_cupy(self, single_tile_vrt):
"""``read_vrt(gpu=True, chunks=N)`` is the documented dask+cupy
VRT entry point. The trailing ``result.chunk(...)`` block has
to wrap the cupy backing without falling back to numpy.
"""
import cupy
import dask.array as da_mod

vrt_path, arr = single_tile_vrt
result = read_vrt(vrt_path, gpu=True, chunks=2)

assert isinstance(result.data, da_mod.Array), (
f"expected dask Array, got {type(result.data).__name__}"
)
# _meta tells distributed Dask the underlying array is cupy.
# A numpy meta here would cause optimizers to silently move
# data back to host.
assert isinstance(result.data._meta, cupy.ndarray), (
f"expected cupy._meta, got "
f"{type(result.data._meta).__module__}."
f"{type(result.data._meta).__name__}"
)
# Chunks honour the spatial spec; the band axis (absent here)
# would chunk as a single block.
assert result.data.chunks == ((2, 2), (2, 2))

computed = result.compute()
assert isinstance(computed.data, cupy.ndarray)
np.testing.assert_array_equal(computed.data.get(), arr)

def test_open_geotiff_vrt_gpu_routes_through(self, single_tile_vrt):
"""``open_geotiff('.vrt', gpu=True)`` dispatches to ``read_vrt``
and surfaces the cupy data unchanged. The dispatcher branch
is one line in ``open_geotiff`` but a refactor that dropped
``gpu=gpu`` from the forwarded kwargs would silently produce
a numpy DataArray.
"""
import cupy

vrt_path, arr = single_tile_vrt
da = open_geotiff(vrt_path, gpu=True)
assert isinstance(da.data, cupy.ndarray)
np.testing.assert_array_equal(da.data.get(), arr)

def test_open_geotiff_vrt_gpu_chunks(self, single_tile_vrt):
"""``open_geotiff('.vrt', gpu=True, chunks=N)`` is the combined
dask+cupy entry point. Same dispatch test as the gpu-only
variant but also pins the chunk forwarding.
"""
import cupy
import dask.array as da_mod

vrt_path, arr = single_tile_vrt
result = open_geotiff(vrt_path, gpu=True, chunks=2)

assert isinstance(result.data, da_mod.Array)
assert isinstance(result.data._meta, cupy.ndarray)
assert result.data.chunks == ((2, 2), (2, 2))

computed = result.compute()
np.testing.assert_array_equal(computed.data.get(), arr)


# ---------------------------------------------------------------------------
# Cat 4: read_vrt parameter coverage (dtype / name)
# ---------------------------------------------------------------------------

class TestReadVrtDtypeKwarg:
"""``read_vrt(dtype=...)`` casts after decode and validates the cast."""

def test_safe_widening_cast(self, single_tile_vrt):
"""float32 -> float64 is permitted; values survive bit-for-bit."""
vrt_path, arr = single_tile_vrt
da = read_vrt(vrt_path, dtype='float64')
assert da.dtype == np.float64
np.testing.assert_array_equal(da.values, arr.astype(np.float64))

def test_float_to_int_rejected(self, single_tile_vrt):
"""Float-to-int is lossy and refused with a descriptive error.
Mirrors ``open_geotiff(dtype=...)`` behaviour so callers see the
same gate on both entry points.
"""
vrt_path, _ = single_tile_vrt
with pytest.raises(ValueError, match="Cannot cast float"):
read_vrt(vrt_path, dtype='int32')


class TestReadVrtNameKwarg:
"""``read_vrt(name='custom')`` overrides the file-stem derivation."""

def test_explicit_name_used(self, single_tile_vrt):
vrt_path, _ = single_tile_vrt
da = read_vrt(vrt_path, name='custom_name')
assert da.name == 'custom_name'

def test_default_name_from_stem(self, single_tile_vrt):
vrt_path, _ = single_tile_vrt
da = read_vrt(vrt_path)
# mosaic.vrt -> mosaic
assert da.name == os.path.splitext(os.path.basename(vrt_path))[0]


# ---------------------------------------------------------------------------
# Cat 4: open_geotiff file-like + backend kwarg rejection
# ---------------------------------------------------------------------------

class TestOpenGeotiffFileLikeKwargRejection:
"""File-like sources reject ``gpu=True`` and ``chunks=N`` up front.

The check sits in ``open_geotiff`` (not the underlying readers)
because both downstream paths re-open the source by path from
worker tasks. A buffer passed through would either raise deep
inside dask graph construction or silently behave as if the
buffer were a string path.
"""

@staticmethod
def _buf_with_tiff(tmp_path):
arr = np.zeros((4, 4), dtype=np.float32)
path = str(tmp_path / 'src.tif')
to_geotiff(arr, path)
with open(path, 'rb') as fh:
return io.BytesIO(fh.read())

def test_gpu_with_file_like_raises(self, tmp_path):
buf = self._buf_with_tiff(tmp_path)
with pytest.raises(ValueError, match="gpu=True is not supported"):
open_geotiff(buf, gpu=True)

def test_chunks_with_file_like_raises(self, tmp_path):
buf = self._buf_with_tiff(tmp_path)
with pytest.raises(ValueError, match="chunks=.*file-like"):
open_geotiff(buf, chunks=64)

def test_chunks_with_pathlib_path_still_works(self, tmp_path):
"""Sanity-check: pathlib.Path is not file-like and must keep
working through the dask path. Otherwise the file-like gate
would also lock out Path inputs.
"""
arr = np.arange(16, dtype=np.float32).reshape(4, 4)
path = tmp_path / 'sample.tif'
to_geotiff(arr, str(path))

import dask.array as da_mod
result = open_geotiff(path, chunks=2)
assert isinstance(result.data, da_mod.Array)
np.testing.assert_array_equal(np.asarray(result.data), arr)
Loading