diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 2381bf6a0..3fe8089af 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -3,5 +3,5 @@ geotiff,2026-05-18,,HIGH,3;4,"Pass 18 (2026-05-18): added test_parallel_strip_de Pass 17 (2026-05-18): added test_mask_nodata_gpu_vrt_2052.py closing Cat 1 HIGH backend-coverage gap on the mask_nodata= opt-out kwarg (#2052). The kwarg was added in #2052 and wired through the four public readers (open_geotiff, read_geotiff_gpu, read_geotiff_dask, read_vrt), but test_mask_nodata_kwarg_2052.py only exercised the eager-numpy and dask+numpy branches. The pure-GPU mask gating at _backends/gpu.py:709, the dask+GPU dispatcher forwarding at _backends/gpu.py:991, the eager VRT mask gating at _backends/vrt.py:320, and the chunked VRT graph builder at _backends/vrt.py:408/588 had zero direct coverage. 19 new tests, all passing on GPU host: GPU eager + dask+GPU mask_nodata=False preserves uint16, GPU defaults still promote to float64, dispatcher thread-through for open_geotiff(gpu=True, mask_nodata=False) and open_geotiff(gpu=True, chunks=N, mask_nodata=False), VRT eager and chunked branches mirror, cross-backend parity (eager vs dask, eager vs GPU, eager vs dask+GPU, eager vs VRT) bit-exact under mask_nodata=False, direct read_geotiff_dask entry-point coverage. Fixture uses tiled+deflate compression so the pure nvCOMP decode path is exercised, not the CPU-fallback piggyback path. Mutation against gpu.py:709 (force mask_nodata=True) flipped 4 GPU tests red; mutation against vrt.py eager mask gate flipped 4 VRT tests red. Cat 1 HIGH (backend coverage on mask_nodata=False for GPU, dask+GPU, VRT eager, VRT chunked). Pass 16 (2026-05-15): added test_max_cloud_bytes_dispatcher_silent_drop_2026_05_15.py closing Cat 4 HIGH parameter-coverage gap on the open_geotiff dispatcher's max_cloud_bytes kwarg. The kwarg was added in #1928 (eager fsspec budget) and re-ordered into the canonical reader signature by #1957, but open_geotiff only forwards it to _read_to_array on the eager non-VRT branch (__init__.py:431). The GPU branch at line 410, the dask branch at line 422, and the VRT branch at line 362 never reference the kwarg, so open_geotiff(p, max_cloud_bytes=8, gpu=True) / open_geotiff(p, max_cloud_bytes=8, chunks=N) / open_geotiff(vrt, max_cloud_bytes=8) all silently drop the budget. Same class of dispatcher-silently-drops-backend-kwarg bug fixed by #1561 / #1605 / #1685 / #1810 for other kwargs; the two sibling kwargs on_gpu_failure (line 339) and missing_sources (line 355) already raise ValueError when used on a path where they do not apply. 11 tests: 4 xfail(strict=True) pinning the fix surface (gpu, dask, vrt, dask+gpu), 3 passing pins on the current silent-drop behaviour so the fix is visible as a diff, 4 positive pins that the eager local + file-like paths accept the kwarg (docstring no-op contract). Filed issue #1974 for the dispatcher fix (sweep is test-only). Cat 4 HIGH (silent backend-kwarg drop). Pass 15 (2026-05-15): added test_write_vrt_bool_nodata_1921.py closing Cat 1 HIGH backend-parity gap on bool nodata rejection. Issue #1911 added the isinstance(nodata, (bool, np.bool_)) -> TypeError guard at to_geotiff and build_geo_tags, but the sibling writers were left unchecked: write_vrt(nodata=True) silently emits True into the VRT XML (str(True) drops the sentinel because no reader parses 'True' as numeric); write_geotiff_gpu direct call relies on the build_geo_tags defense-in-depth rather than an entry-point check, so a future refactor moving that guard would regress the GPU writer with no test coverage. 17 new tests: 4 xfail (strict=True) pinning the write_vrt fix surface (issue #1921), 1 passing pin on the current buggy str(True) emission so the fix is visible as a diff, 6 numeric/None happy-path tests on write_vrt, 4 GPU writer direct-call bool-reject tests (4 dtypes x 1 call), 1 to_geotiff(gpu=True) dispatcher thread-through. Filed issue #1921 for the write_vrt fix (sweep is test-only). Cat 1 HIGH (write_vrt backend parity bug) + Cat 1 MEDIUM (write_geotiff_gpu defense-in-depth pin). Pass 14 (2026-05-15): added test_dask_streaming_write_degenerate_2026_05_15.py closing Cat 3 HIGH and Cat 2 HIGH/MEDIUM gaps on the dask streaming write path (to_geotiff with dask-backed DataArray, #1084). test_streaming_write.py covered 100x100 with a NaN block plus a 2x2 small raster but had nothing 1-pixel-row, 1-pixel-column, all-NaN, all-Inf, or +/-Inf-mixed. The streaming tile-row segmenter (#1485) on a 1-pixel-tall raster and the streaming nodata-mask coercion on an all-NaN chunk were reachable only with a dask input and had no direct coverage; a regression on either would not surface from the eager numpy path or the write_geotiff_gpu path (pass 5 covered the GPU writer's degenerate shapes). 16 new tests, all passing: 1x1 chunk-matches-shape + nodata-attr round-trip + uint16, 1xN single chunk + chunks-split-columns + wide-segmented-by-buffer (#1485 streaming_buffer_bytes=1 forces the segmenter), Nx1 single chunk + chunks-split-rows, all-NaN with finite sentinel + all-NaN without sentinel, mixed NaN/+Inf/-Inf preserving Inf bit-exact + sentinel masking NaN only, all-+Inf and all--Inf, predictor=3 (float predictor) round-trip on float32 + float64 plus int-dtype ValueError. predictor=3 streaming coverage extends the small-chunk and int-rejection geometry around test_predictor_fp_write_1313.test_predictor3_streaming_dask (which already covers a 128x192 predictor=3 dask streaming write with a Predictor-tag assertion). Cat 3 HIGH (1x1/1xN/Nx1) + Cat 2 HIGH (all-NaN with sentinel) + Cat 2 MEDIUM (mixed-Inf, all-Inf) + Cat 4 MEDIUM (predictor=3 streaming). Pass 13 (2026-05-13): added test_size_param_validation_gpu_vrt_1776.py closing Cat 4 HIGH parameter-coverage gap on size-arg validation. Issue #1752 added tile_size validation to to_geotiff and chunks validation to read_geotiff_dask, but the matching kwargs on three sibling entry points were left unchecked: write_geotiff_gpu(tile_size=) raised ZeroDivisionError for 0, struct.error for -1, TypeError for 256.0; read_geotiff_gpu(chunks=) and read_vrt(chunks=) raised ZeroDivisionError for 0 and silently accepted negative values. Factored two shared validators (_validate_tile_size_arg, _validate_chunks_arg) and called them up front from each entry point. 34 new tests, all passing on GPU host: tile_size matrix on write_geotiff_gpu (0/-1/256.0/True/False/positive/np.int64), chunks matrix on read_geotiff_gpu and read_vrt (0/-1/(0,N)/(N,-1)/wrong-length/bool/non-int/(N,float)/positive/np.int64), dispatcher thread-through tests (open_geotiff(gpu=True, chunks=0), to_geotiff(gpu=True, tile_size=0)). Pre-existing 13 #1752 tests still pass after refactor. Filed issue #1776. Pass 12 (2026-05-12): added test_gpu_writer_overview_mode_and_compression_level_1740.py closing Cat 4 HIGH and Cat 4 MEDIUM parameter-coverage gaps. (1) write_geotiff_gpu(overview_resampling='mode') and the dedicated _block_reduce_2d_gpu mode-fallback branch (_gpu_decode.py:3051-3056) had zero direct tests; six of the seven overview_resampling modes were covered (mean/nearest by test_features, min/max/median by pass 6, cubic by test_signature_parity_1631) but mode was the odd one out -- a regression dropping the mode dispatch from _block_reduce_2d_gpu would fall through to the mean reshape branch and emit wrong overview pixels for integer rasters. (2) write_geotiff_gpu(compression_level=) documented as accepted-but-ignored had no test; the CPU writer rejects out-of-range levels with ValueError, the GPU writer is documented not to -- a regression wiring the GPU writer up to the CPU range validator would silently break every to_geotiff(gpu=True, compression_level=X) caller for in-range levels and noisily for out-of-range. 19 tests, all passing on GPU host: _block_reduce_2d_gpu(method='mode') CPU-parity on 4x4 deterministic + random 8x8 + dtype-preserved across u8/u16/i16/i32, write_geotiff_gpu(cog=True, overview_resampling='mode') end-to-end round trip, to_geotiff(gpu=True, ..., overview_resampling='mode') dispatcher thread-through, GPU-vs-CPU pixel parity on 8x8 input, write_geotiff_gpu(compression_level=) in-range matrix on zstd/deflate, out-of-range matrix (zstd=999/-5, deflate=50/0) accepted without raising + round-trip preserved, to_geotiff(gpu=True, compression_level=999) dispatcher thread-through, companion CPU rejects-OOR pin to lock the asymmetry. Mutation against the mode branch (drop the 'if method == mode' block in _block_reduce_2d_gpu) flipped 9 mode tests red. Filed issue #1740. Pass 11 (2026-05-12): added test_gpu_writer_cpu_fallback_codecs_2026_05_12.py closing a Cat 4 HIGH parameter-coverage gap on write_geotiff_gpu compression= modes for the CPU-fallback codecs (lzw, packbits, lz4, lerc, jpeg2000/j2k). Pass 7 (test_gpu_writer_compression_modes_2026_05_11) covered only none/deflate/zstd/jpeg; the remaining five codecs route through dedicated branches in gpu_compress_tiles (_gpu_decode.py:2974-3019) with CPU fallbacks (lerc_compress, jpeg2000_compress, cpu_compress) that had zero direct tests via write_geotiff_gpu. A regression in routing/tag-wiring/fallback dispatch would ship silently because the internal reader uses the same compression-tag table. 17 tests, all passing on GPU host: lzw/packbits/lz4 round-trip + compression-tag pin on uint16, lerc lossless float32 + uint16 round-trip + tag pin, jpeg2000 uint8 single-band + RGB multi-band lossless round-trip + j2k-alias parity + tag pin, GPU-vs-CPU writer pixel parity for lzw/packbits, to_geotiff(gpu=True, compression=lzw/packbits) dispatcher thread-through. Mutation against compression dispatch (swap lzw bytes to zstd; swap lerc bytes to deflate) flipped round-trip tests red. Filed issue #1706. Pass 10 (2026-05-12): added test_kwarg_behaviour_2026_05_12_v2.py closing two Cat 4 HIGH parameter-coverage gaps. (1) write_geotiff_gpu(predictor=True/2/3) had zero direct tests; the GPU writer threads predictor= through normalize_predictor and gpu_compress_tiles into five CUDA encode kernels (_predictor_encode_kernel_u8/u16/u32/u64 for predictor=2, _fp_predictor_encode_kernel for predictor=3) and a regression dropping the encode-kernel calls would ship corrupt files. (2) read_vrt(window=) had no behaviour tests (only a signature pin in test_signature_annotations_1654); the kwarg is documented and _vrt.read_vrt implements full windowed-read semantics (clip, multi-source overlap, src/dst scaling, GeoTransform origin shift on coords + attrs['transform']). 23 tests, all passing on GPU host: predictor=True/2 round-trips on u8/u16/i32 + 3-band RGB samples_per_pixel stride; predictor=3 lossless round-trip on f32 and f64; predictor=3 int-dtype ValueError (CPU/GPU parity); CPU/GPU pixel-exact parity for pred=2 u16 and pred=3 f32; read_vrt(window=) subregion + full + clamp-overflow + clamp-negative + 2x1 mosaic seam straddle + offset past seam + transform-attr origin shift + y/x coords half-pixel shift + window+band + window+chunks (dask) + window+gpu (cupy) + window+gpu+chunks (dask+cupy). Mutation against the encode dispatch flipped 7 predictor tests red. Filed issue #1690. Pass 9 (2026-05-12): added test_kwarg_behaviour_2026_05_12.py closing three Cat 4 MEDIUM parameter-coverage gaps plus one Cat 4 LOW error path. write_vrt documented kwargs (relative/crs_wkt/nodata) had a smoke-test pinning that the kwargs are accepted but no test verified the override *effect* -- a regression dropping the override branch and silently using the default-from-first-source would ship undetected. read_geotiff_gpu(dtype=) cast had zero direct tests; the eager path has TestDtypeEager and dask has TestDtypeDask but the GPU branch had no equivalent. write_geotiff_gpu(bigtiff=) threads through to _assemble_tiff(force_bigtiff=) but no test asserted the on-disk header byte switches; the CPU writer had it via test_features::test_force_bigtiff_via_public_api. write_vrt(source_files=[]) ValueError was uncovered. 26 tests, all passing on GPU host: write_vrt relative=True/False XML attribute + path inspection + parse-back round-trip, write_vrt crs_wkt= override distinct-from-default XML check, write_vrt nodata= override + default-from-source coverage, write_vrt([]) ValueError + no-file side effect, read_geotiff_gpu dtype= matrix (float64->float32, float64->float16, uint16->int32, uint16->uint8, float-to-int raise, dtype=None preserves native), open_geotiff(gpu=True, dtype=) dispatcher, read_geotiff_gpu(chunks=, dtype=) dask+GPU branch, write_geotiff_gpu bigtiff=True/False/None header verification, to_geotiff(gpu=True, bigtiff=True) dispatcher thread-through. Pass 8 (2026-05-11): added test_lz4_compression_level_2026_05_11.py closing Cat 4 MEDIUM parameter-coverage gap on compression='lz4' + compression_level=. _LEVEL_RANGES advertises lz4: (0, 16) but only deflate (1, 9) and zstd (1, 22) had direct level boundary + round-trip + reject tests. The range check is the gatekeeper -- lz4_compress silently accepts any int level -- so a regression dropping 'lz4' from _LEVEL_RANGES would ship undetected. 18 tests, all passing: round-trip at levels 0/1/9/16 (lossless), default-level no-arg path, higher-level-not-larger smoke check on compressible input, out-of-range reject at -1/-10/17/100 on eager path, valid-range message format pin (lz4 valid: 0-16), dask streaming round-trip at 0/1/8/16, dask streaming out-of-range reject at -1/17/50 (separate _LEVEL_RANGES call site). Pass 7 (2026-05-11): added test_gpu_writer_compression_modes_2026_05_11.py closing Cat 4 HIGH gap on write_geotiff_gpu compression= modes. The writer documents zstd (default, fastest GPU), deflate, jpeg, and none, but only deflate + none had round-trip tests; the default zstd and the jpeg (nvJPEG/Pillow) paths shipped without targeted coverage. 11 new tests, all passing on GPU host: zstd round-trip + default-codec pinning, jpeg round-trip on 3-band RGB uint8 + 1-band greyscale, TIFF compression-tag header check across none/deflate/zstd/jpeg, plain deflate + none round-trips outside the COG/sentinel paths, and a cross-codec lossless parity check (zstd/deflate/none agree pixel-exact). nvJPEG path was exercised live, not just the Pillow fallback. Pass 6 (2026-05-11): added test_overview_resampling_min_max_median_2026_05_11.py covering Cat 4 HIGH parameter-coverage gap on overview_resampling=min/max/median. CPU end-to-end paths were already covered by test_cog_overview_nodata_1613::test_cpu_cog_overview_aggregations_ignore_sentinel; the GPU end-to-end paths and the direct CPU+GPU block-reducer branches had no targeted tests, so a regression on those code paths would ship undetected. 26 tests, all passing on GPU host: block-reducer unit tests (finite + partial-NaN), end-to-end COG writes for both to_geotiff and write_geotiff_gpu, CPU/GPU parity for to_geotiff(gpu=True), CPU nodata-sentinel regression check, and ValueError error-path tests for unknown method names on both backends. Pass 5 (2026-05-11): added test_degenerate_shapes_backends_2026_05_11.py covering Cat 3 HIGH geometric gaps (1x1 / 1xN / Nx1 reads on dask+numpy, GPU, dask+cupy backends; 1x1 / 1xN / Nx1 writes through write_geotiff_gpu) and Cat 2 MEDIUM NaN/Inf gaps (all-NaN read on GPU + dask+cupy, Inf / -Inf reads on all non-eager backends, NaN sentinel mask on dask read path including sentinel block split across chunk boundary). 23 tests, all passing on GPU host. Prior passes still hold: pass 4 (r4) closed read_geotiff_gpu/dask name= + max_pixels= kwargs (Cat 4), pass 3 (r3) closed read_vrt GPU/dask+GPU backend dispatch (Cat 1) and dtype/name kwargs (Cat 4)." polygonize,2026-05-19,2155,HIGH,1;2;3;4,"Pass 1 (2026-05-19): added test_polygonize_coverage_2026_05_19.py with 58 tests, all passing on a CUDA host. Closes Cat 3 HIGH 1x1 / Nx1 single-column geometric gaps (Nx1 exercises the nx==1 padding path at polygonize.py:565 and the cupy nx==1 numpy-fallback at polygonize.py:671), Cat 3 MEDIUM 1xN single-row and all-equal-value rasters on all four backends. Closes Cat 2 HIGH NaN parity for cupy + dask+cupy (numpy/dask were already covered by test_polygonize_nan_pixels_excluded*), Cat 2 MEDIUM all-NaN raster on all four backends, Cat 2 HIGH +/-Inf pins on all four backends. Filed source-bug issue #2155: numpy/dask/dask+cupy backends silently absorb Inf cells into adjacent finite polygons because _is_close reduces abs(inf-inf) to nan; cupy backend handles Inf correctly. Pins lock the asymmetric behaviour so the fix is visible. Closes Cat 1 MEDIUM simplify_tolerance + mask= parity gaps on dask+cupy backend (numpy/cupy/dask were already covered). Closes Cat 4 MEDIUM column_name non-default value across geopandas/spatialpandas/geojson return types and Cat 4 MEDIUM validation error paths (bad connectivity, bad transform length, mask shape mismatch, mask underlying-type mismatch). Cat 5 N/A: polygonize returns lists/dataframes, not a DataArray with attrs to propagate." -rasterize,2026-05-17,,HIGH,1;3;4,"Pass 1 (2026-05-17): added test_rasterize_coverage_2026_05_17.py with 34 tests, all passing on a CUDA host. Closes four documented public-API gaps left after the pass-0 audit. (1) Cat 3 HIGH 1x1 single-pixel raster -- test_rasterize.py covers 1xN strips and Nx1 strips but never width=1 AND height=1, so the polygon scanline / line Bresenham / point burn kernels all ship without the single-cell degenerate case; the new TestSinglePixelRaster class pins polygon/point/line on eager numpy plus polygon parity across cupy / dask+numpy / dask+cupy. (2) Cat 4 HIGH like= template-raster parameter is documented at rasterize.py:2038 and implemented by _extract_grid_from_like (line 1930) but no test exercises it; TestLikeParameter pins dtype/bounds/coords inheritance, the three override branches (dtype, bounds, width/height), the three validation branches (not-DataArray, 3D, wrong dim names) and like= on all four backends. Mutation against the like-dtype branch (rasterize.py:2183-2184) flipped the inheritance test red. (3) Cat 4 HIGH resolution= happy path -- only the oversize-rejection error path was tested (line 304); TestResolutionParameter pins the scalar branch, the tuple branch, the ceil-and-clamp-to-1 semantics, and resolution= on all four backends. (4) Cat 4 HIGH non-empty GeometryCollection unpacking is documented at rasterize.py:1995 and implemented by _classify_geometries_loop (line 228) but only the empty-GC case was tested (line 269); TestGeometryCollection pins polygon+point and polygon+line+point collections on eager numpy plus parity across cupy / dask+numpy / dask+cupy so the loop classifier's polygon/line/point sub-bucketing has direct coverage. Cat 1 MEDIUM gap closed: eager cupy all_touched=True parity vs eager numpy (TestEagerCupyAllTouched) -- the existing test only covered dask+cupy all_touched, leaving the direct GPU all_touched kernel untested. Cat 2 MEDIUM gap closed: int32 dtype with default NaN fill silently casts to the int32-min sentinel (TestIntegerDtypeNanFill) -- pin the cast so any future ValueError-raises switch is visible as a code-review diff. Pre-existing 143 passing + 2 skipped tests in test_rasterize.py untouched." +rasterize,2026-05-21,2255,HIGH,1;2;3,"Pass 2 (2026-05-21): added test_rasterize_coverage_2026_05_21.py with 58 tests, all passing on a CUDA host. Closes Cat 2 HIGH +/-Inf and NaN burn-value gaps that pass-1 left untouched: pin +Inf / -Inf / Inf+(-Inf)/NaN polygon, point, and line burn behaviour across numpy / cupy / dask+numpy / dask+cupy, plus Inf+finite under sum stays Inf, Inf+(-Inf) under sum collapses to NaN, min(Inf, 1.0) and max(-Inf, 1.0) pick the finite value, and Inf-as-bound is rejected with the same ValueError as NaN-as-bound (pass-1 only tested the NaN-bound rejection). Closes Cat 1 MEDIUM nested GeometryCollection on all four backends: a GC inside a GC has no direct test today even though rasterize.py:1995 documents recursive unpacking, and the deeply-nested-3-levels eager test pins the recursion depth limit isn't 1 or 2. Closes Cat 1 MEDIUM columns= (multi-column) parity on cupy and dask+cupy (TestMultiColumn covered numpy/dask+numpy only); pin three columns of props on GPU so the (N, P) loop survives the kernel boundary. Closes Cat 3 LOW rectangular-pixel parity with resolution=(rx, ry) across backends. Filed source-bug issue #2255: GPU max/min merge silently suppresses NaN burn values -- CPU returns NaN (1.0 > NaN is False, keeps NaN); GPU returns 1.0 because the kernel inits the output buffer to -inf for max (or +inf for min) and atomicMax/Min is NaN-suppressing under IEEE device semantics. Pinned both the CPU NaN-propagating behaviour and the GPU NaN-suppressing behaviour as paired tests (test_nan_burn_overlaps_max_cpu_propagates vs test_nan_burn_overlaps_max_gpu_suppresses_nan, plus test_nan_burn_single_geom_max_gpu_returns_neg_inf for the single-write-on-GPU-returns-buffer-init case) so the divergence is visible in CI until the GPU kernels are aligned. Source untouched. Pass 1 (2026-05-17): added test_rasterize_coverage_2026_05_17.py with 34 tests, all passing on a CUDA host. Closes four documented public-API gaps left after the pass-0 audit. (1) Cat 3 HIGH 1x1 single-pixel raster -- test_rasterize.py covers 1xN strips and Nx1 strips but never width=1 AND height=1, so the polygon scanline / line Bresenham / point burn kernels all ship without the single-cell degenerate case; the new TestSinglePixelRaster class pins polygon/point/line on eager numpy plus polygon parity across cupy / dask+numpy / dask+cupy. (2) Cat 4 HIGH like= template-raster parameter is documented at rasterize.py:2038 and implemented by _extract_grid_from_like (line 1930) but no test exercises it; TestLikeParameter pins dtype/bounds/coords inheritance, the three override branches (dtype, bounds, width/height), the three validation branches (not-DataArray, 3D, wrong dim names) and like= on all four backends. Mutation against the like-dtype branch (rasterize.py:2183-2184) flipped the inheritance test red. (3) Cat 4 HIGH resolution= happy path -- only the oversize-rejection error path was tested (line 304); TestResolutionParameter pins the scalar branch, the tuple branch, the ceil-and-clamp-to-1 semantics, and resolution= on all four backends. (4) Cat 4 HIGH non-empty GeometryCollection unpacking is documented at rasterize.py:1995 and implemented by _classify_geometries_loop (line 228) but only the empty-GC case was tested (line 269); TestGeometryCollection pins polygon+point and polygon+line+point collections on eager numpy plus parity across cupy / dask+numpy / dask+cupy so the loop classifier's polygon/line/point sub-bucketing has direct coverage. Cat 1 MEDIUM gap closed: eager cupy all_touched=True parity vs eager numpy (TestEagerCupyAllTouched) -- the existing test only covered dask+cupy all_touched, leaving the direct GPU all_touched kernel untested. Cat 2 MEDIUM gap closed: int32 dtype with default NaN fill silently casts to the int32-min sentinel (TestIntegerDtypeNanFill) -- pin the cast so any future ValueError-raises switch is visible as a code-review diff. Pre-existing 143 passing + 2 skipped tests in test_rasterize.py untouched." reproject,2026-05-10,,HIGH,1;4;5,"Added 39 tests: LiteCRS direct coverage, itrf_transform behaviour/roundtrip/array, itrf_frames, geoid_height numerical correctness + raster happy-path, vertical helpers (ellipsoidal<->orthometric/depth), reproject() lat/lon and latitude/longitude dim propagation. Note: _merge_arrays_cupy is imported but unused (no cupy merge dispatch in merge()); flagged as feature gap not test gap." diff --git a/xrspatial/tests/test_rasterize_coverage_2026_05_21.py b/xrspatial/tests/test_rasterize_coverage_2026_05_21.py new file mode 100644 index 000000000..6a5bcce9d --- /dev/null +++ b/xrspatial/tests/test_rasterize_coverage_2026_05_21.py @@ -0,0 +1,573 @@ +"""Coverage-gap tests for xrspatial.rasterize (deep-sweep test-coverage, pass 2). + +Closes the documented public-API gaps left after the pass-1 audit on +2026-05-17: + +- Cat 2 HIGH -- Inf burn-in values were never tested as a property / + geometry value on any backend. rasterize() builds a numeric output by + burning the per-geometry value into the raster, so passing + +Inf / -Inf / Inf+(-Inf) flows through every merge function: sum should + propagate the infinity, min/max should observe inf-vs-finite ordering, + Inf+(-Inf) under sum should produce NaN by IEEE arithmetic. Pin those + behaviours on all four backends so a regression that special-cases + non-finite at the kernel boundary (e.g. early-exit on isfinite) ships + visibly. +- Cat 2 HIGH -- NaN burn-in values were never tested. A geometry with a + NaN property is documented to write NaN into the covered pixels (and + any subsequent merge picks up NaN per IEEE rules). Pin the + cross-backend agreement so a future kernel optimisation that "skips + NaN" silently drops coverage instead of writing the documented value. +- Cat 1 MEDIUM -- Nested GeometryCollection: rasterize.py:1995 documents + "GeometryCollection -- recursively unpacked", and + ``_classify_geometries`` implements the recursion through the slow + path's ``_classify_one(geom, ...)`` callback. The pass-1 coverage file + only tests a single-level GC; a GC nested inside another GC has no + direct test. All four backends route through this code path because + the GC fan-out happens before backend dispatch. +- Cat 1 MEDIUM -- ``columns=`` (multi-column properties) on the cupy and + dask+cupy backends. test_rasterize.TestMultiColumn covers numpy and + dask+numpy parity but the GPU paths -- which thread the (N, P) props + array through the GPU init / scanline kernels and the per-tile dask + graph -- have no direct coverage. +- Cat 3 LOW (documented, not fixed) -- non-square cell size with + resolution=(rx, ry) and rx != ry already has indirect coverage via + TestResolutionParameter.test_tuple_resolution_branch but no test pins + the rectangular-pixel parity across backends. Pin a single eager + parity check so a regression that swaps rx/ry between code paths is + visible. + +The "fix" in this sweep is *adding tests*. No source changes. CUDA is +available on this host so cupy / dask+cupy tests execute live. +""" +from __future__ import annotations + +import numpy as np +import pytest + +try: + from shapely.geometry import ( + box, GeometryCollection, LineString, MultiPoint, Point, + ) + has_shapely = True +except ImportError: + has_shapely = False + +try: + import geopandas as gpd + has_geopandas = True +except ImportError: + has_geopandas = False + +if has_shapely: + from xrspatial.rasterize import rasterize + +pytestmark = pytest.mark.skipif( + not has_shapely, reason="shapely not installed" +) + +try: + import cupy + has_cupy = True +except ImportError: + has_cupy = False + +try: + import dask # noqa: F401 (availability probe only) + has_dask = True +except ImportError: + has_dask = False + +try: + from numba import cuda + has_cuda = has_cupy and cuda.is_available() +except Exception: + has_cuda = False + +skip_no_cuda = pytest.mark.skipif( + not has_cuda, reason="CUDA / CuPy not available") +skip_no_dask = pytest.mark.skipif( + not has_dask, reason="dask not installed") +skip_no_geopandas = pytest.mark.skipif( + not has_geopandas, reason="geopandas not installed") + + +def _materialise(result): + """Compute (if dask) and copy to host (if cupy) so callers see ndarray.""" + data = result.data + if hasattr(data, 'compute'): + data = data.compute() + if has_cupy and isinstance(data, cupy.ndarray): + return cupy.asnumpy(data) + return np.asarray(data) + + +_BACKEND_KWARGS = { + 'numpy': {}, + 'cupy': {'use_cuda': True}, + 'dask_numpy': {'chunks': (5, 5)}, + 'dask_cupy': {'use_cuda': True, 'chunks': (5, 5)}, +} + + +def _backend_param(name): + """Wrap a backend kwargs entry in the right pytest skip marker.""" + if name == 'cupy': + return pytest.param('cupy', _BACKEND_KWARGS['cupy'], + marks=skip_no_cuda, id='cupy') + if name == 'dask_numpy': + return pytest.param('dask_numpy', _BACKEND_KWARGS['dask_numpy'], + marks=skip_no_dask, id='dask_numpy') + if name == 'dask_cupy': + return pytest.param('dask_cupy', _BACKEND_KWARGS['dask_cupy'], + marks=[skip_no_cuda, skip_no_dask], + id='dask_cupy') + return pytest.param('numpy', _BACKEND_KWARGS['numpy'], id='numpy') + + +ALL_BACKENDS = [_backend_param(name) for name in _BACKEND_KWARGS] + + +# --------------------------------------------------------------------------- +# Cat 2 HIGH -- Inf burn-in values +# --------------------------------------------------------------------------- + + +class TestInfBurnValues: + """rasterize() burns the geometry value into covered pixels. + + Passing +Inf / -Inf as the burn value flows through every merge + function. Sum should propagate Inf; min and max should obey IEEE + ordering against finite values; Inf+(-Inf) under sum should yield + NaN. No test exists today for any of these on any backend; a + regression that gates writes on ``isfinite`` at the kernel boundary + would silently drop the pixels. + """ + + @pytest.mark.parametrize('backend_name,kw', ALL_BACKENDS) + def test_positive_inf_burn_default(self, backend_name, kw): + """+Inf burn fills the covered region with +Inf under the default + ``last`` merge.""" + r = rasterize([(box(0, 0, 10, 5), np.inf)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, **kw) + data = _materialise(r) + assert np.all(np.isposinf(data)) + + @pytest.mark.parametrize('backend_name,kw', ALL_BACKENDS) + def test_negative_inf_burn_default(self, backend_name, kw): + """-Inf burn fills covered region with -Inf under the default merge.""" + r = rasterize([(box(0, 0, 10, 5), -np.inf)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, **kw) + data = _materialise(r) + assert np.all(np.isneginf(data)) + + @pytest.mark.parametrize('backend_name,kw', ALL_BACKENDS) + def test_inf_plus_finite_sum_remains_inf(self, backend_name, kw): + """Inf + finite under ``sum`` should remain Inf, not collapse to + the finite value. IEEE arithmetic guarantees Inf + 1.0 == Inf.""" + r = rasterize( + [(box(0, 0, 10, 5), np.inf), (box(0, 0, 10, 5), 1.0)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, merge='sum', **kw) + data = _materialise(r) + assert np.all(np.isposinf(data)) + + @pytest.mark.parametrize('backend_name,kw', ALL_BACKENDS) + def test_inf_plus_neg_inf_sum_is_nan(self, backend_name, kw): + """Inf + (-Inf) under ``sum`` produces NaN by IEEE arithmetic. + Pinning the NaN result makes a future kernel that special-cases + infinity (e.g. saturating arithmetic) surface in CI.""" + r = rasterize( + [(box(0, 0, 10, 5), np.inf), (box(0, 0, 10, 5), -np.inf)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, merge='sum', **kw) + data = _materialise(r) + assert np.all(np.isnan(data)) + + @pytest.mark.parametrize('backend_name,kw', ALL_BACKENDS) + def test_min_inf_vs_finite_picks_finite(self, backend_name, kw): + """min(Inf, 1.0) == 1.0; the finite value wins.""" + r = rasterize( + [(box(0, 0, 10, 5), np.inf), (box(0, 0, 10, 5), 1.0)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, merge='min', **kw) + data = _materialise(r) + np.testing.assert_array_equal(data, np.ones_like(data)) + + @pytest.mark.parametrize('backend_name,kw', ALL_BACKENDS) + def test_max_neg_inf_vs_finite_picks_finite(self, backend_name, kw): + """max(-Inf, 1.0) == 1.0; the finite value wins.""" + r = rasterize( + [(box(0, 0, 10, 5), -np.inf), (box(0, 0, 10, 5), 1.0)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, merge='max', **kw) + data = _materialise(r) + np.testing.assert_array_equal(data, np.ones_like(data)) + + def test_inf_bounds_rejected(self): + """+Inf bounds are rejected with the same ValueError as NaN bounds. + The eager NaN-bound rejection was tested by + test_explicit_nan_bounds_rejected; this pin extends that to ``inf`` + so a future refactor that switches the check from ``isfinite`` to + ``not isnan`` (which would accept inf) surfaces in CI. + + Match is anchored on the bounds error prefix ``Invalid bounds:`` + so a future refactor that adds a different "must be finite" check + (e.g. on resolution) earlier in the call cannot accidentally + satisfy this assertion via the wrong code path.""" + with pytest.raises(ValueError, + match=r"Invalid bounds:.*must be finite"): + rasterize([(box(0, 0, 1, 1), 1.0)], width=2, height=2, + bounds=(0, 0, float('inf'), 1)) + with pytest.raises(ValueError, + match=r"Invalid bounds:.*must be finite"): + rasterize([(box(0, 0, 1, 1), 1.0)], width=2, height=2, + bounds=(0, 0, 1, -float('inf'))) + + +# --------------------------------------------------------------------------- +# Cat 2 HIGH -- NaN burn-in values +# --------------------------------------------------------------------------- + + +class TestNaNBurnValues: + """A geometry value of NaN writes NaN into the covered pixels. + + No test pins this behaviour on any backend. A kernel optimisation + that drops NaN writes (e.g. ``if isnan(val) continue``) would silently + leave the fill value in covered cells, which is a different + observable than emitting NaN there. + + NOTE: the GPU ``max`` / ``min`` merges currently suppress NaN burn + values, asymmetric with the CPU IEEE-propagating behaviour pinned + here. See issue #2255. The + ``test_nan_burn_overlaps_max_gpu_suppresses_nan`` and + ``test_nan_burn_single_geom_max_gpu_returns_neg_inf`` tests below + pin the current (asymmetric) GPU observable so the divergence is + visible in CI; both will need to be inverted once the GPU kernels + are aligned with CPU semantics. + """ + + @pytest.mark.parametrize('backend_name,kw', ALL_BACKENDS) + def test_nan_burn_polygon(self, backend_name, kw): + """A NaN-valued polygon burn produces NaN in every covered pixel, + not the fill sentinel.""" + r = rasterize([(box(0, 0, 5, 5), np.nan), + (box(5, 0, 10, 5), 1.0)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, **kw) + data = _materialise(r) + # Left half (NaN polygon) is NaN, not 0 (the fill). + assert np.all(np.isnan(data[:, :5])), ( + f"{backend_name}: left half should be NaN, got {data[:, :5]}") + # Right half (finite polygon) is 1.0, not NaN. + np.testing.assert_array_equal(data[:, 5:], np.ones((5, 5))) + + @pytest.mark.parametrize('backend_name,kw', ALL_BACKENDS) + def test_nan_burn_point(self, backend_name, kw): + """NaN burn through the point kernel produces NaN at the point's + cell. Pixel grid spans 10x5 at unit cells: Point(2.5, 2.5) lands + at row=2 col=2 (y descends from ymax=5).""" + r = rasterize([(Point(2.5, 2.5), np.nan)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0.0, **kw) + data = _materialise(r) + assert np.isnan(data[2, 2]) + # All other pixels keep the fill. + mask = np.ones_like(data, dtype=bool) + mask[2, 2] = False + assert np.all(data[mask] == 0) + + @pytest.mark.parametrize('backend_name,kw', ALL_BACKENDS) + def test_nan_burn_line(self, backend_name, kw): + """NaN burn through the line Bresenham kernel produces NaN along + the rasterized line.""" + line = LineString([(0.5, 2.5), (9.5, 2.5)]) + r = rasterize([(line, np.nan)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0.0, **kw) + data = _materialise(r) + # The line falls on the central row. Find which row by counting + # NaN per row -- this avoids backend-specific Bresenham rounding. + nan_rows = [int(np.isnan(data[r_idx]).sum()) for r_idx in range(5)] + assert max(nan_rows) >= 9, ( + f"{backend_name}: expected at least 9 NaN cells in some row, " + f"got per-row NaN counts {nan_rows}" + ) + + # CPU backends (numpy / dask+numpy) follow IEEE: max(NaN, 1.0) returns + # NaN because the comparison ``1.0 > NaN`` is False, so the kernel + # keeps the prior NaN pixel. GPU backends (cupy / dask+cupy) currently + # return 1.0: the GPU kernel initialises the output buffer to -inf for + # max merge and uses atomic_max which is NaN-suppressing under IEEE + # device semantics. See issue #2255. The CPU pin and the GPU + # asymmetry pin keep the divergence visible until the GPU kernels are + # aligned with the CPU is_first semantics. + + @pytest.mark.parametrize('backend_name,kw', [ + ALL_BACKENDS[0], # numpy + ALL_BACKENDS[2], # dask_numpy + ]) + def test_nan_burn_overlaps_max_cpu_propagates(self, backend_name, kw): + """CPU: ``max(NaN, 1.0) == NaN``. + + Pin the IEEE NaN-propagating behaviour so a future change that + switches the CPU max to ``fmax`` (NaN-suppressing) is visible as + a diff, not silent. + """ + r = rasterize( + [(box(0, 0, 10, 5), np.nan), (box(0, 0, 10, 5), 1.0)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, merge='max', **kw) + data = _materialise(r) + assert np.all(np.isnan(data)) + + @pytest.mark.parametrize('backend_name,kw', [ + ALL_BACKENDS[1], # cupy + ALL_BACKENDS[3], # dask_cupy + ]) + def test_nan_burn_overlaps_max_gpu_suppresses_nan(self, backend_name, kw): + """GPU: ``max(NaN, 1.0) == 1.0`` (NaN-suppressing). + + Asymmetric with the CPU behaviour above; see issue #2255. + Pinning the current observable here so the GPU<->CPU divergence + is documented in CI until the GPU max kernel can be aligned with + IEEE NaN propagation. + """ + r = rasterize( + [(box(0, 0, 10, 5), np.nan), (box(0, 0, 10, 5), 1.0)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, merge='max', **kw) + data = _materialise(r) + # Current (asymmetric) GPU behaviour: NaN was suppressed and the + # finite 1.0 won. This pin will need to flip to ``np.all(isnan)`` + # once the GPU max kernel is fixed to match the CPU IEEE + # propagation. + np.testing.assert_array_equal(data, np.ones_like(data)) + + @pytest.mark.parametrize('backend_name,kw', [ + ALL_BACKENDS[1], # cupy + ALL_BACKENDS[3], # dask_cupy + ]) + def test_nan_burn_single_geom_max_gpu_returns_neg_inf( + self, backend_name, kw): + """GPU: a single NaN-burn polygon under ``max`` leaves the + kernel's -inf init value in covered pixels. + + On CPU the value would be NaN (the burn was the only write and + ``_merge_max`` returns ``props[0]`` on first-write). On GPU the + buffer is initialised to -inf and atomicMax(-inf, NaN) keeps -inf + because NaN comparisons are False. See issue #2255. Pin so the + divergence is visible against the CPU behaviour pinned by + test_nan_burn_polygon[numpy]. + """ + r = rasterize([(box(0, 0, 10, 5), np.nan)], + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, merge='max', **kw) + data = _materialise(r) + # The 5x10 covered region is -inf, not NaN, not 0. + assert np.all(np.isneginf(data)), ( + f"{backend_name}: expected -inf in covered pixels, got {data}" + ) + + +# --------------------------------------------------------------------------- +# Cat 1 MEDIUM -- Nested GeometryCollection +# --------------------------------------------------------------------------- + + +class TestNestedGeometryCollection: + """A GeometryCollection containing another GeometryCollection. + + rasterize.py:1995 documents: "GeometryCollection -- recursively + unpacked". ``_classify_geometries`` implements that recursion through + a closure that walks ``sub.geoms`` for any ``GeometryCollection`` + child. The pass-1 coverage file only tests a flat GC (Polygon + + Point + Line inside one GC); a regression that limited the recursion + depth to 1 would silently drop deeper geometries. + """ + + @staticmethod + def _nested(): + """Outer GC = [ inner GC([box, Point]), Point ].""" + inner = GeometryCollection([box(0, 0, 4, 4), Point(7.5, 7.5)]) + return GeometryCollection([inner, Point(2.5, 2.5)]) + + @staticmethod + def _flat_equivalent(): + """Flattened list with identical pixel coverage.""" + return [box(0, 0, 4, 4), Point(7.5, 7.5), Point(2.5, 2.5)] + + @pytest.mark.parametrize('backend_name,kw', ALL_BACKENDS) + def test_nested_gc_matches_flat(self, backend_name, kw): + nested = self._nested() + flat = self._flat_equivalent() + nested_r = rasterize([(nested, 1.0)], width=10, height=10, + bounds=(0, 0, 10, 10), fill=0, **kw) + flat_r = rasterize([(g, 1.0) for g in flat], width=10, height=10, + bounds=(0, 0, 10, 10), fill=0, **kw) + np.testing.assert_array_equal( + _materialise(nested_r), _materialise(flat_r), + err_msg=f"backend {backend_name}: nested GC did not match flat") + + @pytest.mark.parametrize('backend_name,kw', [ + ALL_BACKENDS[0], # numpy + ALL_BACKENDS[2], # dask_numpy + ]) + def test_deeply_nested_gc(self, backend_name, kw): + """Three levels deep: GC(GC(GC([poly]))). + + Exercised on numpy and dask+numpy. The dask run additionally + checks that the recursive GC pre-classification survives the + per-tile graph builder, not just the eager path. + """ + l3 = GeometryCollection([box(2, 2, 8, 8)]) + l2 = GeometryCollection([l3]) + l1 = GeometryCollection([l2]) + r = rasterize([(l1, 5.0)], width=10, height=10, + bounds=(0, 0, 10, 10), fill=0, **kw) + # The inner polygon (box 2..8 in a 10x10 raster) writes 36 pixels + # of 5.0 -- pin the count rather than a per-pixel mask so the + # test is robust to scanline tie-breaks. + burned = (_materialise(r) == 5.0) + assert burned.sum() == 36, ( + f"{backend_name} deeply nested GC: expected 36 burned pixels, " + f"got {burned.sum()}" + ) + + @skip_no_geopandas + def test_nested_gc_in_geodataframe(self): + """GeometryCollections inside a GeoDataFrame also unpack.""" + nested = self._nested() + gdf = gpd.GeoDataFrame({'value': [3.0]}, geometry=[nested]) + r = rasterize(gdf, width=10, height=10, + bounds=(0, 0, 10, 10), fill=0, column='value') + data = _materialise(r) + # Polygon covers the 4x4 SW block (row 6..9, col 0..3 in standard + # y-descending image orientation). The two points appear at + # (2.5, 2.5) -> row 7 col 2 (covered by the polygon already) and + # (7.5, 7.5) -> row 2 col 7. + assert data[7, 2] == 3.0 # inside polygon + assert data[2, 7] == 3.0 # standalone point burns + + +# --------------------------------------------------------------------------- +# Cat 1 MEDIUM -- columns= on cupy and dask+cupy +# --------------------------------------------------------------------------- + + +@skip_no_geopandas +class TestMultiColumnGPU: + """``columns=`` parity on the cupy and dask+cupy backends. + + TestMultiColumn in test_rasterize.py covers numpy + dask+numpy parity + but the GPU paths thread the (N, P) props array through dedicated GPU + kernels and per-tile dask graphs. A regression on the GPU + multi-column wiring would not surface from any existing test. + """ + + @staticmethod + def _fixture(): + return gpd.GeoDataFrame({ + 'num': [6.0, 12.0], + 'den': [2.0, 3.0], + 'geometry': [box(0, 0, 5, 5), box(5, 0, 10, 5)], + }) + + @skip_no_cuda + def test_multi_column_sum_cupy_matches_numpy(self): + gdf = self._fixture() + np_r = rasterize(gdf, columns=['num', 'den'], merge='sum', + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0) + cp_r = rasterize(gdf, columns=['num', 'den'], merge='sum', + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, use_cuda=True) + np.testing.assert_array_equal(np_r.values, _materialise(cp_r)) + + @skip_no_cuda + @skip_no_dask + def test_multi_column_sum_dask_cupy_matches_numpy(self): + gdf = self._fixture() + np_r = rasterize(gdf, columns=['num', 'den'], merge='sum', + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0) + dc_r = rasterize(gdf, columns=['num', 'den'], merge='sum', + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, use_cuda=True, chunks=(3, 3)) + np.testing.assert_array_equal(np_r.values, _materialise(dc_r)) + + @skip_no_cuda + def test_multi_column_props_count_cupy(self): + """Multi-column with merge='count' uses props[0]: pin the (N, P) + array shape survives the GPU init. count=2 because two polygons + share no pixels but each writes once -- so per-pixel count is 1 + everywhere geometries are present.""" + gdf = self._fixture() + cp_r = rasterize(gdf, columns=['num', 'den'], merge='count', + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, use_cuda=True) + data = _materialise(cp_r) + # Every covered pixel has count==1; uncovered pixels are 0. + assert (data == 1).sum() == 50 # full 10x5 covered by union + + @skip_no_cuda + def test_multi_column_three_columns_cupy(self): + """A third column exercises the props array shape >2 on GPU. + Built-in merges read props[0] only; the loop that copies all P + columns into the per-pixel state still has to run.""" + gdf = gpd.GeoDataFrame({ + 'a': [1.0, 4.0], + 'b': [2.0, 5.0], + 'c': [3.0, 6.0], + 'geometry': [box(0, 0, 5, 5), box(5, 0, 10, 5)], + }) + np_r = rasterize(gdf, columns=['a', 'b', 'c'], merge='sum', + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0) + cp_r = rasterize(gdf, columns=['a', 'b', 'c'], merge='sum', + width=10, height=5, bounds=(0, 0, 10, 5), + fill=0, use_cuda=True) + np.testing.assert_array_equal(np_r.values, _materialise(cp_r)) + + +# --------------------------------------------------------------------------- +# Cat 3 LOW -- rectangular pixel parity (non-square cells) +# --------------------------------------------------------------------------- + + +class TestRectangularPixels: + """resolution=(rx, ry) with rx != ry produces non-square cells. + + The pass-1 coverage file exercises the tuple branch through + test_tuple_resolution_branch but does not assert per-backend parity. + Pin a single rectangular-pixel parity check so a regression that + swaps rx/ry in the height/width derivation surfaces in CI. + """ + + @pytest.mark.parametrize('backend_name,kw', ALL_BACKENDS) + def test_rectangular_pixels_polygon_parity(self, backend_name, kw): + """resolution=(2, 1) -> width=5, height=10 over (0,0)-(10,10).""" + geom = box(2, 2, 8, 8) + np_r = rasterize([(geom, 3.0)], + resolution=(2.0, 1.0), + bounds=(0, 0, 10, 10), fill=0) + backend_r = rasterize([(geom, 3.0)], + resolution=(2.0, 1.0), + bounds=(0, 0, 10, 10), fill=0, **kw) + assert backend_r.shape == (10, 5) + np.testing.assert_array_equal( + np_r.values, _materialise(backend_r), + err_msg=f"{backend_name} disagreed on (rx=2, ry=1) cell size") + + def test_rectangular_pixels_attrs_res(self): + """The resolved cell size lands on the output if a like attrs + dict is supplied with res; without like, no res attr is set -- + this test pins that contract so callers know which path emits + the metadata.""" + # No like -> no res attr. + r = rasterize([(box(2, 2, 8, 8), 3.0)], + resolution=(2.0, 1.0), + bounds=(0, 0, 10, 10), fill=0) + assert 'res' not in r.attrs