diff --git a/.claude/sweep-accuracy-state.csv b/.claude/sweep-accuracy-state.csv
index 5933ea7a..1eaf831f 100644
--- a/.claude/sweep-accuracy-state.csv
+++ b/.claude/sweep-accuracy-state.csv
@@ -12,7 +12,7 @@ emerging_hotspots,2026-04-30,,MEDIUM,2;3,MEDIUM: threshold_90 uses int() (trunca
fire,2026-04-30,,,,All ops per-pixel (no accumulation/stencil/projected distance). NaN handled via x!=x; CUDA bounds use strict <; rdnbr and ros divisions guarded; CPU/GPU/dask paths algorithmically identical. No accuracy issues found.
flood,2026-04-30,,MEDIUM,2;5,"MEDIUM (not fixed): dask backend preserves float32 input dtype while numpy promotes to float64 in flood_depth and curve_number_runoff; DataArray inputs for curve_number, mannings_n bypass scalar > 0 (and CN <= 100) range validation, silently producing NaN/garbage."
focal,2026-03-30T13:00:00Z,1092,,,
-geotiff,2026-05-14,1847,HIGH,2;5,"Pass 23 (2026-05-14): HIGH fixed -- issue #1847. extract_geo_info parsed GDAL_NODATA via float() unconditionally, which loses 1 ULP on uint64 max (2**64-1) and int64 max (2**63-1). The downstream integer-mask gate info.min <= int(nodata) <= info.max then rejects the cast because float-rounded sentinel is one above the dtype max; the sentinel pixel survives as a literal valid integer instead of NaN. Same float-only parse in _reader._resolve_masked_fill (LERC fill) and _reader._sparse_fill_value (SPARSE_OK fill). VRT _vrt._parse_band_nodata had already fixed this for the XML parse path (PR #1833) but TIFF source-of-truth was never updated, so write_vrt([uint64.tif]) stringified the float-parsed nodata as '1.8446744073709552e+19' into XML where the VRT reader then rejected it for being out of range. Fix: lift the int-first parse into shared helper _parse_nodata_str in _geotags.py and reuse across the three TIFF-side sites. The helper tries int(text) first to preserve full precision, falls back to float(text) for NaN/Inf/scientific/fractional. Downstream gates already handle int values transparently because np.isfinite(int) works and int(int) is a no-op. 25 regression tests in test_nodata_int64_precision_1847.py: unit-level _parse_nodata_str matrix (int vs float branches, edge cases), eager open_geotiff (uint64 max / int64 max / int64 min / uint16 / int32 / float regression guards), read_geotiff_dask (uint64 max, int64 max), write_vrt + read_vrt round-trip with XML literal assertion, and a GPU parity test. All 2434 non-stale geotiff tests still pass (1 pre-existing test_size_param_validation_gpu_vrt_1776 failure unrelated -- test asserts pre-#1767 tile_size=4 behaviour). Categories: Cat 2 (NaN propagation: sentinel pixel survived as literal valid number on all 4 backends) + Cat 5 (backend inconsistency: VRT XML parse path handled 64-bit sentinels via _parse_band_nodata but TIFF parse path did not, even though write_vrt fed the latter into the former). Audited but did not file: LOW silent kwarg drop -- to_geotiff(da, 'out.vrt', photometric='miniswhite') drops the photometric arg at _write_vrt_tiled call (per-tile files written as MinIsBlack). Data round-trips correctly because no inversion happens on either side; only the tile photometric tag disagrees with the user's request. Niche path + no data corruption + metadata-only drift = LOW, not filed. | Pass 22 (2026-05-13): HIGH fixed -- issue #1809. MinIsWhite (photometric=0) inversion ran before the sentinel-to-NaN nodata mask on all four backends (eager numpy in open_geotiff, dask chunk reader, eager GPU in read_geotiff_gpu, GPU stripped fallback). Because the inversion rewrites the original sentinel value (e.g. uint8 nodata=0 becomes 255, float32 nodata=-9999 becomes 9999), the post-inversion mask matched the wrong pixels: cells whose stored value happened to equal iinfo.max - sentinel were flagged NaN while real sentinel cells survived as inverted values. PR #1804 (a5d78e4) had refactored the helper but kept the original ordering. Fix: introduce _miniswhite_inverted_nodata in _reader.py and stash the inverted sentinel on geo_info._mask_nodata; route every backend mask through that field, keeping geo_info.nodata + attrs[nodata] at the original value for write-side round-trip. Dask path also re-inverts the closure nodata at graph-build time, picking up _ifd_photometric / _ifd_samples_per_pixel stashed in _read_geo_info. 9 regression tests in test_miniswhite_nodata_1809.py cover uint8 nodata=0, uint16 nodata=65535, float32 nodata=-9999 across numpy, dask, and GPU backends plus no-collision and no-nodata controls. All 2424 non-stale geotiff tests pass (4 pre-existing failures unrelated to this fix). Categories: Cat 2 (NaN propagation: real data became NaN while sentinel survived as inverted value) + Cat 5 (backend inconsistency: all four backends share the identical wrong result, so they agreed on the wrong answer rather than diverged). | Pass 21 (2026-05-13): MEDIUM fixed -- issue #1774. open_geotiff / read_geotiff_dask / _apply_nodata_mask_gpu crashed with ValueError: cannot convert float NaN to integer when reading an integer TIFF whose GDAL_NODATA tag was the string ""nan"" / ""inf"" / ""-inf"". Three sites in xrspatial/geotiff/__init__.py called int(nodata) on the integer-dtype branch without first checking np.isfinite. _geotags.py:extract_geo_info parses the GDAL_NODATA tag through float(nodata_str) so a ""nan"" tag surfaces as Python NaN; the integer mask code then explodes. Sibling helpers _resolve_masked_fill and _sparse_fill_value in _reader.py already gate on not math.isnan(v) and not math.isinf(v) (the unfinished pass of #1581). Fix: gate each int(nodata) cast on np.isfinite(nodata). A non-finite sentinel on an integer file cannot match any pixel, so the mask is a no-op and the file dtype is preserved; attrs['nodata'] still carries the raw NaN/Inf sentinel so a write round-trip keeps the original GDAL_NODATA tag. The read_geotiff_dask effective_dtype branch already used try/except and was safe in practice, but tightened with the same isfinite gate for readability. 15 regression tests in test_nodata_nan_int_1774.py covering eager numpy (3 NaN variants + 6 Inf variants), in-range finite still masks regression guard, dask (NaN + Inf), and GPU (NaN + Inf + finite). All pass; 2023 existing geotiff tests still pass (7 pre-existing test_predictor2_big_endian_gpu failures unrelated: they reference xrspatial.geotiff.read_to_array which was hidden from the public namespace in #1708, 3 pre-existing matplotlib palette failures in test_features.py unrelated). Categories: Cat 2 (NaN propagation: NaN nodata produced a crash instead of being treated as missing) + Cat 5 (backend inconsistency: _resolve_masked_fill / _sparse_fill_value already guarded; the three __init__.py sites did not). | Pass 20 (2026-05-12): HIGH fixed -- PR #1691 (no issue created; agent harness blocked gh issue create). Integer COG overview pyramid mixed sentinel into reduced pixels. _block_reduce_2d (_writer.py:258-264) and _block_reduce_2d_gpu (_gpu_decode.py:3027-3028) promoted integer blocks to float64 but never masked the sentinel to NaN before nanmean / nanmin / nanmax / nanmedian. The reduction averaged the sentinel into surrounding valid cells (e.g. (-9999 + 100 + 100 + 100)/4 = -2425 cast back to int16), producing overview pixels that the read-side int-to-NaN mask in open_geotiff couldn't recover because they didn't equal the sentinel. Silent garbage at every zoom above level 0 for to_geotiff(int_data, cog=True, nodata=N). Methods affected: mean, min, max, median; nearest/mode safe (no averaging). Fix: gate the sentinel-to-NaN mask on representability in the source integer dtype (mirrors _int_nodata_in_range in _reader.py) so uint16+GDAL_NODATA=""-9999"" stays a no-op; rewrite all-sentinel-block NaN back to sentinel before the integer dtype cast so the cast is well-defined (the caller's post-overview loop in write() only runs for floats). GPU mirror gets the same path with cupy.where + cupy.isnan for byte parity with CPU. 38 regression tests in test_cog_int_overview_nodata_2026_05_12.py: _block_reduce_2d per-dtype/per-method matrix (uint8/uint16/int16/int32 x mean/min/max/median), all-sentinel-block, no-nodata regression, out-of-range sentinel no-op, end-to-end uint16 + int16 round-trip, 3-band integer COG, GPU per-dtype/per-method matrix, CPU/GPU byte-match parity. All 1606 existing geotiff tests still pass. Categories: Cat 1 (precision/representation loss in nan-aware reduction) + Cat 2 (silent NaN-equivalent corruption from sentinel poisoning) + Cat 5 (backend parity between float and integer code paths within the same writer). Deferred LOW: HTTP COG path (_read_cog_http at _reader.py:1638) skips the band-range validation that local/dask/GPU added in #1673; band=-1 silently selects the last channel on HTTP while local raises IndexError. Cat 5, MEDIUM-leaning but separate concern from the overview fix; one-finding-per-PR per project policy. | Pass 19 (2026-05-12): MEDIUM fixed -- issue #1655. read_vrt silently dropped 0 on a SimpleSource because of src.nodata or nodata at _vrt.py:370. Python treats 0.0 as falsy, so the per-source sentinel fell through to the band-level (or None when missing) and pixels equal to 0.0 in the source file survived as valid data. The in-code comment acknowledged the quirk as backward compat, but the resulting behaviour silently biased every NaN-aware aggregation on VRT mosaics whose sources used 0 as a sentinel (a common convention for unsigned remote-sensing imagery). Fix: src_nodata = src.nodata if src.nodata is not None else nodata. Five regression tests in test_vrt_source_nodata_zero_1655.py covering source NODATA=0, integer XML literal, non-zero unchanged, band-level NoDataValue=0 still honoured, and source-overrides-band precedence. All 100 vrt-related geotiff tests still pass; 3 pre-existing test_features.py matplotlib palette failures unrelated. Categories: Cat 2 (NaN propagation) + Cat 5 (backend inconsistency: read_geotiff masks 0 correctly when GDAL_NODATA tag is set; only VRT path was broken). | Pass 18 (2026-05-11): MEDIUM fixed -- issue #1642. PR #1641 (issue #1640) inherited level-0 georef on overview reads but kept the level-0 origin_x/origin_y unchanged. That is correct for PixelIsArea (origin = upper-left corner of pixel (0,0)) but wrong for PixelIsPoint (origin = center of pixel (0,0), GeoKey 1025 = 2). For a 1024x1024 PixelIsPoint COG with 10 m pixels and origin (0, 0), open_geotiff(overview_level=1) returned x[:3]=[0,20,40] instead of [5,25,45] (level-1 pixel 0 covers level-0 pixels 0-1 whose centers are 0 and 10, centroid 5); same for y. Downstream sel/interp/reproject silently snaps to the wrong pixel for any DEM-style PixelIsPoint COG (USGS, OpenTopography, Copernicus DEM). Categories: Cat 3 (off-by-one / boundary handling) + Cat 5 (raster_type-dependent backend convention). Fix: in extract_geo_info_with_overview_inheritance (_geotags.py), pick the effective raster_type first (overview-declared if non-default, otherwise inherited from parent), then when it is PixelIsPoint apply origin_shift = (scale - 1) * 0.5 * pixel_size_lvl0 along each axis before building the new GeoTransform. PixelIsArea path is byte-equivalent. 13 regression tests in test_overview_pixel_is_point_1642.py: centroid identity across all 4 backends, transform tuple across all 4 backends, uniform grid step, unit-level helper tests for both raster_types via stubbed extract_geo_info, own-geokeys-not-clobbered path on PixelIsPoint, and a PixelIsArea regression check. All 1397 existing non-network geotiff tests still pass (3 pre-existing matplotlib palette failures unrelated). Deferred LOW: non-power-of-two overview dimensions cause scale = base_w/ov_w to diverge from the true 2^level reduction (writer drops the right/bottom strip via h2=(h//2)*2; for h=1023 a level-1 overview has 511 rows so scale=2.0019 not 2.0). Fix would need to either (a) emit explicit geo tags on overview IFDs from the writer or (b) pass the level number into the inheritance helper; neither is a one-line change and the resulting coord error is sub-pixel of level 0. | Pass 17 (2026-05-11): MEDIUM fixed -- issue #1634. open_geotiff eager path windowed read produced confusing CoordinateValidationError when window extended past source extent. read_to_array clamped the window internally and returned a smaller array, but the eager code path used unclamped window indices for y/x coord generation (xrspatial/geotiff/__init__.py lines 562-572), so the coord array length differed from the data and xarray refused to construct the DataArray. Same bug affected the windowed transform shift in _populate_attrs_from_geo_info. The dask path (read_geotiff_dask) already validated up front since #1561, raising a clear ValueError with the format 'window=... is outside the source extent (HxW) or has non-positive size.' so the two backends diverged on the contract. Fix: validate the window up front in open_geotiff's eager branch via _read_geo_info (metadata-only read, no extra pixel cost) using the exact same condition the dask path uses, raising the same ValueError message format. Reproduction: 10x10 raster + window=(5,5,15,15) on eager raised CoordinateValidationError('conflicting sizes ... length 5 ... length 10'); now raises ValueError('window=(5, 5, 15, 15) is outside the source extent (10x10) or has non-positive size.'). Categories: Cat 3 (off-by-one / boundary handling) + Cat 5 (backend inconsistency). 12 regression tests in test_window_out_of_bounds_1634.py: negative start, past-right-edge, past-bottom-edge, past-both-edges, zero-size, inverted window, full-extent ok, interior subset, edge-aligned, eager-vs-dask parity, message-format parity, issue reproducer. All 1286 existing non-network geotiff tests still pass. | Pass 16 (2026-05-11): HIGH fixed -- issue #1623. to_geotiff(cog=True, overview_resampling='cubic', nodata=) on a float raster with NaN regions produced overview pixels with severe ringing artefacts near nodata borders. Same class of bug as #1613 but for the cubic branch: writer rewrites NaN to the sentinel upstream, then _block_reduce_2d(method=cubic) handed the sentinel-poisoned array straight to scipy.ndimage.zoom(order=3). The cubic spline blended the sentinel (e.g. -9999) into neighbouring cells, producing values like 1133.44, -10290.08 where the data was a constant 100. Repro on 16x16 float32 with a 4x4 NaN corner showed 18 polluted pixels in the 8x8 overview. Fix: when nodata is supplied on a float dtype and the sentinel is found, mask sentinel to NaN, run cubic with prefilter=False so a single NaN cannot poison the entire row/column (default B-spline prefilter is global), then rewrite any NaN in the result back to the sentinel. prefilter=False only fires when a sentinel is present so the non-nodata cubic semantics are unchanged. GPU side: _block_reduce_2d_gpu previously raised on method='cubic'; added a CPU fallback (same pattern as 'mode') so GPU writer produces byte-equivalent overviews. GPU_OVERVIEW_METHODS now includes 'cubic'. 12 regression tests in test_cog_cubic_overview_nodata_1623.py (helper no-ringing, poisoning repro, no-nodata unchanged, end-to-end round-trip, GPU fallback, CPU/GPU byte-match, +/-inf nodata mask, NaN-sentinel no-op, GPU_OVERVIEW_METHODS contract). All 1256 existing geotiff tests still pass (3 pre-existing matplotlib failures unrelated). | Pass 15 (2026-05-11): HIGH fixed -- issue #1613. to_geotiff(cog=True, nodata=) on a float raster with NaN produced a corrupted overview pyramid. The NaN-to-sentinel rewrite in __init__.py:1202 (CPU) and :2852 (GPU write_geotiff_gpu) ran BEFORE _make_overview / make_overview_gpu, so the nan-aware aggregations (np.nanmean/min/max/median, cupy.nanmean/min/max/median) saw the sentinel as a real number and biased every overview pixel. Reproduction with -9999 sentinel produced [[-4998.75,-4997.75],..] where np.nanmean gives [[1.5,3.5],..]. Both CPU and GPU paths affected; backend results matched each other but were both wrong (CAT 2 NaN propagation + CAT 5 documents the parity). Fix: _block_reduce_2d / _block_reduce_2d_gpu accept a nodata kwarg that masks the sentinel back to NaN for float dtypes before the reduction; the writer's overview loop passes nodata in, then rewrites all-sentinel reductions (which surface as NaN from the reducer) back to the sentinel for the on-disk pyramid. 11 regression tests in test_cog_overview_nodata_1613.py (CPU mean / partial-block / min/max/median / no-nodata passthrough / helper kwarg / all-sentinel block / GPU mean / GPU helper / CPU-GPU agreement). All 235 nodata/overview/cog tests still pass. | Pass 14 (2026-05-11): HIGH fixed -- issue #1611. read_vrt(band=None) on a multi-band integer VRT with per-band tags only masks band 0's sentinel. __init__.py lines 2795-2809 in read_vrt apply vrt.bands[0].nodata to the full ndim==3 array; bands 1+ keep their integer sentinels as literal finite values (e.g. 65000 surfaces as 65000.0 after the dtype=float64 cast, not NaN). Float-VRT path masks per-band correctly in _vrt._read_data lines 296-297 + 347-351. PR #1602 fixed the single-band band=N case for issue #1598; the band=None multi-band case is the same class of bug. Repro: 2-band uint16 VRT with NoDataValue 65535 / 65000 returns r.values[1,1,1] == 65000.0 instead of NaN; r.values[1,1,0] is NaN (band 0 sentinel masked). Fix scope: in read_vrt, when band is None, iterate over vrt.bands and mask each arr[..., i] slice against its own (gated by the same _int_nodata_in_range guard PR #1583 introduced). Severity HIGH (Cat 2 NaN propagation + Cat 5 backend inconsistency: identical input semantics produce different masking outcomes based on dtype, with finite garbage values where NaN expected). Fix in PR #1612: walks vrt.bands when band is None and ndim==3, masks each arr[..., i] slice against its own via the refactored _sentinel_for_dtype helper (reuses PR #1583's range guard so out-of-range/non-finite/fractional sentinels are a no-op). attrs['nodata'] still carries band 0's sentinel for band=None reads (documented contract). 7 regression tests in test_vrt_multiband_int_nodata_1611.py: uint16 per-band, int32 negative, mixed presence, dtype preservation when no sentinel hit, out-of-range gating, band=N non-regression, attrs contract. 135 existing vrt/nodata geotiff tests still pass. | Pass 13 (2026-05-11): HIGH fixed -- issue #1599. write_geotiff_gpu (and to_geotiff gpu=True) emitted raw NaN bytes for missing pixels even when nodata= was supplied, while the CPU writer substituted NaN with the sentinel before encoding. xrspatial-only round-trips were unaffected (the reader masks both NaN and the sentinel), but external readers (rasterio/GDAL/QGIS) that mask only on the GDAL_NODATA tag saw NaN pixels as valid data -- rasterio reported 100% valid pixels on a 25-NaN file vs CPU's 25-invalid report. Root cause: __init__.py lines 2579-2587 jumped from shape/dtype resolution straight to compression, missing the equivalent of the CPU writer's NaN-to-sentinel rewrite at to_geotiff line ~1156. Fix: cupy.isnan + masked write on a defensive copy of arr, gated on np_dtype.kind=='f' and not np.isnan(float(nodata)). Caller's CuPy buffer preserved (copy before mutate). 7 regression tests in test_gpu_writer_nan_sentinel_1599.py: substitution lands as sentinel, CPU/GPU byte-equivalent, caller buffer not mutated, no-NaN no-op, NaN sentinel skips substitution, rasterio sees identical invalid count on CPU/GPU, multiband 3D path. All other GPU writer tests still pass (50 passed across band-first, attrs, nodata, dask+cupy, writer, nodata aliases). | Pass 12 (2026-05-11): HIGH fixed -- issue #1581. Reading a uint TIFF with a negative GDAL_NODATA sentinel (e.g. uint16 + -9999) raised OverflowError on every backend because the nodata-mask code did arr.dtype.type(int(nodata)) with no range check. Three identical cast sites in __init__.py (numpy eager, _apply_nodata_mask_gpu, _delayed_read_window) plus _resolve_masked_fill and _sparse_fill_value in _reader.py. Fix: _int_nodata_in_range helper gates the cast; out-of-range sentinels are a no-op for value matching (the file can never contain that value), file dtype is preserved, attrs['nodata'] still surfaces the original sentinel so write round-trips keep the GDAL_NODATA tag intact. Matches rasterio behavior. 8 regression tests in test_nodata_out_of_range_1581.py cover the helper, both eager and dask read paths, in-range sentinel non-regression, and GPU helper (cupy-gated). | Pass 11 (2026-05-10): CLEAN. Audited the one additional commit since pass 10 -- #1559 (PR 1548, Centralise GeoTIFF attrs population across all read backends). Refactor extracts _populate_attrs_from_geo_info helper and routes eager numpy, dask, GPU stripped, GPU tiled read paths through it; before the fix dask only emitted crs/transform/raster_type/nodata while numpy emitted the full attrs set including x/y_resolution, resolution_unit, image_description, extra_samples, GDAL metadata, and the CRS-description fields. No data-path arithmetic touched; only attrs dict population. Windowed origin math (origin_x + c0*pixel_width, origin_y + r0*pixel_height) verified to produce -98.0 / 48.75 origin for window=(10,20,50,70) on a (0.1,-0.125) pixel-size raster, with PixelIsArea half-pixel offset preserved on coord lookups (-97.95, 48.6875). Cross-backend attrs parity re-verified: numpy/dask/cupy all emit identical key set on deflate+predictor3+nodata round-trip (crs, crs_wkt, nodata, transform, x_resolution, y_resolution). Data bit-parity re-verified across numpy/dask/cupy on same payload (np.array_equal with equal_nan=True). test_attrs_parity_1548.py (5 tests), test_reader.py/test_writer.py/test_dask_cupy_combined.py (25 tests), GPU orientation/predictor2-BE/LERC-mask/nodata/byteswap suites (65 tests) all green. No accuracy or backend-divergence findings. | Pass 10 (2026-05-10): CLEAN. Audited 5 recent commits: #1558 drop-defensive-copies (frombuffer path still .copy()s before in-place predictor decode at _reader.py:778), #1556 fp-predictor ngjit (writer pre-ravels so 1-D slice arg is correct, float32/64 LE+BE bit-exact), #1552 batched D2H (OOM guard fires before cupy.concatenate, host_buf offsets correct), #1551 parallel-decode gate (>= vs > sends 256x256 default to parallel path, no value diff confirmed via partial-tile parity), #1549 nvjpeg constants (gray + RGB GPU JPEG decode pixel-identical to Pillow CPU, max diff = 0). Cross-backend parity re-verified clean: numpy/dask+numpy/cupy/dask+cupy equal .data/.dtype/.coords/nodata/NaN-mask on deflate+predictor3+nodata; orientations 1-8 numpy==GPU; partial edge tiles 100x150, 257x383, 512x257 numpy==GPU==dask; predictor2 LE/BE round-trip uint8/int16/uint16/int32/uint32 pass; predictor3 LE/BE float32/64 pass. Deferred LOW (pre-existing, not opened): float16 (bps=16, SampleFormat=3) absent from tiff_dtype_to_numpy map - writer never emits, asymmetric but unreachable. | Pass 9 (2026-05-09): TWO HIGH fixed -- (a) PR #1539 closes #1537: TIFF Orientation tag 2/3/4 (mirror flips) on georeferenced files left y/x coords computed from the un-flipped transform, so xarray label lookups returned the wrong pixel even though _apply_orientation flipped the buffer. PR #1521 only updated the transform for the 5-8 axis-swap branch. Fix updates origin and pixel-scale signs along whichever axes were flipped, for both PixelIsArea (origin shifts by N*step) and PixelIsPoint (shifts by (N-1)*step). 10 new tests in test_orientation.py. (b) PR #1546 closes #1540: read_geotiff_gpu ignored Orientation tag completely; CPU correctly applied 2-8 (PR #1521) but GPU returned the raw stored buffer. Cross-backend disagreement on every non-default orientation. Fix adds _apply_orientation_gpu (cupy slicing mirror of the CPU helper) and _apply_orientation_geo_info, threads them into the tiled GPU pipeline, reuses CPU-fallback geo_info for the stripped path to avoid double-applying. 28 new tests in test_orientation_gpu.py (every orientation, single-band tiled, single-band stripped, 3-band tiled, mirror-flip sel-fidelity, default no-tag passthrough). Re-confirmed clean: HTTP coalesce_ranges with overlapping ranges and zero-length ranges, parallel streaming write thread-safety (each tile gets independent buffer via copy or padded zeros), planar=2 + chunky GPU LERC mask propagation matches CPU, IFD chain cap MAX_IFDS=256, max_z_error round-trip on tiled write, _resolve_masked_fill float vs integer dtype semantics. Deferred LOW: per-sample LERC mask (3D mask (h,w,samples)) collapsed to per-pixel ""any sample invalid"" on GPU while CPU honours per-sample; LERC implementations rarely emit 3D masks (verified: lerc.encode with 2D mask on 3-band returns 2D mask). Documented planar=2 + LERC + GPU silently drops mask (rare in practice, source comment acknowledges). | Pass 8 (2026-05-07): HIGH fixed in fix-jpeg-tiff-disable -- to_geotiff(compression='jpeg') wrote files that no external reader can decode. The writer tags compression=7 (new-style JPEG) but emits a self-contained JFIF stream per tile/strip and never writes the JPEGTables tag (347) that the TIFF spec requires for that codec. libtiff/GDAL/rasterio all reject the file with TIFFReadEncodedStrip() failed; our reader round-trips because Pillow decodes the standalone JFIF, hiding the break. Pass-4 notes flagged the read side of the same JPEGTables gap and deferred it; pass-8 covers the write side. Fix: reject compression='jpeg' at the to_geotiff entry with a clear ValueError pointing at deflate/zstd/lzw. The internal _writer.write is untouched so the existing self-decoding tests still cover the codec; re-enabling the public path needs a JPEGTables-aware encoder. PR diffs reviewed but not merged: #1512 (BytesIO source) and #1513 (LERC max_z_error) -- both look correct; #1512 file-like read path goes through read_all() once so the per-call BytesIOSource lock is theoretical, and #1513 forwards max_z_error through every overview/tile/strip/streaming path including _write_vrt_tiled and _compress_block. No regressions found in either open PR. Other surfaces audited clean: predictor=3 with float16 (writer auto-promotes to float32 on both eager and streaming paths, value-exact round-trip); planar=2 multi-tile read uses band_idx*tiles_per_band offset so no cross-contamination between planes; _header.py multi-byte tag parsing uses bo (byte_order) consistently; Pillow YCbCr-vs-tagged-RGB photometric mismatch becomes moot once JPEG is disabled. Deferred (LOW/MEDIUM, not filed): JPEG2000 writer accepts arbitrary dtype with no validation (rare codec, narrow risk); float16 dtype not in tiff_dtype_to_numpy decode map (writer never emits it - asymmetric but unreachable); Orientation tag (274) still ignored on read (pass-4 deferral). | Pass 7 (2026-05-07): HIGH fixed in fix-mmap-cache-refcount-after-replace -- _MmapCache.release() looked up the cache entry by realpath, so a holder that acquired the OLD mmap before an os.replace and released it AFTER another caller had acquired the post-replace entry would decrement the new holder's refcount. Subsequent eviction (cache full, or another acquire) closed the still-in-use mmap, breaking reads with 'mmap closed or invalid'. Real exposure: any concurrent reader/writer pattern where to_geotiff replaces a file that another reader had just opened via open_geotiff with chunks= or via _FileSource. PR #1506 added stale-replacement detection but did not fix the refcount confusion across the pop. Fix: acquire returns an opaque entry token; release takes the token and decrements that exact entry, regardless of cache state. Orphaned (popped) entries close their fh+mmap when their own refcount hits zero. _FileSource updated to pass the token. Regression test test_release_after_path_replacement_does_not_clobber_new_holder added. All 665 geotiff tests pass; GPU path verified. | Pass 6 (2026-05-07) PR #1507: BE pred2 numba TypingError. | Pass 5 (2026-05-06) PR #1506: mmap cache stale after file replace. | Pass 4 (2026-05-06) PR #1501: sparse COG tiles. | Pass 3 (2026-05-06) PR #1500: predictor=3 byte order. | Pass 2 (2026-05-05) PR #1498: predictor=2 sample-wise. | Pass 1 (2026-04-23) PR #1247. Re-confirmed clean over passes 2-7: items 2 (writer always emits LE TIFFs - hardcoded b'II'), 3 (RowsPerStrip default = height when missing), 4 (StripByteCounts missing raises clear ValueError), 5 (TileWidth without TileLength caught by 'tw <= 0 or th <= 0' check at _reader.py:688), 9 (read determinism on compressed+tiled+multiband), 11 (predictor=2 with awkward sample stride round-trips), 18 (compression_level=99 raises ValueError 'out of range for deflate (valid: 1-9)'), 21 (concurrent writes serialize correctly via mkstemp+os.replace), 24 (uint16 dtype preserved on numpy backend, dask honors chunks param), 26 (chunks rounds correctly with remainder chunk for non-tile-aligned). Deferred: item 8 (BytesIO/file-like sources are not supported, source.lower() error) - documented as 'str' parameter, not a bug; item 19 (LERC max_z_error not user-exposed by to_geotiff) - missing feature, not a bug."
+geotiff,2026-05-15,1975,HIGH,1;2;5,"Pass 25 (2026-05-15): HIGH fixed -- issue #1975. _block_reduce_2d's cubic branch in xrspatial/geotiff/_writer.py gated the sentinel-to-NaN mask on arr2d.dtype.kind=='f', so to_geotiff(cog=True, overview_resampling='cubic', nodata=) on an integer raster fell through to an unmasked zoom(arr2d, 0.5, order=3). The bicubic spline blended the sentinel (e.g. -9999) into neighbouring valid cells; cast back to the source integer dtype, the boundary pixels surfaced as silent garbage. Reproduction (1024x1024 int16 + 256x256 nodata corner + nodata=-9999): lvl1 boundary [128, 124:132] showed [1082, 1082, 1085, 1134, 5, 93, 100, 100] instead of [-9999/NaN, ..., 100, 100, 100, 100]; max poisoned value 1134 (11x the actual data value of 100) and min -11104 (below the sentinel -9999). Same root cause as #1623 (float cubic + nodata) but for the integer dtype branch. Both CPU and GPU writers affected because _block_reduce_2d_gpu's cubic path falls back to _block_reduce_2d on CPU. Fix mirrors the float branch: promote the cropped block to float64, mask sentinel to NaN via the integer-range guard (mirrors _int_nodata_in_range), run scipy.ndimage.zoom(prefilter=False), rewrite NaN back to the sentinel, then np.round(...).astype(source_int_dtype) so the integer cast is well-defined. 12 regression tests in test_cog_cubic_int_overview_nodata_1975.py: helper-level cubic per int dtype (int16, uint16, int32), no-nodata regression, out-of-range sentinel no-op, fractional sentinel no-op, all-sentinel block fallback, float cubic regression guard, end-to-end 1024x1024 round-trip, non-constant int regression, cubic-vs-mean sentinel-mask parity, and GPU/CPU byte parity. All 3186 non-stale geotiff tests still pass (2 pre-existing failures unrelated: test_predictor2_big_endian_gpu references the hidden read_to_array symbol, and test_size_param_validation_gpu_vrt_1776 asserts pre-#1767 tile_size=4 behaviour). Categories: Cat 1 (precision loss from cubic spline blending sentinel into valid cells) + Cat 2 (NaN-equivalent corruption: the read-side int-to-NaN mask only catches exact sentinel hits, so the poisoned values survive as legitimate measurements) + Cat 5 (backend parity: CPU and GPU writers shared the same wrong cubic path). | Pass 23 (2026-05-14): HIGH fixed -- issue #1847. extract_geo_info parsed GDAL_NODATA via float() unconditionally, which loses 1 ULP on uint64 max (2**64-1) and int64 max (2**63-1). The downstream integer-mask gate info.min <= int(nodata) <= info.max then rejects the cast because float-rounded sentinel is one above the dtype max; the sentinel pixel survives as a literal valid integer instead of NaN. Same float-only parse in _reader._resolve_masked_fill (LERC fill) and _reader._sparse_fill_value (SPARSE_OK fill). VRT _vrt._parse_band_nodata had already fixed this for the XML parse path (PR #1833) but TIFF source-of-truth was never updated, so write_vrt([uint64.tif]) stringified the float-parsed nodata as '1.8446744073709552e+19' into XML where the VRT reader then rejected it for being out of range. Fix: lift the int-first parse into shared helper _parse_nodata_str in _geotags.py and reuse across the three TIFF-side sites. The helper tries int(text) first to preserve full precision, falls back to float(text) for NaN/Inf/scientific/fractional. Downstream gates already handle int values transparently because np.isfinite(int) works and int(int) is a no-op. 25 regression tests in test_nodata_int64_precision_1847.py: unit-level _parse_nodata_str matrix (int vs float branches, edge cases), eager open_geotiff (uint64 max / int64 max / int64 min / uint16 / int32 / float regression guards), read_geotiff_dask (uint64 max, int64 max), write_vrt + read_vrt round-trip with XML literal assertion, and a GPU parity test. All 2434 non-stale geotiff tests still pass (1 pre-existing test_size_param_validation_gpu_vrt_1776 failure unrelated -- test asserts pre-#1767 tile_size=4 behaviour). Categories: Cat 2 (NaN propagation: sentinel pixel survived as literal valid number on all 4 backends) + Cat 5 (backend inconsistency: VRT XML parse path handled 64-bit sentinels via _parse_band_nodata but TIFF parse path did not, even though write_vrt fed the latter into the former). Audited but did not file: LOW silent kwarg drop -- to_geotiff(da, 'out.vrt', photometric='miniswhite') drops the photometric arg at _write_vrt_tiled call (per-tile files written as MinIsBlack). Data round-trips correctly because no inversion happens on either side; only the tile photometric tag disagrees with the user's request. Niche path + no data corruption + metadata-only drift = LOW, not filed. | Pass 22 (2026-05-13): HIGH fixed -- issue #1809. MinIsWhite (photometric=0) inversion ran before the sentinel-to-NaN nodata mask on all four backends (eager numpy in open_geotiff, dask chunk reader, eager GPU in read_geotiff_gpu, GPU stripped fallback). Because the inversion rewrites the original sentinel value (e.g. uint8 nodata=0 becomes 255, float32 nodata=-9999 becomes 9999), the post-inversion mask matched the wrong pixels: cells whose stored value happened to equal iinfo.max - sentinel were flagged NaN while real sentinel cells survived as inverted values. PR #1804 (a5d78e4) had refactored the helper but kept the original ordering. Fix: introduce _miniswhite_inverted_nodata in _reader.py and stash the inverted sentinel on geo_info._mask_nodata; route every backend mask through that field, keeping geo_info.nodata + attrs[nodata] at the original value for write-side round-trip. Dask path also re-inverts the closure nodata at graph-build time, picking up _ifd_photometric / _ifd_samples_per_pixel stashed in _read_geo_info. 9 regression tests in test_miniswhite_nodata_1809.py cover uint8 nodata=0, uint16 nodata=65535, float32 nodata=-9999 across numpy, dask, and GPU backends plus no-collision and no-nodata controls. All 2424 non-stale geotiff tests pass (4 pre-existing failures unrelated to this fix). Categories: Cat 2 (NaN propagation: real data became NaN while sentinel survived as inverted value) + Cat 5 (backend inconsistency: all four backends share the identical wrong result, so they agreed on the wrong answer rather than diverged). | Pass 21 (2026-05-13): MEDIUM fixed -- issue #1774. open_geotiff / read_geotiff_dask / _apply_nodata_mask_gpu crashed with ValueError: cannot convert float NaN to integer when reading an integer TIFF whose GDAL_NODATA tag was the string ""nan"" / ""inf"" / ""-inf"". Three sites in xrspatial/geotiff/__init__.py called int(nodata) on the integer-dtype branch without first checking np.isfinite. _geotags.py:extract_geo_info parses the GDAL_NODATA tag through float(nodata_str) so a ""nan"" tag surfaces as Python NaN; the integer mask code then explodes. Sibling helpers _resolve_masked_fill and _sparse_fill_value in _reader.py already gate on not math.isnan(v) and not math.isinf(v) (the unfinished pass of #1581). Fix: gate each int(nodata) cast on np.isfinite(nodata). A non-finite sentinel on an integer file cannot match any pixel, so the mask is a no-op and the file dtype is preserved; attrs['nodata'] still carries the raw NaN/Inf sentinel so a write round-trip keeps the original GDAL_NODATA tag. The read_geotiff_dask effective_dtype branch already used try/except and was safe in practice, but tightened with the same isfinite gate for readability. 15 regression tests in test_nodata_nan_int_1774.py covering eager numpy (3 NaN variants + 6 Inf variants), in-range finite still masks regression guard, dask (NaN + Inf), and GPU (NaN + Inf + finite). All pass; 2023 existing geotiff tests still pass (7 pre-existing test_predictor2_big_endian_gpu failures unrelated: they reference xrspatial.geotiff.read_to_array which was hidden from the public namespace in #1708, 3 pre-existing matplotlib palette failures in test_features.py unrelated). Categories: Cat 2 (NaN propagation: NaN nodata produced a crash instead of being treated as missing) + Cat 5 (backend inconsistency: _resolve_masked_fill / _sparse_fill_value already guarded; the three __init__.py sites did not). | Pass 20 (2026-05-12): HIGH fixed -- PR #1691 (no issue created; agent harness blocked gh issue create). Integer COG overview pyramid mixed sentinel into reduced pixels. _block_reduce_2d (_writer.py:258-264) and _block_reduce_2d_gpu (_gpu_decode.py:3027-3028) promoted integer blocks to float64 but never masked the sentinel to NaN before nanmean / nanmin / nanmax / nanmedian. The reduction averaged the sentinel into surrounding valid cells (e.g. (-9999 + 100 + 100 + 100)/4 = -2425 cast back to int16), producing overview pixels that the read-side int-to-NaN mask in open_geotiff couldn't recover because they didn't equal the sentinel. Silent garbage at every zoom above level 0 for to_geotiff(int_data, cog=True, nodata=N). Methods affected: mean, min, max, median; nearest/mode safe (no averaging). Fix: gate the sentinel-to-NaN mask on representability in the source integer dtype (mirrors _int_nodata_in_range in _reader.py) so uint16+GDAL_NODATA=""-9999"" stays a no-op; rewrite all-sentinel-block NaN back to sentinel before the integer dtype cast so the cast is well-defined (the caller's post-overview loop in write() only runs for floats). GPU mirror gets the same path with cupy.where + cupy.isnan for byte parity with CPU. 38 regression tests in test_cog_int_overview_nodata_2026_05_12.py: _block_reduce_2d per-dtype/per-method matrix (uint8/uint16/int16/int32 x mean/min/max/median), all-sentinel-block, no-nodata regression, out-of-range sentinel no-op, end-to-end uint16 + int16 round-trip, 3-band integer COG, GPU per-dtype/per-method matrix, CPU/GPU byte-match parity. All 1606 existing geotiff tests still pass. Categories: Cat 1 (precision/representation loss in nan-aware reduction) + Cat 2 (silent NaN-equivalent corruption from sentinel poisoning) + Cat 5 (backend parity between float and integer code paths within the same writer). Deferred LOW: HTTP COG path (_read_cog_http at _reader.py:1638) skips the band-range validation that local/dask/GPU added in #1673; band=-1 silently selects the last channel on HTTP while local raises IndexError. Cat 5, MEDIUM-leaning but separate concern from the overview fix; one-finding-per-PR per project policy. | Pass 19 (2026-05-12): MEDIUM fixed -- issue #1655. read_vrt silently dropped 0 on a SimpleSource because of src.nodata or nodata at _vrt.py:370. Python treats 0.0 as falsy, so the per-source sentinel fell through to the band-level (or None when missing) and pixels equal to 0.0 in the source file survived as valid data. The in-code comment acknowledged the quirk as backward compat, but the resulting behaviour silently biased every NaN-aware aggregation on VRT mosaics whose sources used 0 as a sentinel (a common convention for unsigned remote-sensing imagery). Fix: src_nodata = src.nodata if src.nodata is not None else nodata. Five regression tests in test_vrt_source_nodata_zero_1655.py covering source NODATA=0, integer XML literal, non-zero unchanged, band-level NoDataValue=0 still honoured, and source-overrides-band precedence. All 100 vrt-related geotiff tests still pass; 3 pre-existing test_features.py matplotlib palette failures unrelated. Categories: Cat 2 (NaN propagation) + Cat 5 (backend inconsistency: read_geotiff masks 0 correctly when GDAL_NODATA tag is set; only VRT path was broken). | Pass 18 (2026-05-11): MEDIUM fixed -- issue #1642. PR #1641 (issue #1640) inherited level-0 georef on overview reads but kept the level-0 origin_x/origin_y unchanged. That is correct for PixelIsArea (origin = upper-left corner of pixel (0,0)) but wrong for PixelIsPoint (origin = center of pixel (0,0), GeoKey 1025 = 2). For a 1024x1024 PixelIsPoint COG with 10 m pixels and origin (0, 0), open_geotiff(overview_level=1) returned x[:3]=[0,20,40] instead of [5,25,45] (level-1 pixel 0 covers level-0 pixels 0-1 whose centers are 0 and 10, centroid 5); same for y. Downstream sel/interp/reproject silently snaps to the wrong pixel for any DEM-style PixelIsPoint COG (USGS, OpenTopography, Copernicus DEM). Categories: Cat 3 (off-by-one / boundary handling) + Cat 5 (raster_type-dependent backend convention). Fix: in extract_geo_info_with_overview_inheritance (_geotags.py), pick the effective raster_type first (overview-declared if non-default, otherwise inherited from parent), then when it is PixelIsPoint apply origin_shift = (scale - 1) * 0.5 * pixel_size_lvl0 along each axis before building the new GeoTransform. PixelIsArea path is byte-equivalent. 13 regression tests in test_overview_pixel_is_point_1642.py: centroid identity across all 4 backends, transform tuple across all 4 backends, uniform grid step, unit-level helper tests for both raster_types via stubbed extract_geo_info, own-geokeys-not-clobbered path on PixelIsPoint, and a PixelIsArea regression check. All 1397 existing non-network geotiff tests still pass (3 pre-existing matplotlib palette failures unrelated). Deferred LOW: non-power-of-two overview dimensions cause scale = base_w/ov_w to diverge from the true 2^level reduction (writer drops the right/bottom strip via h2=(h//2)*2; for h=1023 a level-1 overview has 511 rows so scale=2.0019 not 2.0). Fix would need to either (a) emit explicit geo tags on overview IFDs from the writer or (b) pass the level number into the inheritance helper; neither is a one-line change and the resulting coord error is sub-pixel of level 0. | Pass 17 (2026-05-11): MEDIUM fixed -- issue #1634. open_geotiff eager path windowed read produced confusing CoordinateValidationError when window extended past source extent. read_to_array clamped the window internally and returned a smaller array, but the eager code path used unclamped window indices for y/x coord generation (xrspatial/geotiff/__init__.py lines 562-572), so the coord array length differed from the data and xarray refused to construct the DataArray. Same bug affected the windowed transform shift in _populate_attrs_from_geo_info. The dask path (read_geotiff_dask) already validated up front since #1561, raising a clear ValueError with the format 'window=... is outside the source extent (HxW) or has non-positive size.' so the two backends diverged on the contract. Fix: validate the window up front in open_geotiff's eager branch via _read_geo_info (metadata-only read, no extra pixel cost) using the exact same condition the dask path uses, raising the same ValueError message format. Reproduction: 10x10 raster + window=(5,5,15,15) on eager raised CoordinateValidationError('conflicting sizes ... length 5 ... length 10'); now raises ValueError('window=(5, 5, 15, 15) is outside the source extent (10x10) or has non-positive size.'). Categories: Cat 3 (off-by-one / boundary handling) + Cat 5 (backend inconsistency). 12 regression tests in test_window_out_of_bounds_1634.py: negative start, past-right-edge, past-bottom-edge, past-both-edges, zero-size, inverted window, full-extent ok, interior subset, edge-aligned, eager-vs-dask parity, message-format parity, issue reproducer. All 1286 existing non-network geotiff tests still pass. | Pass 16 (2026-05-11): HIGH fixed -- issue #1623. to_geotiff(cog=True, overview_resampling='cubic', nodata=) on a float raster with NaN regions produced overview pixels with severe ringing artefacts near nodata borders. Same class of bug as #1613 but for the cubic branch: writer rewrites NaN to the sentinel upstream, then _block_reduce_2d(method=cubic) handed the sentinel-poisoned array straight to scipy.ndimage.zoom(order=3). The cubic spline blended the sentinel (e.g. -9999) into neighbouring cells, producing values like 1133.44, -10290.08 where the data was a constant 100. Repro on 16x16 float32 with a 4x4 NaN corner showed 18 polluted pixels in the 8x8 overview. Fix: when nodata is supplied on a float dtype and the sentinel is found, mask sentinel to NaN, run cubic with prefilter=False so a single NaN cannot poison the entire row/column (default B-spline prefilter is global), then rewrite any NaN in the result back to the sentinel. prefilter=False only fires when a sentinel is present so the non-nodata cubic semantics are unchanged. GPU side: _block_reduce_2d_gpu previously raised on method='cubic'; added a CPU fallback (same pattern as 'mode') so GPU writer produces byte-equivalent overviews. GPU_OVERVIEW_METHODS now includes 'cubic'. 12 regression tests in test_cog_cubic_overview_nodata_1623.py (helper no-ringing, poisoning repro, no-nodata unchanged, end-to-end round-trip, GPU fallback, CPU/GPU byte-match, +/-inf nodata mask, NaN-sentinel no-op, GPU_OVERVIEW_METHODS contract). All 1256 existing geotiff tests still pass (3 pre-existing matplotlib failures unrelated). | Pass 15 (2026-05-11): HIGH fixed -- issue #1613. to_geotiff(cog=True, nodata=) on a float raster with NaN produced a corrupted overview pyramid. The NaN-to-sentinel rewrite in __init__.py:1202 (CPU) and :2852 (GPU write_geotiff_gpu) ran BEFORE _make_overview / make_overview_gpu, so the nan-aware aggregations (np.nanmean/min/max/median, cupy.nanmean/min/max/median) saw the sentinel as a real number and biased every overview pixel. Reproduction with -9999 sentinel produced [[-4998.75,-4997.75],..] where np.nanmean gives [[1.5,3.5],..]. Both CPU and GPU paths affected; backend results matched each other but were both wrong (CAT 2 NaN propagation + CAT 5 documents the parity). Fix: _block_reduce_2d / _block_reduce_2d_gpu accept a nodata kwarg that masks the sentinel back to NaN for float dtypes before the reduction; the writer's overview loop passes nodata in, then rewrites all-sentinel reductions (which surface as NaN from the reducer) back to the sentinel for the on-disk pyramid. 11 regression tests in test_cog_overview_nodata_1613.py (CPU mean / partial-block / min/max/median / no-nodata passthrough / helper kwarg / all-sentinel block / GPU mean / GPU helper / CPU-GPU agreement). All 235 nodata/overview/cog tests still pass. | Pass 14 (2026-05-11): HIGH fixed -- issue #1611. read_vrt(band=None) on a multi-band integer VRT with per-band tags only masks band 0's sentinel. __init__.py lines 2795-2809 in read_vrt apply vrt.bands[0].nodata to the full ndim==3 array; bands 1+ keep their integer sentinels as literal finite values (e.g. 65000 surfaces as 65000.0 after the dtype=float64 cast, not NaN). Float-VRT path masks per-band correctly in _vrt._read_data lines 296-297 + 347-351. PR #1602 fixed the single-band band=N case for issue #1598; the band=None multi-band case is the same class of bug. Repro: 2-band uint16 VRT with NoDataValue 65535 / 65000 returns r.values[1,1,1] == 65000.0 instead of NaN; r.values[1,1,0] is NaN (band 0 sentinel masked). Fix scope: in read_vrt, when band is None, iterate over vrt.bands and mask each arr[..., i] slice against its own (gated by the same _int_nodata_in_range guard PR #1583 introduced). Severity HIGH (Cat 2 NaN propagation + Cat 5 backend inconsistency: identical input semantics produce different masking outcomes based on dtype, with finite garbage values where NaN expected). Fix in PR #1612: walks vrt.bands when band is None and ndim==3, masks each arr[..., i] slice against its own via the refactored _sentinel_for_dtype helper (reuses PR #1583's range guard so out-of-range/non-finite/fractional sentinels are a no-op). attrs['nodata'] still carries band 0's sentinel for band=None reads (documented contract). 7 regression tests in test_vrt_multiband_int_nodata_1611.py: uint16 per-band, int32 negative, mixed presence, dtype preservation when no sentinel hit, out-of-range gating, band=N non-regression, attrs contract. 135 existing vrt/nodata geotiff tests still pass. | Pass 13 (2026-05-11): HIGH fixed -- issue #1599. write_geotiff_gpu (and to_geotiff gpu=True) emitted raw NaN bytes for missing pixels even when nodata= was supplied, while the CPU writer substituted NaN with the sentinel before encoding. xrspatial-only round-trips were unaffected (the reader masks both NaN and the sentinel), but external readers (rasterio/GDAL/QGIS) that mask only on the GDAL_NODATA tag saw NaN pixels as valid data -- rasterio reported 100% valid pixels on a 25-NaN file vs CPU's 25-invalid report. Root cause: __init__.py lines 2579-2587 jumped from shape/dtype resolution straight to compression, missing the equivalent of the CPU writer's NaN-to-sentinel rewrite at to_geotiff line ~1156. Fix: cupy.isnan + masked write on a defensive copy of arr, gated on np_dtype.kind=='f' and not np.isnan(float(nodata)). Caller's CuPy buffer preserved (copy before mutate). 7 regression tests in test_gpu_writer_nan_sentinel_1599.py: substitution lands as sentinel, CPU/GPU byte-equivalent, caller buffer not mutated, no-NaN no-op, NaN sentinel skips substitution, rasterio sees identical invalid count on CPU/GPU, multiband 3D path. All other GPU writer tests still pass (50 passed across band-first, attrs, nodata, dask+cupy, writer, nodata aliases). | Pass 12 (2026-05-11): HIGH fixed -- issue #1581. Reading a uint TIFF with a negative GDAL_NODATA sentinel (e.g. uint16 + -9999) raised OverflowError on every backend because the nodata-mask code did arr.dtype.type(int(nodata)) with no range check. Three identical cast sites in __init__.py (numpy eager, _apply_nodata_mask_gpu, _delayed_read_window) plus _resolve_masked_fill and _sparse_fill_value in _reader.py. Fix: _int_nodata_in_range helper gates the cast; out-of-range sentinels are a no-op for value matching (the file can never contain that value), file dtype is preserved, attrs['nodata'] still surfaces the original sentinel so write round-trips keep the GDAL_NODATA tag intact. Matches rasterio behavior. 8 regression tests in test_nodata_out_of_range_1581.py cover the helper, both eager and dask read paths, in-range sentinel non-regression, and GPU helper (cupy-gated). | Pass 11 (2026-05-10): CLEAN. Audited the one additional commit since pass 10 -- #1559 (PR 1548, Centralise GeoTIFF attrs population across all read backends). Refactor extracts _populate_attrs_from_geo_info helper and routes eager numpy, dask, GPU stripped, GPU tiled read paths through it; before the fix dask only emitted crs/transform/raster_type/nodata while numpy emitted the full attrs set including x/y_resolution, resolution_unit, image_description, extra_samples, GDAL metadata, and the CRS-description fields. No data-path arithmetic touched; only attrs dict population. Windowed origin math (origin_x + c0*pixel_width, origin_y + r0*pixel_height) verified to produce -98.0 / 48.75 origin for window=(10,20,50,70) on a (0.1,-0.125) pixel-size raster, with PixelIsArea half-pixel offset preserved on coord lookups (-97.95, 48.6875). Cross-backend attrs parity re-verified: numpy/dask/cupy all emit identical key set on deflate+predictor3+nodata round-trip (crs, crs_wkt, nodata, transform, x_resolution, y_resolution). Data bit-parity re-verified across numpy/dask/cupy on same payload (np.array_equal with equal_nan=True). test_attrs_parity_1548.py (5 tests), test_reader.py/test_writer.py/test_dask_cupy_combined.py (25 tests), GPU orientation/predictor2-BE/LERC-mask/nodata/byteswap suites (65 tests) all green. No accuracy or backend-divergence findings. | Pass 10 (2026-05-10): CLEAN. Audited 5 recent commits: #1558 drop-defensive-copies (frombuffer path still .copy()s before in-place predictor decode at _reader.py:778), #1556 fp-predictor ngjit (writer pre-ravels so 1-D slice arg is correct, float32/64 LE+BE bit-exact), #1552 batched D2H (OOM guard fires before cupy.concatenate, host_buf offsets correct), #1551 parallel-decode gate (>= vs > sends 256x256 default to parallel path, no value diff confirmed via partial-tile parity), #1549 nvjpeg constants (gray + RGB GPU JPEG decode pixel-identical to Pillow CPU, max diff = 0). Cross-backend parity re-verified clean: numpy/dask+numpy/cupy/dask+cupy equal .data/.dtype/.coords/nodata/NaN-mask on deflate+predictor3+nodata; orientations 1-8 numpy==GPU; partial edge tiles 100x150, 257x383, 512x257 numpy==GPU==dask; predictor2 LE/BE round-trip uint8/int16/uint16/int32/uint32 pass; predictor3 LE/BE float32/64 pass. Deferred LOW (pre-existing, not opened): float16 (bps=16, SampleFormat=3) absent from tiff_dtype_to_numpy map - writer never emits, asymmetric but unreachable. | Pass 9 (2026-05-09): TWO HIGH fixed -- (a) PR #1539 closes #1537: TIFF Orientation tag 2/3/4 (mirror flips) on georeferenced files left y/x coords computed from the un-flipped transform, so xarray label lookups returned the wrong pixel even though _apply_orientation flipped the buffer. PR #1521 only updated the transform for the 5-8 axis-swap branch. Fix updates origin and pixel-scale signs along whichever axes were flipped, for both PixelIsArea (origin shifts by N*step) and PixelIsPoint (shifts by (N-1)*step). 10 new tests in test_orientation.py. (b) PR #1546 closes #1540: read_geotiff_gpu ignored Orientation tag completely; CPU correctly applied 2-8 (PR #1521) but GPU returned the raw stored buffer. Cross-backend disagreement on every non-default orientation. Fix adds _apply_orientation_gpu (cupy slicing mirror of the CPU helper) and _apply_orientation_geo_info, threads them into the tiled GPU pipeline, reuses CPU-fallback geo_info for the stripped path to avoid double-applying. 28 new tests in test_orientation_gpu.py (every orientation, single-band tiled, single-band stripped, 3-band tiled, mirror-flip sel-fidelity, default no-tag passthrough). Re-confirmed clean: HTTP coalesce_ranges with overlapping ranges and zero-length ranges, parallel streaming write thread-safety (each tile gets independent buffer via copy or padded zeros), planar=2 + chunky GPU LERC mask propagation matches CPU, IFD chain cap MAX_IFDS=256, max_z_error round-trip on tiled write, _resolve_masked_fill float vs integer dtype semantics. Deferred LOW: per-sample LERC mask (3D mask (h,w,samples)) collapsed to per-pixel ""any sample invalid"" on GPU while CPU honours per-sample; LERC implementations rarely emit 3D masks (verified: lerc.encode with 2D mask on 3-band returns 2D mask). Documented planar=2 + LERC + GPU silently drops mask (rare in practice, source comment acknowledges). | Pass 8 (2026-05-07): HIGH fixed in fix-jpeg-tiff-disable -- to_geotiff(compression='jpeg') wrote files that no external reader can decode. The writer tags compression=7 (new-style JPEG) but emits a self-contained JFIF stream per tile/strip and never writes the JPEGTables tag (347) that the TIFF spec requires for that codec. libtiff/GDAL/rasterio all reject the file with TIFFReadEncodedStrip() failed; our reader round-trips because Pillow decodes the standalone JFIF, hiding the break. Pass-4 notes flagged the read side of the same JPEGTables gap and deferred it; pass-8 covers the write side. Fix: reject compression='jpeg' at the to_geotiff entry with a clear ValueError pointing at deflate/zstd/lzw. The internal _writer.write is untouched so the existing self-decoding tests still cover the codec; re-enabling the public path needs a JPEGTables-aware encoder. PR diffs reviewed but not merged: #1512 (BytesIO source) and #1513 (LERC max_z_error) -- both look correct; #1512 file-like read path goes through read_all() once so the per-call BytesIOSource lock is theoretical, and #1513 forwards max_z_error through every overview/tile/strip/streaming path including _write_vrt_tiled and _compress_block. No regressions found in either open PR. Other surfaces audited clean: predictor=3 with float16 (writer auto-promotes to float32 on both eager and streaming paths, value-exact round-trip); planar=2 multi-tile read uses band_idx*tiles_per_band offset so no cross-contamination between planes; _header.py multi-byte tag parsing uses bo (byte_order) consistently; Pillow YCbCr-vs-tagged-RGB photometric mismatch becomes moot once JPEG is disabled. Deferred (LOW/MEDIUM, not filed): JPEG2000 writer accepts arbitrary dtype with no validation (rare codec, narrow risk); float16 dtype not in tiff_dtype_to_numpy decode map (writer never emits it - asymmetric but unreachable); Orientation tag (274) still ignored on read (pass-4 deferral). | Pass 7 (2026-05-07): HIGH fixed in fix-mmap-cache-refcount-after-replace -- _MmapCache.release() looked up the cache entry by realpath, so a holder that acquired the OLD mmap before an os.replace and released it AFTER another caller had acquired the post-replace entry would decrement the new holder's refcount. Subsequent eviction (cache full, or another acquire) closed the still-in-use mmap, breaking reads with 'mmap closed or invalid'. Real exposure: any concurrent reader/writer pattern where to_geotiff replaces a file that another reader had just opened via open_geotiff with chunks= or via _FileSource. PR #1506 added stale-replacement detection but did not fix the refcount confusion across the pop. Fix: acquire returns an opaque entry token; release takes the token and decrements that exact entry, regardless of cache state. Orphaned (popped) entries close their fh+mmap when their own refcount hits zero. _FileSource updated to pass the token. Regression test test_release_after_path_replacement_does_not_clobber_new_holder added. All 665 geotiff tests pass; GPU path verified. | Pass 6 (2026-05-07) PR #1507: BE pred2 numba TypingError. | Pass 5 (2026-05-06) PR #1506: mmap cache stale after file replace. | Pass 4 (2026-05-06) PR #1501: sparse COG tiles. | Pass 3 (2026-05-06) PR #1500: predictor=3 byte order. | Pass 2 (2026-05-05) PR #1498: predictor=2 sample-wise. | Pass 1 (2026-04-23) PR #1247. Re-confirmed clean over passes 2-7: items 2 (writer always emits LE TIFFs - hardcoded b'II'), 3 (RowsPerStrip default = height when missing), 4 (StripByteCounts missing raises clear ValueError), 5 (TileWidth without TileLength caught by 'tw <= 0 or th <= 0' check at _reader.py:688), 9 (read determinism on compressed+tiled+multiband), 11 (predictor=2 with awkward sample stride round-trips), 18 (compression_level=99 raises ValueError 'out of range for deflate (valid: 1-9)'), 21 (concurrent writes serialize correctly via mkstemp+os.replace), 24 (uint16 dtype preserved on numpy backend, dask honors chunks param), 26 (chunks rounds correctly with remainder chunk for non-tile-aligned). Deferred: item 8 (BytesIO/file-like sources are not supported, source.lower() error) - documented as 'str' parameter, not a bug; item 19 (LERC max_z_error not user-exposed by to_geotiff) - missing feature, not a bug."
glcm,2026-05-01,1408,HIGH,2,"angle=None averaged NaN as 0, masking no-valid-pairs as zero texture; fixed via nanmean-style averaging"
hillshade,2026-04-10T12:00:00Z,,,,"Horn's method correct. All backends consistent. NaN propagation correct. float32 adequate for [0,1] output."
hydro,2026-04-30,,LOW,1,Only LOW: twi log(0)=-inf if fa=0 (out-of-contract); MFD weighted sum no Kahan (negligible). No CRIT/HIGH issues.
diff --git a/xrspatial/geotiff/_writer.py b/xrspatial/geotiff/_writer.py
index 805eb6da..a2468bd2 100644
--- a/xrspatial/geotiff/_writer.py
+++ b/xrspatial/geotiff/_writer.py
@@ -365,6 +365,28 @@ def _validate_overview_levels(overview_levels, height=None, width=None):
return cleaned
+def _resolve_int_nodata(dtype, nodata):
+ """Return ``int(nodata)`` if it is representable as *dtype*, else None.
+
+ Folds the three checks that gate the integer sentinel-to-NaN mask in
+ :func:`_block_reduce_2d` into one call: ``dtype`` is integer, the
+ sentinel is finite and integer-valued, and the integer fits the
+ dtype range. Out-of-range pairs like ``uint16`` + ``GDAL_NODATA=-9999``
+ return None so the caller stays a no-op rather than tripping
+ ``OverflowError`` on the dtype cast. Mirrors
+ ``_int_nodata_in_range`` in ``_reader.py``.
+ """
+ if nodata is None or dtype.kind not in ('i', 'u'):
+ return None
+ if not np.isfinite(nodata) or not float(nodata).is_integer():
+ return None
+ nodata_int = int(nodata)
+ info = np.iinfo(dtype)
+ if info.min <= nodata_int <= info.max:
+ return nodata_int
+ return None
+
+
def _block_reduce_2d(arr2d, method, nodata=None):
"""2x block-reduce a single 2D plane using *method*.
@@ -385,7 +407,10 @@ def _block_reduce_2d(arr2d, method, nodata=None):
by masking the sentinel to NaN, running cubic with
``prefilter=False`` to keep the kernel local, and rewriting any
NaN in the output back to the sentinel before returning (issue
- #1623).
+ #1623). Cubic on integer dtypes follows the same path via a
+ float64 promotion so NaN can carry through the spline, with a
+ ``np.round(...).astype(arr2d.dtype)`` at the end to keep the cast
+ well-defined (issue #1975).
"""
h, w = arr2d.shape
h2 = (h // 2) * 2
@@ -420,6 +445,11 @@ def _block_reduce_2d(arr2d, method, nodata=None):
# full-resolution band. The ``prefilter=False`` switch only
# fires when a sentinel was actually found in the input, so the
# default cubic semantics still apply to inputs without nodata.
+ #
+ # Integer rasters take the same path via a float64 promotion so
+ # NaN can carry through the spline; the result is rewritten
+ # back to the sentinel and rounded before casting to the source
+ # integer dtype (issue #1975, integer mirror of #1623).
if (nodata is not None
and arr2d.dtype.kind == 'f'
and not np.isnan(nodata)):
@@ -440,6 +470,22 @@ def _block_reduce_2d(arr2d, method, nodata=None):
result = result.copy()
result[nan_mask] = float(nodata)
return result.astype(arr2d.dtype)
+ nodata_int = _resolve_int_nodata(arr2d.dtype, nodata)
+ if nodata_int is not None:
+ sentinel = arr2d.dtype.type(nodata_int)
+ mask = arr2d == sentinel
+ if mask.any():
+ masked = np.where(mask, np.float64('nan'),
+ arr2d.astype(np.float64))
+ with warnings.catch_warnings():
+ warnings.simplefilter('ignore', RuntimeWarning)
+ result = zoom(masked, 0.5, order=3,
+ prefilter=False)
+ nan_mask = np.isnan(result)
+ if nan_mask.any():
+ result = np.where(nan_mask, float(nodata_int),
+ result)
+ return np.round(result).astype(arr2d.dtype)
return zoom(arr2d, 0.5, order=3).astype(arr2d.dtype)
if method == 'mode':
@@ -491,22 +537,17 @@ def _block_reduce_2d(arr2d, method, nodata=None):
# ``_reader.py``) so an out-of-range sentinel pair like
# ``uint16`` + ``GDAL_NODATA="-9999"`` stays a no-op rather than
# tripping ``OverflowError`` on the dtype cast.
- if (nodata is not None
- and np.isfinite(nodata)
- and float(nodata).is_integer()):
- nodata_int = int(nodata)
- info = np.iinfo(arr2d.dtype)
- if info.min <= nodata_int <= info.max:
- sentinel = arr2d.dtype.type(nodata_int)
- # Compare against the original integer block view so
- # the equality runs at the integer's native width
- # (avoids any float-cast rounding on adjacent values).
- # The boolean mask broadcasts into the float64 block
- # layout below.
- int_blocks = cropped.reshape(oh, 2, ow, 2)
- mask = int_blocks == sentinel
- if mask.any():
- blocks = np.where(mask, np.float64('nan'), blocks)
+ nodata_int = _resolve_int_nodata(arr2d.dtype, nodata)
+ if nodata_int is not None:
+ sentinel = arr2d.dtype.type(nodata_int)
+ # Compare against the original integer block view so the
+ # equality runs at the integer's native width (avoids any
+ # float-cast rounding on adjacent values). The boolean
+ # mask broadcasts into the float64 block layout below.
+ int_blocks = cropped.reshape(oh, 2, ow, 2)
+ mask = int_blocks == sentinel
+ if mask.any():
+ blocks = np.where(mask, np.float64('nan'), blocks)
# nanmean / nanmin / nanmax / nanmedian emit RuntimeWarning when a
# 2x2 block is all-NaN (typical at nodata borders). The all-NaN
@@ -538,14 +579,11 @@ def _block_reduce_2d(arr2d, method, nodata=None):
# post-overview rewrite in ``write()``; integer dtypes skip that
# branch because ``current.dtype.kind == 'f'`` is False, so we
# close the loop here.
- if nodata is not None and np.isfinite(nodata):
- nan_mask = np.isnan(result)
- if nan_mask.any():
- info = np.iinfo(arr2d.dtype)
- nodata_int = int(nodata) if float(nodata).is_integer() else None
- if (nodata_int is not None
- and info.min <= nodata_int <= info.max):
- result = np.where(nan_mask, float(nodata_int), result)
+ nan_mask = np.isnan(result)
+ if nan_mask.any():
+ nodata_int = _resolve_int_nodata(arr2d.dtype, nodata)
+ if nodata_int is not None:
+ result = np.where(nan_mask, float(nodata_int), result)
return np.round(result).astype(arr2d.dtype)
return result.astype(arr2d.dtype)
diff --git a/xrspatial/geotiff/tests/test_cog_cubic_int_overview_nodata_1975.py b/xrspatial/geotiff/tests/test_cog_cubic_int_overview_nodata_1975.py
new file mode 100644
index 00000000..872b3ba6
--- /dev/null
+++ b/xrspatial/geotiff/tests/test_cog_cubic_int_overview_nodata_1975.py
@@ -0,0 +1,239 @@
+"""Regression tests for issue #1975.
+
+``to_geotiff(data, cog=True, overview_resampling='cubic', nodata=N)`` on
+**integer** rasters with a finite nodata sentinel used to produce severe
+ringing artifacts in the overview pyramid near the nodata border.
+
+Root cause: ``_block_reduce_2d``'s cubic branch masked the sentinel to
+NaN only when the input dtype was float (``arr2d.dtype.kind == 'f'``).
+For integer rasters the function fell through to an unmasked
+``zoom(arr2d, 0.5, order=3)``, and the bicubic spline blended the
+sentinel value (e.g. -9999) into neighbouring valid cells. Cast back
+to the integer dtype, the boundary pixels surfaced as silent garbage
+(values like 1082 / 1134 / -11104 against actual data of 100 with
+sentinel -9999).
+
+The fix mirrors the float branch:
+
+1. Promote the cropped block to float64 so NaN can survive the spline.
+2. Mask the sentinel to NaN before ``zoom(... prefilter=False)`` so the
+ interpolation does not treat it as signal and a single NaN does not
+ poison the entire row/column.
+3. Rewrite NaN back to the sentinel after the spline.
+4. ``np.round(...).astype(arr2d.dtype)`` so the integer cast is
+ well-defined (mirrors the mean/min/max/median integer tail).
+"""
+from __future__ import annotations
+
+import numpy as np
+import pytest
+import xarray as xr
+
+from xrspatial.geotiff import open_geotiff, to_geotiff
+from xrspatial.geotiff._writer import _block_reduce_2d
+
+
+# ---------------------------------------------------------------------------
+# Helper-level: _block_reduce_2d cubic + integer + sentinel
+# ---------------------------------------------------------------------------
+
+def _make_block_with_nodata_corner(dtype, nodata_value, size=64,
+ corner=16, fill=100):
+ """Return a (size, size) ``dtype`` array with a corner of nodata."""
+ arr = np.full((size, size), fill, dtype=dtype)
+ arr[:corner, :corner] = nodata_value
+ return arr
+
+
+def test_cubic_int16_with_nodata_does_not_poison_overview():
+ """int16 + finite sentinel: cubic overview must not blend sentinel."""
+ arr = _make_block_with_nodata_corner(np.int16, -9999)
+ result = _block_reduce_2d(arr, method='cubic', nodata=-9999)
+ # Finite (non-sentinel) values must lie within the source data range.
+ # Pre-fix the boundary surfaced values like 1082 / 1134 / -11104.
+ finite_non_sentinel = result[result != -9999]
+ assert finite_non_sentinel.size > 0
+ assert finite_non_sentinel.max() <= 100
+ assert finite_non_sentinel.min() >= 100 # only valid data value is 100
+ # The output dtype is the input dtype.
+ assert result.dtype == np.int16
+ # Result shape is half (size/2, size/2).
+ assert result.shape == (32, 32)
+
+
+def test_cubic_uint16_with_nodata_does_not_poison_overview():
+ """uint16 + finite sentinel: same guarantee as int16."""
+ arr = _make_block_with_nodata_corner(np.uint16, 65535, fill=200)
+ result = _block_reduce_2d(arr, method='cubic', nodata=65535)
+ finite = result[result != 65535]
+ assert finite.size > 0
+ assert finite.min() >= 200
+ assert finite.max() <= 200
+ assert result.dtype == np.uint16
+
+
+def test_cubic_int32_with_nodata_does_not_poison_overview():
+ """int32 + negative sentinel: same guarantee."""
+ arr = _make_block_with_nodata_corner(np.int32, -2147483648, fill=42)
+ result = _block_reduce_2d(arr, method='cubic', nodata=-2147483648)
+ finite = result[result != -2147483648]
+ assert finite.size > 0
+ assert finite.min() >= 42
+ assert finite.max() <= 42
+ assert result.dtype == np.int32
+
+
+def test_cubic_int_no_nodata_unchanged():
+ """Cubic on integer without nodata still runs the plain zoom path."""
+ arr = np.arange(64 * 64, dtype=np.int16).reshape(64, 64)
+ result_no_nd = _block_reduce_2d(arr, method='cubic', nodata=None)
+ # Plain zoom path: dtype preserved, shape halved.
+ assert result_no_nd.dtype == np.int16
+ assert result_no_nd.shape == (32, 32)
+
+
+def test_cubic_int_nodata_out_of_range_noop():
+ """Sentinel outside the dtype range cannot equal any pixel — no-op."""
+ arr = np.full((64, 64), 100, dtype=np.uint16)
+ # -1 cannot exist in uint16; the guard skips the masking branch.
+ result = _block_reduce_2d(arr, method='cubic', nodata=-1)
+ # Falls through to plain zoom path; values stay 100 (cubic on constant).
+ assert result.dtype == np.uint16
+ # Cubic of a constant grid is the same constant.
+ assert np.all(result == 100)
+
+
+def test_cubic_int_nodata_fractional_noop():
+ """Fractional sentinel on integer dtype: no-op (cannot match any pixel)."""
+ arr = np.full((64, 64), 100, dtype=np.int16)
+ result = _block_reduce_2d(arr, method='cubic', nodata=1.5)
+ assert result.dtype == np.int16
+ assert np.all(result == 100)
+
+
+def test_cubic_int_all_sentinel_block_becomes_sentinel():
+ """A 2x2 block that is entirely the sentinel rounds back to the sentinel."""
+ arr = np.full((4, 4), -9999, dtype=np.int16)
+ result = _block_reduce_2d(arr, method='cubic', nodata=-9999)
+ assert result.dtype == np.int16
+ assert np.all(result == -9999)
+
+
+def test_cubic_float_branch_still_works():
+ """Float regression guard: the existing #1623 path must still work."""
+ arr = np.full((64, 64), 100.0, dtype=np.float32)
+ arr[:16, :16] = -9999.0
+ result = _block_reduce_2d(arr, method='cubic', nodata=-9999.0)
+ assert result.dtype == np.float32
+ finite = result[result != -9999.0]
+ assert finite.size > 0
+ # No ringing: all valid output pixels are 100 (constant input region).
+ np.testing.assert_allclose(finite, 100.0, atol=1e-3)
+
+
+# ---------------------------------------------------------------------------
+# End-to-end: to_geotiff cubic + integer + nodata round-trip
+# ---------------------------------------------------------------------------
+
+def test_to_geotiff_int_cubic_overview_round_trip(tmp_path):
+ """1024x1024 int16 + cog + cubic + nodata round-trips without poisoning."""
+ data = np.full((1024, 1024), 100, dtype=np.int16)
+ data[:256, :256] = -9999
+ da = xr.DataArray(
+ data, dims=('y', 'x'),
+ coords={'y': np.arange(1024.0), 'x': np.arange(1024.0)},
+ )
+ path = tmp_path / "cubic_int_1975.tif"
+ to_geotiff(da, str(path), cog=True, overview_resampling='cubic',
+ nodata=-9999, crs=4326)
+ # Level 0: full resolution.
+ r0 = open_geotiff(str(path), overview_level=0)
+ uniq_0 = set(np.unique(r0.values[~np.isnan(r0.values)]))
+ assert uniq_0 == {100.0}
+ # Level 1: the historically poisoned level.
+ r1 = open_geotiff(str(path), overview_level=1)
+ finite_1 = r1.values[~np.isnan(r1.values)]
+ # All finite values must be 100 (the only valid data value); no ringing.
+ np.testing.assert_array_equal(finite_1, 100.0)
+
+
+def test_to_geotiff_int_cubic_no_nodata_regression(tmp_path):
+ """int16 + cog + cubic without nodata: cubic still runs (regression)."""
+ rng = np.random.default_rng(0)
+ data = rng.integers(0, 1000, size=(1024, 1024), dtype=np.int16)
+ da = xr.DataArray(
+ data, dims=('y', 'x'),
+ coords={'y': np.arange(1024.0), 'x': np.arange(1024.0)},
+ )
+ path = tmp_path / "cubic_int_no_nd_1975.tif"
+ to_geotiff(da, str(path), cog=True, overview_resampling='cubic',
+ crs=4326)
+ r1 = open_geotiff(str(path), overview_level=1)
+ # Output dtype is the source integer dtype.
+ assert r1.values.dtype == np.int16
+ assert r1.shape == (512, 512)
+
+
+def test_to_geotiff_int_cubic_overview_matches_mean_finite_range(tmp_path):
+ """Cubic must agree with mean on which pixels are finite vs nodata."""
+ data = np.full((512, 512), 50, dtype=np.uint16)
+ data[:128, :128] = 65535
+ da = xr.DataArray(
+ data, dims=('y', 'x'),
+ coords={'y': np.arange(512.0), 'x': np.arange(512.0)},
+ )
+ cubic_path = tmp_path / "cubic.tif"
+ mean_path = tmp_path / "mean.tif"
+ to_geotiff(da, str(cubic_path), cog=True, overview_resampling='cubic',
+ nodata=65535, crs=4326)
+ to_geotiff(da, str(mean_path), cog=True, overview_resampling='mean',
+ nodata=65535, crs=4326)
+ r_cubic = open_geotiff(str(cubic_path), overview_level=0)
+ r_mean = open_geotiff(str(mean_path), overview_level=0)
+ # Sentinel masks should land on the same pixels for both methods on a
+ # constant valid region with a constant nodata corner.
+ np.testing.assert_array_equal(
+ np.isnan(r_cubic.values), np.isnan(r_mean.values),
+ )
+ finite_cubic = r_cubic.values[~np.isnan(r_cubic.values)]
+ finite_mean = r_mean.values[~np.isnan(r_mean.values)]
+ # All valid pixels are 50 in both.
+ np.testing.assert_array_equal(finite_cubic, 50.0)
+ np.testing.assert_array_equal(finite_mean, 50.0)
+
+
+def test_gpu_int_cubic_overview_matches_cpu(tmp_path):
+ """GPU writer cubic falls back to CPU; bytes must match CPU writer."""
+ cupy = pytest.importorskip("cupy")
+ if not cupy.cuda.is_available():
+ pytest.skip("CUDA not available")
+
+ data = np.full((1024, 1024), 100, dtype=np.int16)
+ data[:256, :256] = -9999
+ cpu_da = xr.DataArray(
+ data, dims=('y', 'x'),
+ coords={'y': np.arange(1024.0), 'x': np.arange(1024.0)},
+ )
+ gpu_da = xr.DataArray(
+ cupy.asarray(data), dims=('y', 'x'),
+ coords={'y': np.arange(1024.0), 'x': np.arange(1024.0)},
+ )
+ cpu_path = tmp_path / "cpu.tif"
+ gpu_path = tmp_path / "gpu.tif"
+ to_geotiff(cpu_da, str(cpu_path), cog=True, overview_resampling='cubic',
+ nodata=-9999, crs=4326)
+ to_geotiff(gpu_da, str(gpu_path), cog=True, overview_resampling='cubic',
+ nodata=-9999, crs=4326)
+ cpu_r1 = open_geotiff(str(cpu_path), overview_level=1)
+ gpu_r1 = open_geotiff(str(gpu_path), overview_level=1)
+ # Both paths route cubic through the same CPU helper; results must agree
+ # bit-for-bit on this constant input.
+ cpu_arr = cpu_r1.values
+ gpu_arr = gpu_r1.values
+ assert cpu_arr.shape == gpu_arr.shape
+ np.testing.assert_array_equal(
+ np.isnan(cpu_arr), np.isnan(gpu_arr),
+ )
+ np.testing.assert_array_equal(
+ cpu_arr[~np.isnan(cpu_arr)], gpu_arr[~np.isnan(gpu_arr)],
+ )