Skip to content

Commit ee4ea34

Browse files
authored
Release gate: eager-vs-dask raster equivalence (#2357) (#2362)
1 parent e73bc12 commit ee4ea34

2 files changed

Lines changed: 327 additions & 0 deletions

File tree

docs/source/reference/release_gate_geotiff.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,16 @@ Local GeoTIFF read and write
7171
coords, and ``attrs`` as the eager numpy read.
7272
- ``xrspatial/geotiff/tests/test_backend_parity_matrix.py``,
7373
``xrspatial/geotiff/tests/test_backend_full_parity_2211.py``
74+
* - ``reader.eager_dask_parity``
75+
- stable
76+
- ``open_geotiff(path)`` and ``read_geotiff_dask(path)`` return the
77+
same pixels, ``dims``, ``coords``, and the seven release-attr
78+
keys (``transform``, ``crs``, ``crs_wkt``, ``nodata``,
79+
``masked_nodata``, ``georef_status``, ``raster_type``) across
80+
four scenarios: integer-nodata, float-NaN-nodata, MinIsWhite,
81+
and the ``mask_nodata=False`` raw-sentinel branch of the
82+
nodata lifecycle.
83+
- ``xrspatial/geotiff/tests/test_release_gate_eager_dask_parity_2341.py``
7484
* - ``writer.local_file``
7585
- stable
7686
- ``to_geotiff`` writes a file that ``open_geotiff`` reads back
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
"""Release gate: eager-vs-dask raster equivalence (PR 1 of 5 of epic #2341).
2+
3+
Epic #2341 calls out the highest release risk for the GeoTIFF surface:
4+
pixels matching while ``attrs``, ``coords``, or ``dims`` silently disagree
5+
between the eager (``open_geotiff``) and lazy (``read_geotiff_dask``)
6+
entry points. Today both paths are documented as ``stable``, but no
7+
single regression test asserts full raster equivalence -- pixels + dims +
8+
coords + the seven release-attr keys -- across the two paths on the same
9+
files.
10+
11+
This module reads each fixture in a representative corpus list once
12+
through ``open_geotiff`` (eager) and once through ``read_geotiff_dask``
13+
(materialised via ``.compute()``), then asserts:
14+
15+
* ``.values`` bit-exact (NaN-aware via ``np.array_equal(..., equal_nan=True)``)
16+
* ``.dims`` equal
17+
* ``.coords`` element-wise equal (dtype + bytes match per axis)
18+
* seven release-attr keys equal:
19+
``transform``, ``crs``, ``crs_wkt``, ``nodata``, ``masked_nodata``,
20+
``georef_status``, ``raster_type``
21+
22+
The assertions are inlined as small helpers in this module. The four
23+
sibling PRs of epic #2341 (windowed-shifted-transform, overview / sidecar
24+
metadata, stable-codec round-trip, ambiguous-metadata negatives) ship
25+
independently; consolidating the helpers into a shared module is a
26+
follow-up once all five have landed and the common shape has settled.
27+
28+
The corpus covers the four scenarios called out in the issue:
29+
30+
* integer dtype with explicit integer nodata sentinel
31+
* float dtype with NaN nodata
32+
* MinIsWhite photometric (no explicit nodata tag)
33+
* masked-nodata lifecycle: the same integer-sentinel fixture read with
34+
``mask_nodata=False`` so the raw uint sentinel branch is pinned in
35+
parity against the default ``mask_nodata=True`` branch (which the
36+
integer-nodata row above already covers)
37+
38+
Out of scope (sibling PRs of epic #2341):
39+
40+
* Windowed-read shifted-transform parity (PR 2 of 5).
41+
* Overview / sidecar metadata survival (PR 3 of 5).
42+
* Stable-codec round-trip (PR 4 of 5).
43+
* Negative tests for ambiguous metadata (PR 5 of 5).
44+
"""
45+
from __future__ import annotations
46+
47+
import pathlib
48+
from typing import Any
49+
50+
import numpy as np
51+
import pytest
52+
import xarray as xr
53+
54+
pytest.importorskip("dask")
55+
56+
from xrspatial.geotiff import open_geotiff, read_geotiff_dask # noqa: E402
57+
58+
# Corpus fixtures live under ``golden_corpus/fixtures``; the same
59+
# directory the wider parity matrix and the per-backend golden tests
60+
# already use.
61+
_FIXTURES_DIR = (
62+
pathlib.Path(__file__).resolve().parent / "golden_corpus" / "fixtures"
63+
)
64+
65+
# Chunk size for the dask reads. The corpus fixtures used here are
66+
# 64x64 or smaller, so a chunk of 32 produces either a 2x2 chunk grid
67+
# or a single chunk depending on the fixture. Either way the dask
68+
# plumbing fires.
69+
_CHUNK_SIZE = 32
70+
71+
# The seven release-attr keys the parity contract pins. Drift on any
72+
# of these between the eager and dask paths is a release blocker; see
73+
# the module docstring for the rationale.
74+
_RELEASE_ATTR_KEYS: tuple[str, ...] = (
75+
"transform",
76+
"crs",
77+
"crs_wkt",
78+
"nodata",
79+
"masked_nodata",
80+
"georef_status",
81+
"raster_type",
82+
)
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Corpus selection
87+
# ---------------------------------------------------------------------------
88+
89+
# One ``pytest.param`` per fixture scenario. ``open_kwargs`` carries
90+
# any extra kwargs (e.g. ``mask_nodata=False``) applied to both the
91+
# eager and dask reads so the masked-nodata-lifecycle row exercises
92+
# the same masking semantics on both paths.
93+
_CORPUS = [
94+
pytest.param(
95+
"nodata_int_sentinel_uint16",
96+
{},
97+
id="int-dtype-nodata",
98+
),
99+
pytest.param(
100+
"nodata_nan_float32",
101+
{},
102+
id="float-dtype-nan-nodata",
103+
),
104+
pytest.param(
105+
"nodata_miniswhite_uint8",
106+
{},
107+
id="miniswhite",
108+
),
109+
# ``mask_nodata=False`` is the contrast cell to the first row's
110+
# default ``mask_nodata=True``: the raw uint16 sentinel is preserved
111+
# and ``masked_nodata`` flips to ``False``. Together the two cells
112+
# pin both sides of the nodata lifecycle on the same fixture, which
113+
# is the silent-disagreement case the issue calls out.
114+
pytest.param(
115+
"nodata_int_sentinel_uint16",
116+
{"mask_nodata": False},
117+
id="masked-nodata-lifecycle",
118+
),
119+
]
120+
121+
122+
# ---------------------------------------------------------------------------
123+
# Inlined helpers (per issue: no new shared helper module in this PR)
124+
# ---------------------------------------------------------------------------
125+
126+
def _materialise(da: xr.DataArray) -> np.ndarray:
127+
"""Return a host-side numpy view of ``da.values``.
128+
129+
For an eager numpy-backed DataArray this is a straight ``np.asarray``;
130+
for a dask-backed DataArray ``.values`` triggers ``.compute()`` so
131+
the result is the materialised numpy array. The eager / lazy split
132+
is hidden here so the assertion call sites stay symmetric. Kept as
133+
a named helper (rather than inlined) so the sibling PRs of epic
134+
#2341 can copy the same shape when they land their own gates.
135+
"""
136+
return np.asarray(da.values)
137+
138+
139+
def _assert_values_equal(eager: xr.DataArray, lazy: xr.DataArray) -> None:
140+
"""Bit-exact NaN-aware comparison of pixel values.
141+
142+
Integer dtypes go through ``np.array_equal`` directly; float dtypes
143+
use ``equal_nan=True`` so a NaN-marked nodata cell compares equal to
144+
itself across paths. A dtype mismatch fails first with an explicit
145+
message because the float / int divergence is the single most
146+
informative diff when ``mask_nodata=True`` flips a row.
147+
"""
148+
assert eager.dtype == lazy.dtype, (
149+
f"pixel dtype differs: eager={eager.dtype} lazy={lazy.dtype}"
150+
)
151+
eager_px = _materialise(eager)
152+
lazy_px = _materialise(lazy)
153+
assert eager_px.shape == lazy_px.shape, (
154+
f"pixel shape differs: eager={eager_px.shape} lazy={lazy_px.shape}"
155+
)
156+
equal_nan = eager_px.dtype.kind == "f"
157+
if not np.array_equal(eager_px, lazy_px, equal_nan=equal_nan):
158+
raise AssertionError(
159+
"pixel values differ between eager and dask reads "
160+
f"(dtype={eager_px.dtype}, equal_nan={equal_nan})"
161+
)
162+
163+
164+
def _assert_dims_equal(eager: xr.DataArray, lazy: xr.DataArray) -> None:
165+
"""Dims tuple matches exactly between the two paths."""
166+
assert eager.dims == lazy.dims, (
167+
f"dims differ: eager={eager.dims!r} lazy={lazy.dims!r}"
168+
)
169+
170+
171+
def _assert_coords_equal(eager: xr.DataArray, lazy: xr.DataArray) -> None:
172+
"""Per-axis coord dtype + byte-level equality.
173+
174+
Coords drive transform reconstruction downstream, so a sub-ULP
175+
divergence still means a different transform. The bytewise compare
176+
catches a dtype-preserving rounding regression that ``allclose``
177+
would let through.
178+
"""
179+
eager_coord_names = set(eager.coords)
180+
lazy_coord_names = set(lazy.coords)
181+
assert eager_coord_names == lazy_coord_names, (
182+
f"coord name set differs: "
183+
f"only-in-eager={sorted(eager_coord_names - lazy_coord_names)} "
184+
f"only-in-lazy={sorted(lazy_coord_names - eager_coord_names)}"
185+
)
186+
for axis in eager_coord_names:
187+
eager_c = np.asarray(eager.coords[axis].values)
188+
lazy_c = np.asarray(lazy.coords[axis].values)
189+
assert eager_c.dtype == lazy_c.dtype, (
190+
f"coord {axis!r} dtype differs: "
191+
f"eager={eager_c.dtype} lazy={lazy_c.dtype}"
192+
)
193+
assert eager_c.shape == lazy_c.shape, (
194+
f"coord {axis!r} shape differs: "
195+
f"eager={eager_c.shape} lazy={lazy_c.shape}"
196+
)
197+
assert eager_c.tobytes() == lazy_c.tobytes(), (
198+
f"coord {axis!r} bytes differ between eager and dask reads"
199+
)
200+
201+
202+
def _is_nan_sentinel(value: Any) -> bool:
203+
"""True when ``value`` is a NaN, regardless of scalar type.
204+
205+
``float('nan') != float('nan')`` by IEEE-754, so the nodata
206+
comparison needs an explicit NaN-aware branch. Accepts python
207+
floats, numpy scalars, and anything castable to ``float``; returns
208+
``False`` for non-numeric values (including ``None``) so the
209+
caller falls through to the strict ``==`` branch.
210+
"""
211+
if value is None:
212+
return False
213+
try:
214+
return bool(np.isnan(float(value)))
215+
except (TypeError, ValueError):
216+
return False
217+
218+
219+
def _attr_equal(a: Any, b: Any) -> bool:
220+
"""Compare two attr values, treating NaN as equal to NaN.
221+
222+
Notable divergence from ``test_backend_full_parity_2211.py``: the
223+
transform 6-tuple of floats is compared bit-exact here (via the
224+
tuple-recursion branch below), where the sibling gate allows a
225+
1e-9 ULP tolerance. Bit-exact is the contract the issue calls for
226+
on the same-file eager-vs-dask axis; the wider gate has to absorb
227+
a hypothetical future cross-backend float-rounding op (e.g. a GPU
228+
decode path) that does not exist on either of the two paths here.
229+
"""
230+
if _is_nan_sentinel(a) and _is_nan_sentinel(b):
231+
return True
232+
if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
233+
return (
234+
isinstance(a, np.ndarray)
235+
and isinstance(b, np.ndarray)
236+
and np.array_equal(a, b)
237+
)
238+
if isinstance(a, (tuple, list)) and isinstance(b, (tuple, list)):
239+
if len(a) != len(b):
240+
return False
241+
return all(_attr_equal(x, y) for x, y in zip(a, b))
242+
return a == b
243+
244+
245+
def _assert_release_attrs_equal(
246+
eager: xr.DataArray, lazy: xr.DataArray,
247+
) -> None:
248+
"""Each of the seven release-attr keys agrees on presence + value.
249+
250+
An attr absent on the eager read must also be absent on the dask
251+
read, and vice versa. This catches the silent-disagreement case the
252+
issue calls out: pixels and dims line up while one path stamps an
253+
attr the other omits.
254+
"""
255+
for key in _RELEASE_ATTR_KEYS:
256+
in_eager = key in eager.attrs
257+
in_lazy = key in lazy.attrs
258+
assert in_eager == in_lazy, (
259+
f"release attr {key!r} presence differs: "
260+
f"eager={in_eager} lazy={in_lazy}"
261+
)
262+
if not in_eager:
263+
continue
264+
eager_v = eager.attrs[key]
265+
lazy_v = lazy.attrs[key]
266+
assert _attr_equal(eager_v, lazy_v), (
267+
f"release attr {key!r} value differs: "
268+
f"eager={eager_v!r} lazy={lazy_v!r}"
269+
)
270+
271+
272+
# ---------------------------------------------------------------------------
273+
# The parity gate
274+
# ---------------------------------------------------------------------------
275+
276+
@pytest.mark.release_gate
277+
@pytest.mark.parametrize("fixture_id, open_kwargs", _CORPUS)
278+
def test_release_gate_eager_dask_full_parity(
279+
fixture_id: str, open_kwargs: dict,
280+
) -> None:
281+
"""Eager and dask reads of the same file agree on the full contract.
282+
283+
Reads ``fixture_id`` once via ``open_geotiff`` and once via
284+
``read_geotiff_dask``, then asserts pixel values, dims, coords, and
285+
the seven release-attr keys all match. The dask result is
286+
materialised via ``.values`` so the comparison is between concrete
287+
arrays, not between graph-vs-array.
288+
"""
289+
path = _FIXTURES_DIR / f"{fixture_id}.tif"
290+
if not path.exists():
291+
pytest.skip(
292+
f"fixture {fixture_id!r} has no .tif on disk; run "
293+
f"`python -m xrspatial.geotiff.tests.golden_corpus.generate`"
294+
)
295+
296+
eager = open_geotiff(str(path), **open_kwargs)
297+
lazy = read_geotiff_dask(str(path), chunks=_CHUNK_SIZE, **open_kwargs)
298+
299+
_assert_values_equal(eager, lazy)
300+
_assert_dims_equal(eager, lazy)
301+
_assert_coords_equal(eager, lazy)
302+
_assert_release_attrs_equal(eager, lazy)
303+
304+
305+
def test_release_gate_corpus_is_non_empty() -> None:
306+
"""The corpus list must not silently shrink to zero rows.
307+
308+
A parametrize argument list that empties out (e.g. a bad refactor
309+
that filters every entry) would cause pytest to collect zero cells
310+
and the matrix would pass vacuously. Pin the row count so a stale
311+
refactor surfaces here instead.
312+
"""
313+
assert len(_CORPUS) == 4, (
314+
f"corpus row count drifted: expected 4 scenarios "
315+
f"(int-nodata, float-nan-nodata, miniswhite, masked-nodata-lifecycle), "
316+
f"got {len(_CORPUS)}"
317+
)

0 commit comments

Comments
 (0)