From 50109580e258b1a03fbc77cb741e21db56d4bf90 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 16 May 2026 06:00:58 -0700 Subject: [PATCH 1/2] geotiff: golden corpus phase 2.6 - nodata sentinels (#1930) Adds three fixtures, one per nodata convention the manifest schema recognises: - nodata_int_sentinel_uint16: integer nodata=0, three sentinel pixels. - nodata_nan_float32: nodata=NaN, three NaN pixels. - nodata_miniswhite_uint8: photometric=miniswhite, no nodata tag, three dtype-max pixels. Each fixture is 16x16 and under 4 KB. The generator gets a small _stamp_nodata_pixels helper that plants the sentinel value at three fixed positions after pixel generation, so the oracle's nodata handling gets exercised rather than just the tag round-trip. Smoke tests verify each fixture opens cleanly, the nodata state is visible on the rasterio source (int / NaN / IMAGE_STRUCTURE MINISWHITE flag), and compare_to_oracle accepts a hand-built DataArray for each convention. No backend wiring; that comes in Phase 3 per the plan on #1930. --- .../fixtures/nodata_int_sentinel_uint16.tif | Bin 0 -> 890 bytes .../fixtures/nodata_miniswhite_uint8.tif | Bin 0 -> 740 bytes .../fixtures/nodata_nan_float32.tif | Bin 0 -> 1402 bytes .../geotiff/tests/golden_corpus/generate.py | 46 ++++- .../geotiff/tests/golden_corpus/manifest.yaml | 59 ++++++ .../golden_corpus/test_nodata_sentinels.py | 186 ++++++++++++++++++ 6 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 xrspatial/geotiff/tests/golden_corpus/fixtures/nodata_int_sentinel_uint16.tif create mode 100644 xrspatial/geotiff/tests/golden_corpus/fixtures/nodata_miniswhite_uint8.tif create mode 100644 xrspatial/geotiff/tests/golden_corpus/fixtures/nodata_nan_float32.tif create mode 100644 xrspatial/geotiff/tests/golden_corpus/test_nodata_sentinels.py diff --git a/xrspatial/geotiff/tests/golden_corpus/fixtures/nodata_int_sentinel_uint16.tif b/xrspatial/geotiff/tests/golden_corpus/fixtures/nodata_int_sentinel_uint16.tif new file mode 100644 index 0000000000000000000000000000000000000000..3a731f1ab2b196fa38b7bc00101578a59242ace0 GIT binary patch literal 890 zcmebD)MDUZU|5hX;%9RRCnaC3mEWCZ(+oq>S`7%o5?fZhPo zKnsDv#>ntYfRSTkJ5ZK^4b1OT=H%GW4wB~v(+mt9xh2zNmlT1_b66G<;TY~7tYBeM z1LU&q{Vd1ZS!6gVNA|Wg>+AS^yc>4qUhcY^JHf_Ie$V&PCm#}5u4|K8@mP+Fch0{e z!Cf+qmk&-}x$;Bw--nJ5c*<`7jN`k@Q$6vCtn3WusexMB($`*3(RzJe_(Ub+nPcC? zroU!bQ0Zvc#J*Zz_#)38hq(Wyn{{iy=6TFGoa}Y#)@fB5K{+_Yr*x<73pNsq4ZxzEa&Gxg04hY-W_ip~0Q zu8&WfZ{4fJVy7~J`yi|5>h!b^rn94d<$nPN@br!ksUmNtzH{*XJuyRPvzYo919qFb zt%61c_cVlmY*jp=wNmE7eHAM?zSuhP6VFbj7bNb}3%GmflE|G1VU{V!C$0-^YX8FR zF03*myVUQjW5~r%QU;Rw7yoNd5-MnXccJnA>kiMiU*G;ayKS%Z&fr&qM{^`D{CZwz zzP9i9?aW!0*%LwpG{XNDC@Zv=xh|2*o@4uN?N0fv=a{ZA$5gtVdimmi$A_d;(`LEO zLzf@$+ZPCH%Ux5;b2|H8{qhp=uco^>X7rg0`!AoHyI=A?qjk$N#wQ1yjx0YHDKTr( XN9Gy3-MP-qGR53%@Aa!g|Hjv36$jAcL`vfQUi-%)`J8RO>eO{Lpu)x6Oerd z$lln_1a|!$AiH4+6GIkI{1y;{fQ`G0qmOTDNn%Q3NunK>f`W}_NouY_USe*lt&+39 zkAIM-Ux;f^fRaLSVs1eWP|!e0p(r23NY2m6FUrg-Ni8Z+vJ1^iNlnYlOHHxS2N__; zWuuR3GQ*#hA20bP1=z!Ai2a1fxC3An4Q>uFm5kspWM^Pt0mcB(kH7!}(m<~PgPxJ$ znE)fl#&)1A0~?s%r_9N*p&ca84W=0wI&w><$u21Zndh)9B*HP=Jy^lQq=w;tRpH@( zckVOh`Z{aYEZ9GVQz4h#^s{EJVeH(Q(vhx4ha_~pl5hQS3g%w8v(x8>!sg2p-HhFI zKOD)l3g%63(#Y6$e3!{%UM1Fhym}(1Uv!smy!u!;#6N6{4@X-4t@FW)HKclG_I3MR zU8L~0$WNH7H%@+jP=55kX{;R6cMIN?7i5*9h&gd+k)AY(M H^!tAR1zf;f literal 0 HcmV?d00001 diff --git a/xrspatial/geotiff/tests/golden_corpus/fixtures/nodata_nan_float32.tif b/xrspatial/geotiff/tests/golden_corpus/fixtures/nodata_nan_float32.tif new file mode 100644 index 0000000000000000000000000000000000000000..bc38929944fa542f90f3929710ae88eedc3c1d0b GIT binary patch literal 1402 zcmaiyc~q2T6vn?{Scat#aAR#iz+@2wM3i~&1Gup0AP5}@V+2_e6G9XOaTzp05)sQ3 zR6LXeNkI;{Hj+$D9S^2*!~>~_9zhAEOeC$qrElioIsE4z_kDivz0Y^w?F$cg<3t?C zS#X>{$O(9wz=vQQ>y5FP&kM&`>oXk376N_Uw;TDwq;dJlW325tWz56rbDZ5cPs|t# zdC%M1%{Z;zW}G3fPxv@qBILxpI`RFLC1#u+&ntOuD$&#JK8NQbg`T!GmzA8w zj~w^BeyBsbG77I%mwe+OQ|m19MM1Bv0^P}sIBjzQzcPOeUik(2YZGw&69l5B628hA z>gMIsj77>4ZQWF-9Je!b{eF8zj<#P=U2J@CTxg6qz;6>jRd1F396^69j>q07_Sj}( zj~ev@N{ajtCf<30;@EEL5>3X!dHH1Cd4{dHW?d(P3xsm0{ll8<7A4f;PHre(_~leA(v zCZ>jFSY|iH3U<5f zFmK0g3UPWyPP^kta@Y~Vp#|9c*c)3;__Hh7)8XUdf}0BqswLa3 zLDqXC3KbDjI3&5_Rmc!M+9yMA@(g_6x(1f>>nZ=H5cb*2ar*CcEXWK(cEW8I@Sinp zs4XWH#IwTrqtqC2nr&B!Xq&-oxT{y;>9urZXb3VB4`dogvq~Qgo!3NQBsYXq3sT@H zks$PfD_$Hbq%|W_DDMoA@Qy7Knyj!e=?M)*3(=lZh?Aa=N#h(w>jDKhmVKUS|Bse` zmg2gi07t54Alua&_wH7cX09`$4=%yM#sG+nQ_v7O2T%RZQr?Yy?BvT_?0xks4IT2L zn#w4674M+Mx1uTS{UmS^6;w0JhbA<;z~YN#FwuWS9fp^wCpQVDS6@=jc^PbM-o>6c z1JreW!;0sXvC9{d@!gN@J+Cm=6**>of0Xxj4}GB_$s2BF`!l|3Vi4XZVaSw(h(}vc`9k9{-Jye@cqKui(>1FL{YRU0JWsED0xD?REz)09- zdE(C>Hc*Gf0LA3EQ+Q}94n$5zT%rTI(>vJx4sRGM8YQC+1n$g zK&_`AW|yso;H)t!eyO3~9ZyNxCQU_6x8>ZVwOeWS#TI&6Ws8A5;TXNzO(_~V>s+Iz Lxn0JjecS&5Q*+LK literal 0 HcmV?d00001 diff --git a/xrspatial/geotiff/tests/golden_corpus/generate.py b/xrspatial/geotiff/tests/golden_corpus/generate.py index 823cdf44..6d1ec96c 100644 --- a/xrspatial/geotiff/tests/golden_corpus/generate.py +++ b/xrspatial/geotiff/tests/golden_corpus/generate.py @@ -310,7 +310,51 @@ def _make_pixels(entry: dict[str, Any]) -> np.ndarray: else: arr = flat.astype(dtype) - return arr.reshape(bands, h, w) + arr = arr.reshape(bands, h, w) + _stamp_nodata_pixels(arr, entry) + return arr + + +def _stamp_nodata_pixels(arr: np.ndarray, entry: dict[str, Any]) -> None: + """Plant a few sentinel pixels at deterministic positions. + + The corpus nodata fixtures (#1930, Phase 2 PR 6) need the oracle to + exercise nodata-masking semantics, not just the tag round-trip. + Noise / ramp / uniform patterns are vanishingly unlikely to hit the + sentinel value on their own for wide integer dtypes (a 16x16 uint16 + raster sees each value with probability 1/65536 per cell), so we + stamp a small set of cells in-place after pattern generation. + + The cells (top-left, centre, bottom-right) are fixed so re-runs stay + byte-stable. We stamp only when ``nodata`` resolves to an actual + sentinel value: + + * a numeric sentinel for integer / float rasters + * NaN for float rasters with ``nodata: "nan"`` + * the dtype max for ``nodata: "miniswhite"`` (white-as-min) + """ + nd = entry.get("nodata") + if nd is None: + return + dtype = arr.dtype + if isinstance(nd, (int, float)): + sentinel: Any = nd + elif nd == "nan": + if dtype.kind != "f": + return + sentinel = np.nan + elif nd == "miniswhite": + if dtype.kind not in ("i", "u"): + return + sentinel = np.iinfo(dtype).max + else: # pragma: no cover - validate() rejects other shapes + return + h = arr.shape[-2] + w = arr.shape[-1] + positions = ((0, 0), (h // 2, w // 2), (h - 1, w - 1)) + for b in range(arr.shape[0]): + for r, c in positions: + arr[b, r, c] = sentinel def _resolve_crs(crs_spec: dict[str, Any] | None): diff --git a/xrspatial/geotiff/tests/golden_corpus/manifest.yaml b/xrspatial/geotiff/tests/golden_corpus/manifest.yaml index ad345ff0..3a6c52a6 100644 --- a/xrspatial/geotiff/tests/golden_corpus/manifest.yaml +++ b/xrspatial/geotiff/tests/golden_corpus/manifest.yaml @@ -141,3 +141,62 @@ fixtures: atol: 0.0 rtol: 0.0 lossy: false + + # ----- Phase 2 PR 6: nodata sentinels (issue #1930) ----- + # Three fixtures, one per nodata convention. Each one places at least + # one pixel on the sentinel so the oracle's nodata-masking semantics + # are exercised, not just the tag round-trip. Per-fixture pixel_seed + # keeps the noise pattern stable across regenerations. + - id: nodata_int_sentinel_uint16 + description: >- + uint16 raster with an explicit integer nodata sentinel (0). A + handful of pixels are forced to 0 so the masked-data path is + reachable through the read backend. + width: 16 + height: 16 + dtype: uint16 + nodata: 0 + pixel_pattern: noise + pixel_seed: 1930006 + tags: [fast, nodata, int_sentinel] + tolerance: + atol: 0.0 + rtol: 0.0 + lossy: false + + - id: nodata_nan_float32 + description: >- + float32 raster with NaN nodata. A few cells are written as NaN so + the oracle's NaN-aware equality (equal_nan=True) is the only path + that can pass. + width: 16 + height: 16 + dtype: float32 + nodata: "nan" + pixel_pattern: noise + pixel_seed: 1930007 + tags: [fast, nodata, nan] + tolerance: + atol: 0.0 + rtol: 0.0 + lossy: false + + - id: nodata_miniswhite_uint8 + description: >- + uint8 raster with photometric=miniswhite and no explicit nodata + tag. Per the TIFF spec, white-as-min means the dtype max value + (255 for uint8) acts as the "background" sentinel. The fixture + seeds a few pixels at 255 so backends that honour the photometric + tag have something to invert. + width: 16 + height: 16 + dtype: uint8 + photometric: miniswhite + nodata: miniswhite + pixel_pattern: noise + pixel_seed: 1930008 + tags: [fast, nodata, miniswhite, photometric] + tolerance: + atol: 0.0 + rtol: 0.0 + lossy: false diff --git a/xrspatial/geotiff/tests/golden_corpus/test_nodata_sentinels.py b/xrspatial/geotiff/tests/golden_corpus/test_nodata_sentinels.py new file mode 100644 index 00000000..7ca20d82 --- /dev/null +++ b/xrspatial/geotiff/tests/golden_corpus/test_nodata_sentinels.py @@ -0,0 +1,186 @@ +"""Smoke tests for the nodata-sentinel golden-corpus fixtures (#1930, Phase 2.6). + +Three fixtures exercise the three nodata conventions the manifest schema +recognises: + +* ``nodata_int_sentinel_uint16`` -- explicit integer sentinel +* ``nodata_nan_float32`` -- ``nodata=NaN`` (string-encoded in YAML) +* ``nodata_miniswhite_uint8`` -- photometric=miniswhite, no tag + +For each fixture we assert: + +1. The file on disk is a valid TIFF that rasterio can open; +2. The nodata convention is observable on the rasterio source (an int / + NaN tag, or the IMAGE_STRUCTURE MINISWHITE flag); +3. ``compare_to_oracle`` accepts a hand-built DataArray that mirrors what + an xrspatial backend would emit. This proves the oracle's NaN-aware + nodata comparison handles each convention end-to-end. + +These tests do not touch any read backend -- backend wiring is deferred +to Phase 3 per the plan on #1930. The xrspatial-shaped DataArray here is +synthesised directly from the rasterio read so the oracle has something +to compare against. + +TODO(#1988): When the codebase grows a "declared nodata vs masked-data +state" split, switch the candidate construction here to drive both sides +explicitly. Today the candidate's ``attrs['nodata']`` mirrors whatever +the rasterio source reports, which is the same shape the existing +xrspatial reader emits. +""" +from __future__ import annotations + +import math +from pathlib import Path + +import numpy as np +import pytest +import xarray as xr + +rasterio = pytest.importorskip('rasterio') + +from xrspatial.geotiff.tests.golden_corpus._oracle import ( # noqa: E402 + compare_to_oracle, +) + + +FIXTURE_DIR = Path(__file__).resolve().parent / 'fixtures' + +FIXTURE_INT = FIXTURE_DIR / 'nodata_int_sentinel_uint16.tif' +FIXTURE_NAN = FIXTURE_DIR / 'nodata_nan_float32.tif' +FIXTURE_MINISWHITE = FIXTURE_DIR / 'nodata_miniswhite_uint8.tif' + +ALL_FIXTURES = (FIXTURE_INT, FIXTURE_NAN, FIXTURE_MINISWHITE) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _candidate_from_source(src) -> xr.DataArray: + """Build the xrspatial-shaped DataArray a backend would emit. + + Mirrors ``coords_from_pixel_geometry`` (pixel-centre coords) and the + ``attrs['transform']`` 6-tuple shape used elsewhere in xrspatial. + """ + arr = src.read(1) + transform = src.transform + height, width = arr.shape + pw = float(transform.a) + ph = float(transform.e) + ox = float(transform.c) + oy = float(transform.f) + x = ox + (np.arange(width) + 0.5) * pw + y = oy + (np.arange(height) + 0.5) * ph + attrs: dict = {'transform': (pw, 0.0, ox, 0.0, ph, oy)} + epsg = src.crs.to_epsg() if src.crs is not None else None + if epsg is not None: + attrs['crs'] = epsg + elif src.crs is not None: + attrs['crs_wkt'] = src.crs.to_wkt() + if src.nodata is not None: + attrs['nodata'] = src.nodata + return xr.DataArray(arr, dims=('y', 'x'), coords={'y': y, 'x': x}, attrs=attrs) + + +# --------------------------------------------------------------------------- +# Per-fixture parametrised TIFF validity check +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize('path', ALL_FIXTURES, ids=lambda p: p.name) +def test_fixture_is_a_valid_tiff(path: Path) -> None: + """Each fixture exists, opens cleanly, and is small enough for git.""" + assert path.exists(), f'corpus fixture missing on disk: {path}' + # The plan budgets each fixture at well under 4 KB; if a future + # change blows the budget the regression should fail loudly. + assert path.stat().st_size < 4096, ( + f'{path.name} exceeded 4 KB budget: {path.stat().st_size} bytes') + with rasterio.open(path) as src: + assert src.count == 1 + assert src.width == 16 + assert src.height == 16 + _ = src.read(1) # raises if the file is unreadable + + +# --------------------------------------------------------------------------- +# Per-convention assertions about the rasterio-observable nodata state +# --------------------------------------------------------------------------- + +def test_int_sentinel_round_trips_through_rasterio() -> None: + """rasterio reads back the integer sentinel and the planted pixels.""" + with rasterio.open(FIXTURE_INT) as src: + assert src.dtypes[0] == 'uint16' + # Stored as float on the rasterio side but represents the int 0. + assert src.nodata == 0 + arr = src.read(1) + # The generator stamps three deterministic positions on the sentinel. + assert int(np.sum(arr == 0)) >= 3 + + +def test_nan_sentinel_round_trips_through_rasterio() -> None: + """rasterio reads back a NaN nodata and the planted NaN pixels.""" + with rasterio.open(FIXTURE_NAN) as src: + assert src.dtypes[0] == 'float32' + assert src.nodata is not None and math.isnan(src.nodata) + arr = src.read(1) + assert int(np.sum(np.isnan(arr))) >= 3 + + +def test_miniswhite_is_visible_on_the_rasterio_source() -> None: + """The miniswhite photometric is observable via IMAGE_STRUCTURE tags. + + rasterio does not surface miniswhite via the ``photometric`` property + on read for a GTiff opened without a colourmap, but it is reachable + through the IMAGE_STRUCTURE namespace tags. The oracle reads from + the rasterio source directly, so any backend wiring that wants the + photometric flag must read it from the same place. + """ + with rasterio.open(FIXTURE_MINISWHITE) as src: + assert src.dtypes[0] == 'uint8' + assert src.nodata is None # white-as-min carries no nodata tag + tags = src.tags(ns='IMAGE_STRUCTURE') + assert tags.get('MINISWHITE') == 'YES', ( + f'miniswhite flag missing from IMAGE_STRUCTURE: {tags}') + arr = src.read(1) + # The generator stamps three deterministic pixels at the dtype max. + assert int(np.sum(arr == 255)) >= 3 + + +# --------------------------------------------------------------------------- +# Oracle accepts each convention end-to-end +# --------------------------------------------------------------------------- + +def test_oracle_accepts_int_sentinel_fixture() -> None: + with rasterio.open(FIXTURE_INT) as src: + cand = _candidate_from_source(src) + compare_to_oracle(FIXTURE_INT, cand) + + +def test_oracle_accepts_nan_sentinel_fixture() -> None: + """Confirms the oracle's NaN-aware equality path handles ``nodata=NaN``. + + A plain ``==`` comparison would fail because ``NaN != NaN``; + ``_nodata_equal`` and ``_pixels_equal`` (with ``equal_nan=True``) are + what makes this pass. + """ + with rasterio.open(FIXTURE_NAN) as src: + cand = _candidate_from_source(src) + # Sanity check: the candidate carries the NaN sentinel and at least + # one NaN pixel, so the test would fail if the oracle short-circuited. + assert math.isnan(cand.attrs['nodata']) + assert int(np.isnan(cand.values).sum()) >= 3 + compare_to_oracle(FIXTURE_NAN, cand) + + +def test_oracle_accepts_miniswhite_fixture() -> None: + """Confirms the oracle accepts the miniswhite convention. + + The white-as-min file carries no nodata tag, so the oracle's nodata + branch compares ``None`` on both sides. The photometric flag itself + is not part of the canonical-attrs contract yet (#1984), and is read + by callers from the rasterio source directly. + """ + with rasterio.open(FIXTURE_MINISWHITE) as src: + cand = _candidate_from_source(src) + assert src.tags(ns='IMAGE_STRUCTURE').get('MINISWHITE') == 'YES' + assert 'nodata' not in cand.attrs + compare_to_oracle(FIXTURE_MINISWHITE, cand) From f363ac324c421bb08280be86c573bc47163c1edb Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 16 May 2026 06:03:21 -0700 Subject: [PATCH 2/2] geotiff: review fixes for nodata sentinels corpus (#1930) Self-review pass on PR #1994: - _stamp_nodata_pixels: reject bool sentinels explicitly so a manifest entry like nodata: true can't slip a 1 into the raster. Matches the write-side gate from #1990. - test_fixture_is_a_valid_tiff: tighten size budget from 4 KB to 2 KB (largest fixture today is 1402 bytes), so silent bloat trips the regression rather than drifting toward the documented limit. - test_int_sentinel_round_trips_through_rasterio: also assert src.nodata is not NaN, since rasterio reports nodata as a float. - manifest description: clarify that the masked-data path is reachable once Phase 3 backend wiring lands, not in this PR. --- .../geotiff/tests/golden_corpus/generate.py | 5 +++++ .../geotiff/tests/golden_corpus/manifest.yaml | 2 +- .../golden_corpus/test_nodata_sentinels.py | 21 +++++++++++++------ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/xrspatial/geotiff/tests/golden_corpus/generate.py b/xrspatial/geotiff/tests/golden_corpus/generate.py index 6d1ec96c..33d1b5e3 100644 --- a/xrspatial/geotiff/tests/golden_corpus/generate.py +++ b/xrspatial/geotiff/tests/golden_corpus/generate.py @@ -337,6 +337,11 @@ def _stamp_nodata_pixels(arr: np.ndarray, entry: dict[str, Any]) -> None: if nd is None: return dtype = arr.dtype + # ``bool`` is a subclass of ``int``; reject it explicitly so a + # ``nodata: true`` manifest entry can't slip a 1 into the raster. + # The write-side gate is #1990; this is the matching read-side gate. + if isinstance(nd, bool): + return if isinstance(nd, (int, float)): sentinel: Any = nd elif nd == "nan": diff --git a/xrspatial/geotiff/tests/golden_corpus/manifest.yaml b/xrspatial/geotiff/tests/golden_corpus/manifest.yaml index 3a6c52a6..2f90b290 100644 --- a/xrspatial/geotiff/tests/golden_corpus/manifest.yaml +++ b/xrspatial/geotiff/tests/golden_corpus/manifest.yaml @@ -151,7 +151,7 @@ fixtures: description: >- uint16 raster with an explicit integer nodata sentinel (0). A handful of pixels are forced to 0 so the masked-data path is - reachable through the read backend. + reachable once a read backend lands in Phase 3. width: 16 height: 16 dtype: uint16 diff --git a/xrspatial/geotiff/tests/golden_corpus/test_nodata_sentinels.py b/xrspatial/geotiff/tests/golden_corpus/test_nodata_sentinels.py index 7ca20d82..585d3776 100644 --- a/xrspatial/geotiff/tests/golden_corpus/test_nodata_sentinels.py +++ b/xrspatial/geotiff/tests/golden_corpus/test_nodata_sentinels.py @@ -86,19 +86,26 @@ def _candidate_from_source(src) -> xr.DataArray: # Per-fixture parametrised TIFF validity check # --------------------------------------------------------------------------- +# Tight budget chosen from the largest fixture today (1402 bytes for the +# float32 NaN file). The plan caps fixtures at 4 KB; tightening to 2 KB +# here catches silent bloat (accidental overviews, predictor changes) +# before it drifts toward the documented limit. +_FIXTURE_SIZE_BUDGET = 2048 + + @pytest.mark.parametrize('path', ALL_FIXTURES, ids=lambda p: p.name) def test_fixture_is_a_valid_tiff(path: Path) -> None: """Each fixture exists, opens cleanly, and is small enough for git.""" assert path.exists(), f'corpus fixture missing on disk: {path}' - # The plan budgets each fixture at well under 4 KB; if a future - # change blows the budget the regression should fail loudly. - assert path.stat().st_size < 4096, ( - f'{path.name} exceeded 4 KB budget: {path.stat().st_size} bytes') + size = path.stat().st_size + assert size < _FIXTURE_SIZE_BUDGET, ( + f'{path.name} exceeded {_FIXTURE_SIZE_BUDGET} byte budget: ' + f'{size} bytes') with rasterio.open(path) as src: assert src.count == 1 assert src.width == 16 assert src.height == 16 - _ = src.read(1) # raises if the file is unreadable + src.read(1) # raises if the file is unreadable # --------------------------------------------------------------------------- @@ -109,7 +116,9 @@ def test_int_sentinel_round_trips_through_rasterio() -> None: """rasterio reads back the integer sentinel and the planted pixels.""" with rasterio.open(FIXTURE_INT) as src: assert src.dtypes[0] == 'uint16' - # Stored as float on the rasterio side but represents the int 0. + # rasterio reports nodata as a float, but it represents int 0. + assert src.nodata is not None + assert not math.isnan(src.nodata) assert src.nodata == 0 arr = src.read(1) # The generator stamps three deterministic positions on the sentinel.