|
| 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