diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv
index 8c8caec6..6dc4cac3 100644
--- a/.claude/sweep-test-coverage-state.csv
+++ b/.claude/sweep-test-coverage-state.csv
@@ -1,4 +1,6 @@
module,last_inspected,issue,severity_max,categories_found,notes
-geotiff,2026-05-18,,HIGH,1,"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)."
+geotiff,2026-05-18,,HIGH,3;4,"Pass 18 (2026-05-18): added test_parallel_strip_decode_sparse_2100.py closing Cat 3 HIGH geometric-edge / Cat 4 HIGH parameter-coverage gap on the parallel-decode strip paths (#2100/#2104). The strip-decode parallelisation in _read_strips (lines 1942-2014) and _fetch_decode_cog_http_strips (lines 2685-2740) added a collect-decode-place pipeline whose job-collection loop filters sparse strips (byte_counts[idx] == 0) before they reach the ThreadPoolExecutor. The existing test_parallel_strip_decode_2100.py covers parallel/serial parity, the pool-engaged branch, single-strip serial short-circuit, windowed strip reads, and planar=2 multi-band, but every fixture is fully populated. The 128x128 sparse fixture in test_sparse_cog.py is below the 64K-pixel parallel gate, so the sparse-strip filter inside the parallel branch is wholly untested. A regression that lost the byte_counts==0 guard would silently ship: the decoder would receive an empty data[offsets[idx]:offsets[idx]+0] slice and either raise 'Decompressed tile/strip size mismatch' or return corrupt pixels. 7 new tests, all passing: local-strip full-image parallel/serial parity with sparse strips, parallel-pool-engaged on multi-strip sparse images, windowed reads across the sparse boundary, all-sparse degenerate (zero filled rows -> empty job list -> short-circuit gate), planar=2 sparse parity (dedicated 'planar == 2 and samples > 1' branch with its own byte_counts==0 guard at lines 1949-1962), HTTP windowed read on a non-sparse strict subset (parallel decode of fetched strips), and HTTP windowed read across the sparse boundary (parallel decode of the fetched strips with placement matching the local read). Mutation against the strip-job collection sparse guard (delete the byte_counts == 0 continue) flips 5 of 5 local tests red with 'Decompressed tile/strip size mismatch: expected ... got 0'; mutation against the HTTP path sparse guard at line 2646 flips the boundary HTTP test red. Confirmed clean restore via md5sum. Source untouched. Cat 3 HIGH + Cat 4 HIGH (geometric edge case + parameter coverage on the sparse-strip code path under parallel decode).
+
+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)."
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."
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/geotiff/tests/test_parallel_strip_decode_sparse_2100.py b/xrspatial/geotiff/tests/test_parallel_strip_decode_sparse_2100.py
new file mode 100644
index 00000000..c8b0751f
--- /dev/null
+++ b/xrspatial/geotiff/tests/test_parallel_strip_decode_sparse_2100.py
@@ -0,0 +1,353 @@
+"""Sparse-strip coverage for the parallel-decode strip paths (#2100).
+
+The strip-decode parallelisation landed in #2100 / #2104 added a
+collect-decode-place pipeline in both ``_read_strips`` and
+``_fetch_decode_cog_http_strips``. The job-collection loop filters out
+sparse strips (``byte_counts[idx] == 0``) so the pool never decodes an
+empty byte slice, and the pre-allocated result already carries the
+sparse fill value. ``test_parallel_strip_decode_2100.py`` exercises
+the parallel/serial parity and the pool-engaged branch, but every
+fixture has every strip populated; a regression that lost the sparse
+filter (e.g. by appending a job before the ``if byte_counts[...] == 0:
+continue`` guard) would slip through because the existing
+``test_sparse_cog.py::TestSparseStrips`` fixture is 128x128, well below
+the 64K-pixel parallel-decode gate.
+
+These tests build a large sparse-stripped TIFF (>= 64K strip pixels,
+multi-strip) so the parallel branch engages, then assert:
+
+1. Local path: parallel and serial decode return the same array; the
+ filled rows carry the source value and the sparse rows carry the
+ nodata sentinel.
+2. Local path under a window that straddles the sparse boundary.
+3. Local planar=2 multi-band sparse decode (the dedicated
+ ``planar == 2 and samples > 1`` branch in the strip-job collection
+ loop has its own ``if byte_counts[global_idx] == 0: continue`` guard
+ that the existing tests do not reach with a sparse fixture).
+4. HTTP COG strip path: a windowed read that fetches a strict subset of
+ non-sparse strips still parallelises and matches the local read.
+
+A mutation against the sparse guard (delete the ``continue`` so sparse
+strips are appended to ``strip_jobs``) flips every test in this file
+red because the decoder either returns a zero-length array or raises
+on empty input — confirmed before commit.
+"""
+from __future__ import annotations
+
+import http.server
+import os
+import socket
+import tempfile
+import threading
+from unittest.mock import patch
+
+import numpy as np
+import pytest
+
+# Sparse-stripped fixtures depend on rasterio's TIFF writer (GDAL's
+# ``SPARSE_OK`` driver option). Skip the module wholesale when rasterio
+# is unavailable in the test environment; the GeoTIFF reader code paths
+# under test do not depend on rasterio at runtime.
+rasterio = pytest.importorskip("rasterio")
+
+from xrspatial.geotiff._reader import read_to_array # noqa: E402
+from xrspatial.geotiff import _reader as _reader_mod # noqa: E402
+
+
+# Local-strip helpers -------------------------------------------------------
+
+def _write_sparse_stripped_large(
+ path: str,
+ *,
+ width: int = 2048,
+ height: int = 2048,
+ rps: int = 64,
+ filled_rows: int = 256,
+ fill_value: int = 200,
+ dtype: str = "uint16",
+ nodata: int = 0,
+ bands: int = 1,
+ planar: str = "pixel",
+):
+ """Build a large stripped TIFF with sparse strips below ``filled_rows``.
+
+ The default geometry (2048x2048, rps=64) yields ``width * rps =
+ 131_072`` pixels per strip — clear of the 64K parallel-decode gate
+ — and 32 strips per band, so leaving rows below ``filled_rows`` un-
+ written gives ``32 - filled_rows / rps`` sparse strips that the
+ job-collection loop must filter.
+
+ ``planar``: ``"pixel"`` (contig, planar=1) or ``"band"``
+ (planar=2 / separate). Rasterio accepts only those literals.
+ """
+ profile = {
+ "driver": "GTiff",
+ "dtype": dtype,
+ "height": height,
+ "width": width,
+ "count": bands,
+ "tiled": False,
+ "blockysize": rps,
+ "compress": "DEFLATE",
+ "SPARSE_OK": "TRUE",
+ "nodata": nodata,
+ "interleave": planar,
+ }
+ fill = np.full((filled_rows, width), fill_value, dtype=np.dtype(dtype))
+ with rasterio.open(path, "w", **profile) as dst:
+ for b in range(1, bands + 1):
+ dst.write(
+ fill, b,
+ window=rasterio.windows.Window(0, 0, width, filled_rows))
+
+
+# HTTP range-server reused from the existing parallel-strip test.
+class _RangeHandler(http.server.BaseHTTPRequestHandler):
+ blob: bytes = b""
+
+ def do_HEAD(self):
+ self.send_response(200)
+ self.send_header("Content-Length", str(len(self.blob)))
+ self.send_header("Accept-Ranges", "bytes")
+ self.end_headers()
+
+ def do_GET(self):
+ rng = self.headers.get("Range")
+ if rng and rng.startswith("bytes="):
+ r0, r1 = rng[len("bytes="):].split("-")
+ r0 = int(r0)
+ r1 = int(r1) if r1 else len(self.blob) - 1
+ r1 = min(r1, len(self.blob) - 1)
+ body = self.blob[r0:r1 + 1]
+ self.send_response(206)
+ self.send_header(
+ "Content-Range",
+ f"bytes {r0}-{r1}/{len(self.blob)}")
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Accept-Ranges", "bytes")
+ self.end_headers()
+ self.wfile.write(body)
+ else:
+ self.send_response(200)
+ self.send_header("Content-Length", str(len(self.blob)))
+ self.send_header("Accept-Ranges", "bytes")
+ self.end_headers()
+ self.wfile.write(self.blob)
+
+ def log_message(self, format, *args):
+ return
+
+
+def _start_server(blob: bytes):
+ s = socket.socket()
+ s.bind(("127.0.0.1", 0))
+ port = s.getsockname()[1]
+ s.close()
+ handler = type("BlobHandler", (_RangeHandler,), {"blob": blob})
+ server = http.server.HTTPServer(("127.0.0.1", port), handler)
+ th = threading.Thread(target=server.serve_forever, daemon=True)
+ th.start()
+ return server, port
+
+
+# Local-strip sparse coverage ----------------------------------------------
+
+class TestReadStripsSparseParallel:
+ """``_read_strips`` parallel branch with sparse strips."""
+
+ def test_full_image_parallel_matches_serial(self, tmp_path):
+ """Sparse + non-sparse strips: parallel and serial paths return
+ bit-identical output, and the sparse rows land on the nodata
+ sentinel."""
+ path = str(tmp_path / "sparse_par_full.tif")
+ _write_sparse_stripped_large(path)
+
+ par, _ = read_to_array(path)
+ with patch.object(
+ _reader_mod,
+ "_PARALLEL_DECODE_PIXEL_THRESHOLD", 10 ** 12):
+ ser, _ = read_to_array(path)
+
+ np.testing.assert_array_equal(par, ser)
+ # First 256 rows carry the fill value, the rest are sparse → 0.
+ assert np.all(par[:256, :] == 200)
+ assert np.all(par[256:, :] == 0)
+
+ def test_parallel_pool_engages_on_sparse_multi_strip(self, tmp_path):
+ """A 2048x2048 sparse stripped TIFF with rps=64 has multiple
+ non-sparse strips; the parallel-decode pool must instantiate.
+
+ Validates that the sparse-strip filter does not regress the gate
+ by pruning the job list below ``n_strips > 1``."""
+ path = str(tmp_path / "sparse_par_gate.tif")
+ # 4 strips filled, 28 sparse → 4 non-sparse strips, so pool
+ # engages because n_strips = 4 > 1 and strip_pixel_count
+ # = 2048 * 64 = 131_072 >= 65_536.
+ _write_sparse_stripped_large(path, filled_rows=256)
+ with patch.object(
+ _reader_mod, "ThreadPoolExecutor",
+ wraps=_reader_mod.ThreadPoolExecutor) as mock_pool:
+ out, _ = read_to_array(path)
+ assert mock_pool.called, (
+ "parallel-decode pool was not engaged for a multi-strip "
+ "sparse-stripped TIFF whose non-sparse strips clear the "
+ "parallel gate"
+ )
+ assert np.all(out[:256, :] == 200)
+ assert np.all(out[256:, :] == 0)
+
+ def test_windowed_across_sparse_boundary(self, tmp_path):
+ """A window that straddles the filled/sparse boundary returns
+ the filled rows on top and zeros below, with parallel == serial.
+
+ Catches a regression in the per-strip placement loop that mis-
+ attributes a parallel-decoded strip to the wrong destination
+ slice when the strip range skips over sparse entries."""
+ path = str(tmp_path / "sparse_par_win.tif")
+ _write_sparse_stripped_large(path)
+
+ win = (128, 0, 384, 1024) # row range [128, 384), col range [0, 1024)
+ par, _ = read_to_array(path, window=win)
+ with patch.object(
+ _reader_mod,
+ "_PARALLEL_DECODE_PIXEL_THRESHOLD", 10 ** 12):
+ ser, _ = read_to_array(path, window=win)
+
+ np.testing.assert_array_equal(par, ser)
+ assert par.shape == (256, 1024)
+ # Rows [128, 256) are filled; rows [256, 384) are sparse.
+ assert np.all(par[:128, :] == 200)
+ assert np.all(par[128:, :] == 0)
+
+ def test_all_sparse_image_returns_fill(self, tmp_path):
+ """An image with zero filled rows → every strip is sparse →
+ ``strip_jobs`` is empty → the parallel branch's
+ ``n_strips > 1`` gate is false and the loop short-circuits.
+
+ Mirrors the "no jobs" degenerate case that the existing tests
+ miss because they always produce >= 1 non-sparse strip."""
+ path = str(tmp_path / "all_sparse.tif")
+ _write_sparse_stripped_large(path, filled_rows=0)
+ with patch.object(
+ _reader_mod, "ThreadPoolExecutor",
+ wraps=_reader_mod.ThreadPoolExecutor) as mock_pool:
+ out, _ = read_to_array(path)
+ # All strips sparse → no jobs → no pool.
+ assert not mock_pool.called, (
+ "parallel-decode pool was instantiated for an all-sparse "
+ "image; the strip-job filter should have left the job "
+ "list empty and the gate should have short-circuited"
+ )
+ assert out.shape == (2048, 2048)
+ assert np.all(out == 0)
+
+
+# Planar=2 sparse coverage --------------------------------------------------
+
+class TestReadStripsSparsePlanar2:
+ """``_read_strips`` planar=2 branch with sparse strips.
+
+ The strip-job collection loop has a dedicated
+ ``planar == 2 and samples > 1`` branch (lines 1949-1962 in
+ ``_reader.py``) with its own ``if byte_counts[global_idx] == 0:
+ continue`` guard. The existing parallel-strip planar=2 tests fill
+ every strip, so a regression in this branch's sparse filter would
+ survive."""
+
+ def test_planar2_sparse_parallel_matches_serial(self, tmp_path):
+ path = str(tmp_path / "planar2_sparse.tif")
+ _write_sparse_stripped_large(
+ path,
+ width=1024,
+ height=1024,
+ rps=64,
+ filled_rows=128,
+ bands=3,
+ planar="band",
+ )
+
+ par, _ = read_to_array(path)
+ with patch.object(
+ _reader_mod,
+ "_PARALLEL_DECODE_PIXEL_THRESHOLD", 10 ** 12):
+ ser, _ = read_to_array(path)
+
+ np.testing.assert_array_equal(par, ser)
+ # Reader returns (h, w, samples) for planar=2 multi-band.
+ # The fixture wrote the same fill into every band, so every
+ # band has the same pattern.
+ assert par.shape == (1024, 1024, 3)
+ for b in range(3):
+ assert np.all(par[:128, :, b] == 200)
+ assert np.all(par[128:, :, b] == 0)
+
+
+# HTTP COG strip sparse coverage -------------------------------------------
+
+class TestHttpStripsSparseParallel:
+ """``_fetch_decode_cog_http_strips`` with sparse strips.
+
+ The HTTP strip path also filters ``byte_counts[idx] == 0`` from the
+ fetch-range list (line 2646-2648 in ``_reader.py``); a window that
+ targets only non-sparse strips still parallel-decodes, and the
+ final placement loop must match the local path."""
+
+ def test_http_windowed_strict_subset_parallel(self, tmp_path, monkeypatch):
+ """HTTP windowed read on a sparse-stripped TIFF.
+
+ Targeted window covers only filled rows so the fetch list
+ excludes the sparse strips, the parallel-decode gate engages,
+ and the result matches the local file read.
+ """
+ monkeypatch.setenv("XRSPATIAL_GEOTIFF_ALLOW_PRIVATE_HOSTS", "1")
+ path = str(tmp_path / "sparse_http.tif")
+ _write_sparse_stripped_large(path, filled_rows=256)
+ with open(path, "rb") as f:
+ blob = f.read()
+
+ server, port = _start_server(blob)
+ try:
+ url = f"http://127.0.0.1:{port}/sparse.tif"
+ par, _ = read_to_array(url, window=(0, 0, 256, 2048))
+ with patch.object(
+ _reader_mod,
+ "_PARALLEL_DECODE_PIXEL_THRESHOLD", 10 ** 12):
+ ser, _ = read_to_array(url, window=(0, 0, 256, 2048))
+ finally:
+ server.shutdown()
+
+ np.testing.assert_array_equal(par, ser)
+ # The full window is in the filled region; nothing sparse.
+ assert np.all(par == 200)
+
+ def test_http_windowed_across_sparse_boundary(
+ self, tmp_path, monkeypatch):
+ """HTTP windowed read that straddles the sparse boundary: the
+ fetch path emits a fetch range per non-sparse strip the window
+ touches, the decoder runs in parallel on those, and the sparse
+ strips inside the window carry the pre-filled fill value."""
+ monkeypatch.setenv("XRSPATIAL_GEOTIFF_ALLOW_PRIVATE_HOSTS", "1")
+ path = str(tmp_path / "sparse_http_boundary.tif")
+ _write_sparse_stripped_large(path, filled_rows=256)
+ with open(path, "rb") as f:
+ blob = f.read()
+
+ server, port = _start_server(blob)
+ try:
+ url = f"http://127.0.0.1:{port}/sparse2.tif"
+ par, _ = read_to_array(url, window=(128, 0, 384, 2048))
+ with patch.object(
+ _reader_mod,
+ "_PARALLEL_DECODE_PIXEL_THRESHOLD", 10 ** 12):
+ ser, _ = read_to_array(url, window=(128, 0, 384, 2048))
+ finally:
+ server.shutdown()
+
+ np.testing.assert_array_equal(par, ser)
+ assert par.shape == (256, 2048)
+ assert np.all(par[:128, :] == 200)
+ assert np.all(par[128:, :] == 0)
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])