diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 8da4a841..559cff79 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -1,4 +1,5 @@ module,last_inspected,issue,severity_max,categories_found,notes +aspect,2026-05-29,2742,HIGH,3;4,"degenerate shapes (1x1/Nx1/1xN) + geodesic boundary modes untested; tests added all 4 backends, GPU-validated" contour,2026-05-29,2704;2710,HIGH,2;5,"Pass 1 (2026-05-29): added TestInfHandling, TestCRSPropagation, TestNonDefaultDims to test_contour.py (5 passed + 2 strict-xfail on a CUDA host; full file 29 passed, 2 xfailed). All four backends (numpy / cupy / dask+numpy / dask+cupy) were already exercised with cross-backend segment-equality assertions (TestBackendEquivalence), and ran green locally on the CUDA host -- Cat 1 well covered, no new backend tests needed. Cat 2 HIGH (Inf): the marching-squares NaN-skip guard at contour.py:67 uses x!=x which does not catch infinity, so a finite level near a +/-inf corner leaks NaN coordinates into the output. Filed source bug #2704 and added two xfail(strict=True) tests pinning it (+inf and -inf) plus test_inf_far_level_no_crossing covering the safe path where the inf quad classifies as all-above (idx 15) and is skipped before any interpolation. Cat 5 MEDIUM: no test asserted gdf.crs propagation from agg.attrs['crs'] (contour.py:660) -- added test_geopandas_crs_from_attrs (to_epsg()==5070) + test_geopandas_no_crs_attr. Cat 5 MEDIUM: the index-to-coordinate transform (contour.py:644-654) reads agg.dims[0]/[1] coords but no test used non-y/x dims -- added test_lat_lon_dims_coordinate_transform + test_lat_lon_matches_yx_equivalent. PR #2710 (test-only, source untouched). LOW (documented, not fixed): non-square cellsize (cellsize_x != cellsize_y) never exercised -- all tests use res (0.5,0.5); levels=None early-return on all-NaN/all-equal works (probed) but only the explicit-levels all-NaN path is asserted. Cat 3 1x1/Nx1/1xN are rejected by the >=2x2 validation guard and that rejection is already tested (test_too_small, test_minimum_raster)." geotiff,2026-05-29,,MEDIUM,1,"Pass 19 (2026-05-29): added TestBboxBackendParity to read/test_bbox_2555.py closing a Cat 1 MEDIUM cross-backend gap on the open_geotiff(bbox=) feature (#2556). bbox= resolves to a pixel window at the dispatcher (__init__.py _bbox_to_window, ~L799) then forwards uniformly into every backend; the eager-numpy path had full validation+overview coverage but bbox= was never exercised with gpu=True, chunks=, or gpu=True+chunks=. window= itself is tested on GPU (parity/test_finalization.py) and dask (parity/test_pixel_equality.py) but the composed bbox->window->backend path was untested, so a regression dropping the resolved window before GPU/dask dispatch would ship undetected. 3 new tests, all RUN (not skipped) and passing on a CUDA host: gpu / dask+numpy / dask+gpu each assert pixel+shape parity vs the eager bbox read. Mutation check confirmed teeth: a dask read with the window dropped returns full 100x100 vs the expected 60x60, failing the shape assert. New features since last pass already covered at merge: #2603 projected+base-geographic CRS (unit/test_geotags.py), #2598 .xrs.open_geotiff accessor (integration/test_dask_pipeline.py). | 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)." polygonize,2026-05-29,2623,MEDIUM,4,"Pass 3 (2026-05-29): added test_polygonize_mask_dtype_coverage_2026_05_29.py (41 passed, 8 xfailed on a CUDA host). Closes Cat 4 MEDIUM parameter-coverage gap: mask= is documented to accept bool/integer/float values but every prior test passed only a bool mask. Integer masks (int32/int64) now pinned against the same-backend bool-mask output on all four backends x both raster dtypes x connectivity 4/8; float-mask-on-integer-raster also pinned. Each backend is compared to its OWN bool reference to isolate mask-dtype from the unrelated numpy-vs-dask hole-vs-single-ring representation difference. Mutation (drop the not-mask[ij] exclusion in _calculate_regions) flips 11 tests red incl. the pixel-exclusion sanity anchor; clean md5 restore. Surfaced source bug #2623: a float-dtype mask on a float-dtype raster raises TypeError at polygonize.py:918 (mask & nan_mask; bitwise_and undefined for float&bool; cupy/dask route floats through _polygonize_numpy so they crash too; int masks coerce fine). 8 float-mask cases marked xfail(strict, raises=TypeError) referencing #2623. Test-only; source untouched. | Pass 2 (2026-05-27): added test_polygonize_atol_rtol_backend_coverage_2026_05_27.py with 15 tests, all passing on a CUDA host. Closes Cat 4 MEDIUM parameter-coverage gap on atol/rtol forwarding through the cupy and dask+cupy backends. atol/rtol were exposed by #2173 / #2194 and thread through _polygonize_cupy (polygonize.py:808) and _polygonize_dask (polygonize.py:1719); the dask path further plumbs them into dask.delayed(_polygonize_chunk)(...) at lines 1748-1754 and into _bucket_key_for_value for cross-chunk merge bucketing at lines 1757-1758. Pre-existing tests covered non-default atol/rtol only on numpy and dask+numpy. The cupy and dask+cupy dispatchers were untested -- a regression dropping the kwargs there would silently change the float polygon count and would not be caught. Same dispatcher-silently-drops-kwarg pattern fixed by #1561 / #1605 / #1685 / #1810 / #1974 on adjacent GeoTIFF surfaces. 15 tests: cupy strict-equality + default-tolerance pin on _REPRO_2173, dask+cupy strict-equality single-chunk + multi-chunk (engages cross-chunk merge bucket) + default-tolerance multi-chunk pin, cupy intermediate-atol small/large pair, dask+cupy intermediate-atol single/multi-chunk small + single-chunk large, cupy integer atol-ignored matrix, dask+cupy integer atol-ignored single-chunk + multi-chunk, cupy rtol-only large/small matrix. Mutation against _polygonize_cupy float branch (drop atol/rtol kwargs in the _polygonize_numpy forward call at polygonize.py:823-825) flips 3 of 5 cupy tests red; mutation against dask.delayed(_polygonize_chunk)(...) at polygonize.py:1748-1754 (drop atol, rtol args) flips 2 of 6 dask+cupy tests red. Confirmed clean restore via md5sum. Source untouched. Filed issue #2537 (test-only). Cat 4 MEDIUM (parameter coverage on cupy + dask+cupy atol/rtol forwarding). 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." diff --git a/xrspatial/tests/test_aspect.py b/xrspatial/tests/test_aspect.py index 85065c0a..32d0f66a 100644 --- a/xrspatial/tests/test_aspect.py +++ b/xrspatial/tests/test_aspect.py @@ -1,7 +1,13 @@ import numpy as np import pytest -from xrspatial import aspect +try: + import dask.array as da +except ImportError: + da = None + +from xrspatial import aspect, eastness, northness +from xrspatial.utils import has_dask_array from xrspatial.tests.general_checks import (assert_boundary_mode_correctness, assert_nan_edges_effect, assert_numpy_equals_cupy, @@ -169,3 +175,77 @@ def test_dask_cupy_advertised_dtype_matches_computed(): da_result = aspect(dask_cupy_agg) assert da_result.dtype == cupy.float32 assert da_result.data.compute().dtype == cupy.float32 + + +# ---- Degenerate raster shapes (1x1, Nx1, 1xN) ---- +# +# A 3x3 kernel has no interior cell when a dimension is smaller than 3, +# so the correct planar result is all-NaN with the input shape preserved. +# These guard against a regression that would crash or reshape on the +# kernel-boundary path. See issue #2742. + +_DEGENERATE_SHAPES = [(1, 1), (1, 5), (5, 1), (3, 1), (1, 3)] + + +def _to_numpy(result_agg): + data = result_agg.data + if has_dask_array() and isinstance(data, da.Array): + data = data.compute() + if hasattr(data, 'get'): # cupy + data = data.get() + return data + + +@pytest.mark.parametrize("func", [aspect, northness, eastness]) +@pytest.mark.parametrize("shape", _DEGENERATE_SHAPES) +def test_degenerate_shape_numpy(func, shape): + data = np.ones(shape, dtype=np.float32) + if shape[0] > 1: + data[0, :] = 2.0 + agg = create_test_raster(data, backend='numpy') + result = func(agg) + general_output_checks(agg, result) + assert result.shape == shape + assert np.all(np.isnan(result.data)) + + +@dask_array_available +@pytest.mark.parametrize("func", [aspect, northness, eastness]) +@pytest.mark.parametrize("shape", _DEGENERATE_SHAPES) +def test_degenerate_shape_dask_numpy(func, shape): + data = np.ones(shape, dtype=np.float32) + if shape[0] > 1: + data[0, :] = 2.0 + chunks = (min(2, shape[0]), min(2, shape[1])) + agg = create_test_raster(data, backend='dask+numpy', chunks=chunks) + result = func(agg) + assert result.shape == shape + assert np.all(np.isnan(_to_numpy(result))) + + +@cuda_and_cupy_available +@pytest.mark.parametrize("func", [aspect, northness, eastness]) +@pytest.mark.parametrize("shape", _DEGENERATE_SHAPES) +def test_degenerate_shape_cupy(func, shape): + data = np.ones(shape, dtype=np.float32) + if shape[0] > 1: + data[0, :] = 2.0 + agg = create_test_raster(data, backend='cupy') + result = func(agg) + assert result.shape == shape + assert np.all(np.isnan(_to_numpy(result))) + + +@dask_array_available +@cuda_and_cupy_available +@pytest.mark.parametrize("func", [aspect, northness, eastness]) +@pytest.mark.parametrize("shape", _DEGENERATE_SHAPES) +def test_degenerate_shape_dask_cupy(func, shape): + data = np.ones(shape, dtype=np.float32) + if shape[0] > 1: + data[0, :] = 2.0 + chunks = (min(2, shape[0]), min(2, shape[1])) + agg = create_test_raster(data, backend='dask+cupy', chunks=chunks) + result = func(agg) + assert result.shape == shape + assert np.all(np.isnan(_to_numpy(result))) diff --git a/xrspatial/tests/test_geodesic_aspect.py b/xrspatial/tests/test_geodesic_aspect.py index 00aff14a..24224534 100644 --- a/xrspatial/tests/test_geodesic_aspect.py +++ b/xrspatial/tests/test_geodesic_aspect.py @@ -163,6 +163,17 @@ def test_aspect_range(self): assert np.all(directional >= 0.0) assert np.all(directional < 360.0) + @pytest.mark.parametrize("shape", [(1, 1), (1, 5), (5, 1)]) + def test_degenerate_shape(self, shape): + """A dimension below 3 leaves no interior cell, so the geodesic + result keeps the input shape and comes back all-NaN. See #2742.""" + H, W = shape + elev = np.arange(H * W, dtype=np.float64).reshape(shape) + raster = _make_geo_raster(elev, 40.0, 41.0, 10.0, 11.0) + result = aspect(raster, method='geodesic') + assert result.shape == shape + assert np.all(np.isnan(result.values)) + # --------------------------------------------------------------------------- # Tests — z_unit @@ -294,3 +305,85 @@ def test_numpy_equals_dask_cupy(self): np.testing.assert_allclose( a_np.values, a_dc.data.compute().get(), rtol=1e-5, equal_nan=True ) + + +# --------------------------------------------------------------------------- +# Tests — boundary modes +# +# The geodesic backends pad the elevation AND the lat/lon coordinate grids +# (the _run_*_geodesic functions in xrspatial/aspect.py) when boundary is +# not 'nan'. Only 'nan' was exercised before; these cover the non-default +# modes and check cross-backend parity. See issue #2742. +# --------------------------------------------------------------------------- + +_NONNAN_BOUNDARY_MODES = ['nearest', 'reflect', 'wrap'] + + +class TestGeodesicAspectBoundary: + + @pytest.mark.parametrize("boundary", _NONNAN_BOUNDARY_MODES) + def test_nonnan_modes_fill_edges(self, boundary): + """Non-nan modes leave no NaN edge for a fully-defined surface.""" + elev = _east_tilted_surface(H=6, W=8, grade=100.0) + raster = _make_geo_raster(elev, 40.0, 41.0, 10.0, 11.0) + result = aspect(raster, method='geodesic', boundary=boundary) + assert result.shape == elev.shape + assert not np.any(np.isnan(result.values)) + + def test_nan_mode_keeps_nan_edges(self): + """Default 'nan' boundary still leaves the outer ring NaN.""" + elev = _east_tilted_surface(H=6, W=8, grade=100.0) + raster = _make_geo_raster(elev, 40.0, 41.0, 10.0, 11.0) + result = aspect(raster, method='geodesic', boundary='nan') + assert np.all(np.isnan(result.values[0, :])) + assert np.all(np.isnan(result.values[-1, :])) + assert np.all(np.isnan(result.values[:, 0])) + assert np.all(np.isnan(result.values[:, -1])) + + +@dask_array_available +class TestGeodesicAspectBoundaryDask: + + @pytest.mark.parametrize("boundary", _NONNAN_BOUNDARY_MODES) + def test_numpy_equals_dask(self, boundary): + elev = _east_tilted_surface(H=8, W=10, grade=100.0) + r_np = _make_geo_raster(elev, 40.0, 41.0, 10.0, 11.0, backend='numpy') + r_da = _make_geo_raster(elev, 40.0, 41.0, 10.0, 11.0, + backend='dask+numpy', chunks=(4, 5)) + a_np = aspect(r_np, method='geodesic', boundary=boundary) + a_da = aspect(r_da, method='geodesic', boundary=boundary) + np.testing.assert_allclose( + a_np.values, a_da.data.compute(), rtol=1e-5, equal_nan=True + ) + + +@cuda_and_cupy_available +class TestGeodesicAspectBoundaryCupy: + + @pytest.mark.parametrize("boundary", _NONNAN_BOUNDARY_MODES) + def test_numpy_equals_cupy(self, boundary): + elev = _east_tilted_surface(H=8, W=10, grade=100.0) + r_np = _make_geo_raster(elev, 40.0, 41.0, 10.0, 11.0, backend='numpy') + r_cu = _make_geo_raster(elev, 40.0, 41.0, 10.0, 11.0, backend='cupy') + a_np = aspect(r_np, method='geodesic', boundary=boundary) + a_cu = aspect(r_cu, method='geodesic', boundary=boundary) + np.testing.assert_allclose( + a_np.values, a_cu.data.get(), rtol=1e-5, equal_nan=True + ) + + +@dask_array_available +@cuda_and_cupy_available +class TestGeodesicAspectBoundaryDaskCupy: + + @pytest.mark.parametrize("boundary", _NONNAN_BOUNDARY_MODES) + def test_numpy_equals_dask_cupy(self, boundary): + elev = _east_tilted_surface(H=8, W=10, grade=100.0) + r_np = _make_geo_raster(elev, 40.0, 41.0, 10.0, 11.0, backend='numpy') + r_dc = _make_geo_raster(elev, 40.0, 41.0, 10.0, 11.0, + backend='dask+cupy', chunks=(4, 5)) + a_np = aspect(r_np, method='geodesic', boundary=boundary) + a_dc = aspect(r_dc, method='geodesic', boundary=boundary) + np.testing.assert_allclose( + a_np.values, a_dc.data.compute().get(), rtol=1e-5, equal_nan=True + )