Skip to content

Commit 8da6ed9

Browse files
authored
Expose coregistered reads through the xarray engine via like= (#3376) (#3377)
* Expose coregistered reads through the xarray engine via like= (#3376) Add a like= backend kwarg to the xrspatial engine. When supplied, the engine routes the read through like's .xrs.open_geotiff accessor, so coregister / auto_reproject / resampling / var become available through xr.open_dataset and open_mfdataset. Without like=, those kwargs raise a pointed ValueError instead of the standalone reader's opaque TypeError. * Address review: validate like= type, generalize guard message (#3376) - Raise TypeError when like= is not a DataArray/Dataset, instead of an opaque AttributeError on .xrs. - Reword the no-like guard so it reads correctly for var= too.
1 parent 823f7bd commit 8da6ed9

3 files changed

Lines changed: 306 additions & 9 deletions

File tree

docs/source/reference/geotiff.rst

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -270,13 +270,31 @@ ambiguous when another raster backend (e.g. rioxarray's ``rasterio``) is
270270
installed and also claims those extensions; xarray then raises and asks
271271
for an explicit ``engine=``.
272272

273-
The engine forwards to the standalone ``open_geotiff`` function, so the
274-
coregistered-read options (``coregister``, ``auto_reproject``,
275-
``resampling``) are *not* available through it; they live on the
276-
``.xrs.open_geotiff`` accessor because they reproject and resample onto a
277-
target array's grid, and the engine opens a single source from scratch
278-
with no target. Passing them through ``backend_kwargs`` raises
279-
``TypeError``. Use the accessor on the target array instead, e.g.
273+
By default the engine forwards to the standalone ``open_geotiff``
274+
function, which opens a single source from scratch with no target grid.
275+
The coregistered-read options (``coregister``, ``auto_reproject``,
276+
``resampling``) reproject and resample onto a target array's grid, so
277+
they need that target. Pass it as a ``like=`` backend kwarg (a DataArray
278+
or Dataset); the engine then routes through ``like``'s
279+
``.xrs.open_geotiff`` accessor:
280+
281+
.. code-block:: python
282+
283+
xr.open_dataset(
284+
"scene.tif", engine="xrspatial",
285+
backend_kwargs={"like": target, "coregister": True,
286+
"auto_reproject": True})
287+
288+
When ``like`` is a Dataset, the ``var=`` backend kwarg picks the variable
289+
used for backend/CRS inference. ``open_mfdataset`` with a shared ``like=``
290+
coregisters every source onto the same grid in one call. The returned
291+
variable is named by the same ``default_name`` / source-stem rule as the
292+
plain engine path, and the accessor's GPU / ``.vrt`` / ``allow_rotated``
293+
rejections apply through the engine too.
294+
295+
Passing ``coregister`` / ``auto_reproject`` / ``resampling`` / ``var``
296+
*without* a ``like=`` raises ``ValueError`` pointing at ``like=``. The
297+
accessor on the target array remains available directly, e.g.
280298
``target.xrs.open_geotiff("scene.tif", coregister=True)``.
281299

282300
Coregistered reads (experimental)

xrspatial/geotiff/_xarray_backend.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,41 @@
2929
a dask-backed dataset (xarray wraps the eager read)::
3030
3131
xr.open_dataset("dem.tif", engine="xrspatial", chunks={})
32+
33+
Coregistered reads (``coregister`` / ``auto_reproject`` / ``resampling``)
34+
reproject and resample a source onto an existing array's grid, so they
35+
need a target grid that the plain ``open_dataset`` path does not have.
36+
Pass that target as a ``like=`` backend kwarg (a DataArray or Dataset);
37+
the engine then routes to the ``.xrs.open_geotiff`` accessor on ``like``
38+
instead of the standalone reader::
39+
40+
xr.open_dataset(
41+
"scene.tif", engine="xrspatial",
42+
backend_kwargs={"like": target, "coregister": True,
43+
"auto_reproject": True},
44+
)
45+
46+
``coregister`` / ``auto_reproject`` / ``resampling`` / ``var`` without a
47+
``like=`` raise ``ValueError`` pointing at it, rather than the opaque
48+
``TypeError`` the standalone reader would emit for the unknown kwarg.
3249
"""
3350
from __future__ import annotations
3451

3552
import os
3653

54+
import xarray as xr
3755
from xarray.backends import BackendEntrypoint
3856

3957
# Name for the one data variable when ``open_geotiff`` cannot derive one
4058
# from the source (e.g. an in-memory file-like object with no path).
4159
_DEFAULT_VARIABLE_NAME = "band_data"
4260

61+
# Backend kwargs only the coregistered-read path (``.xrs.open_geotiff`` on
62+
# ``like``) understands. Supplied without ``like=`` they would reach the
63+
# standalone reader and raise an opaque ``TypeError``; the engine raises a
64+
# pointed ``ValueError`` instead.
65+
_COREGISTER_ONLY_KWARGS = ("coregister", "auto_reproject", "resampling", "var")
66+
4367
# Extensions ``guess_can_open`` claims so ``xr.open_dataset`` /
4468
# ``open_mfdataset`` can auto-select this engine without ``engine=``.
4569
_SUPPORTED_EXTENSIONS = (".tif", ".tiff", ".vrt")
@@ -67,13 +91,36 @@ class GeoTIFFBackendEntrypoint(BackendEntrypoint):
6791
# ``backend_kwargs`` instead.
6892
open_dataset_parameters = ("filename_or_obj", "drop_variables")
6993

70-
def open_dataset(self, filename_or_obj, *, drop_variables=None, **kwargs):
94+
def open_dataset(self, filename_or_obj, *, drop_variables=None,
95+
like=None, **kwargs):
7196
# Imported here rather than at module scope so importing this
7297
# backend module stays cheap; the heavy reader package only loads
7398
# when a source is actually opened.
7499
from . import open_geotiff
75100

76-
da = open_geotiff(filename_or_obj, **kwargs)
101+
if like is not None:
102+
if not isinstance(like, (xr.DataArray, xr.Dataset)):
103+
raise TypeError(
104+
"'like=' must be an xarray DataArray or Dataset whose "
105+
"grid the read coregisters onto, got "
106+
f"{type(like).__name__}."
107+
)
108+
# Importing the accessor module registers the ``.xrs``
109+
# accessor that carries the coregistered-read path; ``like``
110+
# may be a DataArray or a Dataset and the accessor dispatches
111+
# on its type (Datasets also honour the ``var=`` kwarg).
112+
from .. import accessor # noqa: F401
113+
da = like.xrs.open_geotiff(filename_or_obj, **kwargs)
114+
else:
115+
offending = [k for k in _COREGISTER_ONLY_KWARGS if k in kwargs]
116+
if offending:
117+
raise ValueError(
118+
f"{', '.join(offending)} only apply when reading onto a "
119+
"target grid, so they need a target. Pass it as a "
120+
"'like=' backend kwarg (a DataArray or Dataset), e.g. "
121+
"backend_kwargs={'like': target, 'coregister': True}."
122+
)
123+
da = open_geotiff(filename_or_obj, **kwargs)
77124
name = da.name if da.name is not None else _DEFAULT_VARIABLE_NAME
78125
ds = da.to_dataset(name=name)
79126
if drop_variables is not None:
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
"""Coregistered reads through the xarray backend engine (issue #3376).
2+
3+
The ``xrspatial`` engine forwards to the standalone ``open_geotiff`` by
4+
default, which has no target grid. Passing ``like=`` (a DataArray or
5+
Dataset) routes the read through that object's ``.xrs.open_geotiff``
6+
accessor instead, so ``coregister`` / ``auto_reproject`` / ``resampling``
7+
become available through the standard ``xr.open_dataset`` API. These
8+
tests pin that routing, the parity with the accessor, the variable
9+
naming, the ``open_mfdataset`` composition, and the guard that turns the
10+
opaque ``TypeError`` (from the standalone reader) into a pointed
11+
``ValueError`` when a coregister kwarg arrives without ``like=``.
12+
"""
13+
from __future__ import annotations
14+
15+
import numpy as np
16+
import pytest
17+
import xarray as xr
18+
19+
from xrspatial.geotiff import to_geotiff
20+
from xrspatial.geotiff._xarray_backend import GeoTIFFBackendEntrypoint
21+
22+
23+
def _file_4326(tmp_path, name, nodata=None):
24+
"""Write a 30x30 EPSG:4326 GeoTIFF; return its path."""
25+
height, width = 30, 30
26+
arr = np.arange(height * width, dtype=np.float32).reshape(height, width)
27+
y = np.linspace(45.5, 44.5, height)
28+
x = np.linspace(-120.5, -119.5, width)
29+
attrs = {"crs": 4326}
30+
if nodata is not None:
31+
attrs["nodata"] = nodata
32+
arr[14:17, 14:17] = nodata
33+
da = xr.DataArray(arr, dims=["y", "x"],
34+
coords={"y": y, "x": x}, attrs=attrs)
35+
path = str(tmp_path / name)
36+
to_geotiff(da, path, compression="none")
37+
return path
38+
39+
40+
def _template_4326(n=5):
41+
"""A coarser, offset same-CRS grid inside the file footprint."""
42+
return xr.DataArray(
43+
np.zeros((n, n), dtype=np.float32),
44+
dims=["y", "x"],
45+
coords={"y": np.linspace(45.3, 44.7, n),
46+
"x": np.linspace(-120.3, -119.7, n)},
47+
attrs={"crs": 4326},
48+
)
49+
50+
51+
def _template_3857(n=6):
52+
"""A grid in a different CRS overlapping the file footprint."""
53+
from pyproj import Transformer
54+
tr = Transformer.from_crs(4326, 3857, always_xy=True)
55+
x0, y0 = tr.transform(-120.25, 45.25)
56+
x1, y1 = tr.transform(-119.75, 44.75)
57+
return xr.DataArray(
58+
np.zeros((n, n), dtype=np.float32),
59+
dims=["y", "x"],
60+
coords={"y": np.linspace(max(y0, y1), min(y0, y1), n),
61+
"x": np.linspace(min(x0, x1), max(x0, x1), n)},
62+
attrs={"crs": 3857},
63+
)
64+
65+
66+
# ---------------------------------------------------------------------------
67+
# like= routes to the coregister path
68+
# ---------------------------------------------------------------------------
69+
70+
def test_coregister_via_engine_matches_template_grid(tmp_path):
71+
path = _file_4326(tmp_path, "cg_3376_same.tif")
72+
template = _template_4326(5)
73+
ds = xr.open_dataset(
74+
path, engine=GeoTIFFBackendEntrypoint,
75+
backend_kwargs={"like": template, "coregister": True},
76+
)
77+
assert isinstance(ds, xr.Dataset)
78+
var = ds[list(ds.data_vars)[0]]
79+
assert var.shape == template.shape
80+
assert np.allclose(var.coords["x"].values, template.coords["x"].values)
81+
assert np.allclose(var.coords["y"].values, template.coords["y"].values)
82+
83+
84+
def test_coregister_via_engine_matches_accessor(tmp_path):
85+
# The engine must produce exactly what the accessor produces; the
86+
# engine only promotes the DataArray to a one-variable Dataset.
87+
path = _file_4326(tmp_path, "cg_3376_parity.tif")
88+
template = _template_4326(5)
89+
ds = xr.open_dataset(
90+
path, engine=GeoTIFFBackendEntrypoint,
91+
backend_kwargs={"like": template, "coregister": True},
92+
)
93+
accessor_da = template.xrs.open_geotiff(path, coregister=True)
94+
engine_da = ds[list(ds.data_vars)[0]]
95+
np.testing.assert_array_equal(engine_da.values, accessor_da.values)
96+
for coord in accessor_da.coords:
97+
np.testing.assert_array_equal(
98+
engine_da[coord].values, accessor_da[coord].values)
99+
100+
101+
def test_coregister_via_engine_crs_mismatch(tmp_path):
102+
path = _file_4326(tmp_path, "cg_3376_mismatch.tif")
103+
template = _template_3857(6)
104+
ds = xr.open_dataset(
105+
path, engine=GeoTIFFBackendEntrypoint,
106+
backend_kwargs={"like": template, "coregister": True},
107+
)
108+
var = ds[list(ds.data_vars)[0]]
109+
assert var.shape == template.shape
110+
assert np.allclose(var.coords["x"].values, template.coords["x"].values)
111+
assert np.allclose(var.coords["y"].values, template.coords["y"].values)
112+
113+
114+
def test_auto_reproject_via_engine(tmp_path):
115+
# auto_reproject (no coregister) keeps the file resolution but still
116+
# needs the target's bbox/CRS, so it routes through like= too.
117+
path = _file_4326(tmp_path, "ar_3376.tif")
118+
template = _template_3857(6)
119+
ds = xr.open_dataset(
120+
path, engine=GeoTIFFBackendEntrypoint,
121+
backend_kwargs={"like": template, "auto_reproject": True},
122+
)
123+
accessor_da = template.xrs.open_geotiff(path, auto_reproject=True)
124+
engine_da = ds[list(ds.data_vars)[0]]
125+
np.testing.assert_array_equal(engine_da.values, accessor_da.values)
126+
127+
128+
def test_dataset_like_with_var(tmp_path):
129+
# A Dataset target dispatches to the Dataset accessor, which honours
130+
# the var= kwarg for backend/CRS inference.
131+
path = _file_4326(tmp_path, "cg_3376_dsvar.tif")
132+
template = _template_4326(5).to_dataset(name="elev")
133+
ds = xr.open_dataset(
134+
path, engine=GeoTIFFBackendEntrypoint,
135+
backend_kwargs={"like": template, "coregister": True, "var": "elev"},
136+
)
137+
accessor_da = template.xrs.open_geotiff(path, coregister=True, var="elev")
138+
engine_da = ds[list(ds.data_vars)[0]]
139+
np.testing.assert_array_equal(engine_da.values, accessor_da.values)
140+
141+
142+
# ---------------------------------------------------------------------------
143+
# Variable naming follows the same default_name / stem rule
144+
# ---------------------------------------------------------------------------
145+
146+
def test_coregister_variable_name_follows_stem(tmp_path):
147+
path = _file_4326(tmp_path, "cg_3376_stem.tif")
148+
template = _template_4326(5)
149+
ds = xr.open_dataset(
150+
path, engine=GeoTIFFBackendEntrypoint,
151+
backend_kwargs={"like": template, "coregister": True},
152+
)
153+
assert "cg_3376_stem" in ds.data_vars
154+
155+
156+
def test_coregister_default_name_renames_variable(tmp_path):
157+
path = _file_4326(tmp_path, "cg_3376_rename.tif")
158+
template = _template_4326(5)
159+
ds = xr.open_dataset(
160+
path, engine=GeoTIFFBackendEntrypoint,
161+
backend_kwargs={"like": template, "coregister": True,
162+
"default_name": "elevation"},
163+
)
164+
assert "elevation" in ds.data_vars
165+
166+
167+
# ---------------------------------------------------------------------------
168+
# open_mfdataset composes onto one shared grid
169+
# ---------------------------------------------------------------------------
170+
171+
def test_open_mfdataset_coregisters_onto_shared_grid(tmp_path):
172+
pytest.importorskip("dask")
173+
template = _template_4326(5)
174+
paths = [
175+
_file_4326(tmp_path, f"mf_3376_{i}.tif")
176+
for i in range(2)
177+
]
178+
ds = xr.open_mfdataset(
179+
paths, engine=GeoTIFFBackendEntrypoint,
180+
combine="nested", concat_dim="tile",
181+
backend_kwargs={"like": template, "coregister": True,
182+
"default_name": "band_data"},
183+
)
184+
assert list(ds.data_vars) == ["band_data"]
185+
assert ds.sizes["tile"] == 2
186+
# Every tile sits on the template grid.
187+
assert np.allclose(ds.coords["x"].values, template.coords["x"].values)
188+
assert np.allclose(ds.coords["y"].values, template.coords["y"].values)
189+
190+
191+
# ---------------------------------------------------------------------------
192+
# Guard: coregister kwargs without like= raise a pointed ValueError
193+
# ---------------------------------------------------------------------------
194+
195+
@pytest.mark.parametrize("kwargs", [
196+
{"coregister": True},
197+
{"auto_reproject": True},
198+
{"resampling": "bilinear"},
199+
{"var": "elev"},
200+
])
201+
def test_coregister_kwarg_without_like_raises(tmp_path, kwargs):
202+
path = _file_4326(tmp_path, "cg_3376_nolike.tif")
203+
with pytest.raises(ValueError, match="like="):
204+
xr.open_dataset(path, engine=GeoTIFFBackendEntrypoint,
205+
backend_kwargs=kwargs)
206+
207+
208+
def test_non_array_like_raises_typeerror(tmp_path):
209+
# A path or other non-array passed as like= would otherwise blow up on
210+
# `.xrs` with an opaque AttributeError; the guard names the right type.
211+
path = _file_4326(tmp_path, "cg_3376_badlike.tif")
212+
with pytest.raises(TypeError, match="DataArray or Dataset"):
213+
xr.open_dataset(path, engine=GeoTIFFBackendEntrypoint,
214+
backend_kwargs={"like": "not_an_array",
215+
"coregister": True})
216+
217+
218+
# ---------------------------------------------------------------------------
219+
# GPU / .vrt rejections are inherited from the accessor path
220+
# ---------------------------------------------------------------------------
221+
222+
def test_coregister_gpu_rejected_through_engine(tmp_path):
223+
# The unpack-and-reproject pipeline is CPU-only; the accessor raises on
224+
# gpu=True, and the engine inherits that rejection for free.
225+
path = _file_4326(tmp_path, "cg_3376_gpu.tif")
226+
template = _template_4326(5)
227+
with pytest.raises(ValueError, match="CPU only"):
228+
xr.open_dataset(
229+
path, engine=GeoTIFFBackendEntrypoint,
230+
backend_kwargs={"like": template, "coregister": True,
231+
"gpu": True},
232+
)

0 commit comments

Comments
 (0)