diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 077bd8e4a..c75d9f2a7 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -9,6 +9,7 @@ interpolate,2026-06-12,3290,MEDIUM,2;3;4;5,"Deep-sweep 2026-06-12 on CUDA host. interpolate-kriging,2026-06-04,2920;2921,HIGH,1;2;3;4;5,"Single public fn kriging(); all 4 backends already had cross-backend parity tests (numpy/cupy/dask+numpy/dask+cupy) incl. cupy & dask+cupy variance -- ran green on CUDA host. Gaps closed (test-only, #2921): Cat1 dask+numpy return_variance branch (_chunk_var) was untested -> added test_dask_return_variance_matches_numpy (atol=1e-12, var ~1e-14). Cat4 nlags only default(15) tested -> added non-default nlags=5 + invalid paths (nlags=0/-1 ValueError, nlags=2.5 TypeError). Cat2/3 two-point <3-lag-bins UserWarning branch -> test_two_point_warns_few_lag_bins. Cat2 all-NaN kriging input -> test_kriging_all_nan_points (only idw covered before). Cat5 output metadata (coords/dims/attrs/name) untested -> added test_output_metadata. Single-point kriging CRASHES (zero-size array reduction in _experimental_variogram, N=1) -- real source bug filed #2920; added xfail(strict, raises=ValueError) test_single_point documenting expected graceful behavior; source fix left to #2920 (test-only PR). LOW/not filed: singular-matrix K_inv-is-None all-NaN branch is defensive and unreachable via public API. GPU-validated." interpolate_spline,2026-06-04,,HIGH,1;3;5,scope=spline-only; cupy+dask_cupy spline backends untested (_tps_cuda_kernel) | n==2 affine branch + metadata untested | added 4 tests to TestSpline all pass on CUDA host | issue-create denied by classifier no GH issue mcda,2026-06-10,3149,HIGH,1;2;5,"Pass 1 (2026-06-10, deep-sweep test-coverage): test_mcda.py had 175 tests, all numpy or dask+numpy -- zero cupy/dask+cupy coverage despite explicit cupy branches in standardize._get_xp and combine._sort_descending (Cat 1 HIGH). Filed #3149, added ~70 tests: cross-backend parity for standardize (7 methods) x cupy/dask+numpy/dask+cupy, combine (wlc/wpm/fuzzy and-or-sum-product-gamma/owa) x 3 backends, constrain, boolean_overlay, sensitivity OAT+MC on GPU backends; metadata preservation (attrs/coords/dims/name) for every stage (Cat 5 MEDIUM); wpm all-NaN criterion + Inf propagation through wlc/fuzzy-and (Cat 2 MEDIUM). All RUN on a CUDA host: 233 passed, 11 xfailed. Probing surfaced real source bugs already filed by sibling sweeps as #3146 (owa raises on ALL dask backends -- _sort_descending calls nonexistent da.sort; owa cupy mixes numpy order weights into cupy stack; piecewise standardize broken on cupy + dask+cupy and categorical on dask+cupy via np.asarray on cupy chunks; monte_carlo sensitivity reads .values on cupy data) and #3147 (constrain drops attrs when masks applied) -- those paths pinned with strict xfail markers to flip on fix; constrain cupy/dask+cupy xfail(strict=False) on the known cupy 13.6 + xarray xr.where dependency incompat, not an mcda bug. Source untouched (test-only PR). LOW (documented, not fixed): name= output parameter untested across combine functions; empty (0-row) raster untested -- elementwise ops, judged low value. weights.py (ahp/rank) is pure-numpy metadata, backend matrix N/A, already well covered." +morphology,2026-06-20,3404,MEDIUM,2;3,"Added Inf/-Inf, all-NaN, Nx1/1xN strip, integer-dtype tests; source already correct, regression guards only; cupy + dask+cupy ran on GPU host" polygon_clip,2026-06-10,3197,MEDIUM,1;2;3;5,"deep-sweep test-coverage 2026-06-10 on a CUDA host. Existing file covered numpy well + one parity test per dask/cupy/dask+cupy backend. Filed #3197 (test-only) and added 13 tests: Cat1 GPU param/NaN coverage (cupy + dask+cupy each get custom nodata, all_touched=True, and NaN-input preservation vs numpy; previously only crop=False inner polygon ran on GPU); Cat2 Inf/-Inf preserved, all-NaN input -> all-NaN, int32 + sentinel nodata=-1; Cat3 Nx1 + 1xN strip rasters; Cat5 coords preserved (crop=False) + crop coords are a contiguous subset of input coords (crop=True). All 13 RAN+PASSED on GPU (6 GPU tests not skipped); full file 36 passed 0 skipped. LOW (documented, NOT fixed): rasterize_kw forwarding never tested; non-square cellsize never tested. SOURCE NOTE (out of scope, not filed): clip_polygon docstring says 'named y and x dims' but rasterize() hard-requires literal y/x, so lat/lon-dim rasters raise -- dim-name preservation (Cat5) is therefore unsupported by contract, not a test gap. SOURCE NOTE 2: polygon_clip.py:216 still passes rasterize(use_cuda=True) on dask+cupy (renamed to gpu= in #3089); harmless deprecation alias today, candidate for an api-consistency follow-up." polygonize,2026-06-12,3299,MEDIUM,1,"Pass 4 (2026-06-12): added test_polygonize_mask_chunk_mismatch_3299.py (25 tests, all passing on a CUDA host incl. dask+cupy). Closes Cat 1 MEDIUM: the _polygonize_dask mask-rechunk branch (mask_data.chunks != dask_data.chunks -> rechunk) was never exercised; every prior dask masked test used mask chunks identical to the raster's. Mismatched layouts pinned against same-backend aligned-mask reference: (6,7) same-grid-shape misalignment (the silent-corruption layout for int rasters), single-chunk (15,18), more-blocks (4,5); int+float rasters, connectivity 4/8, dask+numpy and dask+cupy; plus exact-geometry single-masked-pixel hole anchor. Mutation (delete the rechunk guard) flips all 25 red; clean md5 restore. Full polygonize suite 486 passed / 16 skipped. Test-only; source untouched. Issue #3299. Audit re-confirmed Cat 2/3/4 closed by passes 1-3 and post-2026-05-29 changes (#2913 float-mask fix flipped prior xfails, #3041 has issue-2677 test file, #2673/#2817 covered by batch-invariance and heap tests); Cat 5 N/A (no DataArray output; CRS/transform propagation already tested). | 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." proximity,2026-06-18,2692;3139,MEDIUM,1;4,"Pass 4 (2026-06-18, deep-sweep test-coverage): 1 MEDIUM, 1 LOW. MEDIUM (Cat 4/Cat 1): all three public funcs are @supports_dataset and document Dataset-in/Dataset-out, but no test ever passed a Dataset; the shared decorator is covered generically in test_dataset_support.py which never lists proximity/allocation/direction, so per-variable _process dispatch + attrs/coords round-trip + result.name=None reset were unpinned. Added test_dataset_input_processes_each_variable (3 funcs x 4 backends, 12 tests); numpy+dask+cupy+dask+cupy all RUN and PASS on this CUDA host, expected built from numpy baseline to avoid implicit cupy host conversion. Verified working first -- no source bug, source untouched. Full file 528 passed. LOW (documented, not fixed): public exports euclidean_distance / manhattan_distance have no direct unit test (only great_circle_distance does); both are trivial pure fns exercised indirectly through proximity. || Pass 3 (2026-06-09, deep-sweep test-coverage): module grew since Pass 2 (#2807 metric validation, #2812 GREAT_CIRCLE brute force, #2850/#2851 input validation, #2854/#2908 halo fixes, tie-break routing) and each landed with its own tests; Pass 2's stale LOW (invalid distance_metric fallback) is FIXED and tested (#2807). Found 3 MEDIUM gaps, filed #3139, added 40 tests (all RUN and PASS on a CUDA host; full file 450 passed): (1) Cat 2 integer-dtype raster untested on any backend -- bounded dask pads int arrays with boundary=np.nan which casts to INT_MIN phantom targets, only neutralized because the coordinate-grid pads are real NaNs; pinned int32 x 3 funcs x 4 backends x bounded/unbounded vs float64 numpy baseline + explicit target_values; (2) Cat 1 bounded dask+cupy (_process_dask_cupy) only ever ran EUCLIDEAN; pinned MANHATTAN+GREAT_CIRCLE x 3 funcs with a routing spy; mutation (pad=0) flips all 6 red, clean md5 restore; (3) Cat 3 empty 0-row/0-col raster unpinned; fails fast with IndexError, pinned raises. All behaviors verified correct before tests were added -- no source bug, source untouched. LOW (documented, not fixed): -inf pixel input never tested (+inf is; isfinite is symmetric). || Pass 2 (2026-06-02): added 18 tests to test_proximity.py closing the two MEDIUM gaps Pass 1 left open, all RUN and passing on a CUDA host across numpy/cupy/dask+numpy/dask+cupy (15 cross-backend + 3 error-path). Source untouched. Cat 4 MEDIUM (error path): _process raises ValueError when raster.dims != (y, x) (proximity.py:1043) but no test exercised the swapped x/y guard; test_wrong_dim_order_raises pins it for proximity/allocation/direction. Cat 2 MEDIUM (all-NaN input): Pass 1 noted all-NaN/all-zero on eager numpy+cupy was unpinned; test_all_nan_raster_all_nan_output pins an all-NaN 6x6 raster -> all-NaN float32 output on all four backends x three functions. Remaining LOW (documented): invalid distance_metric string silently falls back to EUCLIDEAN (proximity.py:1049-1051). || PREVIOUS: Pass 1 (2026-05-29): added 65 tests to test_proximity.py closing three coverage gaps, all RUN and passing on a CUDA host (numpy/cupy/dask+numpy/dask+cupy). Issue #2692, PR opened. Source untouched. Cat 3 HIGH: degenerate raster shapes (1x1 single pixel, Nx1 column strip, 1xN row strip) had zero coverage for proximity/allocation/direction on any backend; they stress the line-sweep kernel boundaries (_process_proximity_line) and the GPU brute-force kernel grid sizing (_proximity_cuda_kernel via cuda_args). Pinned all three shapes x three functions x four backends against hand-checked expected values; mutation of a pinned direction expectation confirms teeth. Cat 1/4 HIGH: allocation and direction only ran EUCLIDEAN across backends; MANHATTAN and GREAT_CIRCLE were cross-backend-tested for proximity only. Pinned both metrics x two functions x four backends against the numpy baseline (all match). Cat 5 MEDIUM: no test set non-empty res/crs attrs so the attrs-preservation assertion in general_output_checks compared two empty dicts. proximity reads attrs['res'] via get_dataarray_resolution for bounded-dask chunk padding, so added attrs round-trip tests on four backends plus a bounded-dask test where a res attr matching the coordinate spacing must equal the numpy baseline. A res attr that lies about the spacing mis-sizes the map_overlap depth; source fragility, not a test gap, left for a separate accuracy issue. Cat 2 (NaN/Inf input) already covered by the shared test_raster fixture (embeds np.inf and np.nan, runs on four backends). Remaining LOW: all-NaN / all-zero input on eager numpy+cupy not directly pinned." diff --git a/xrspatial/tests/test_morphology.py b/xrspatial/tests/test_morphology.py index 8bf066768..848ac8c52 100644 --- a/xrspatial/tests/test_morphology.py +++ b/xrspatial/tests/test_morphology.py @@ -482,6 +482,152 @@ def test_dilate_centre_zero_cupy_matches_numpy(): np.testing.assert_allclose(np_res.data, cp_res.data.get()) +# --------------------------------------------------------------------------- +# Infinite inputs (issue #3404) +# --------------------------------------------------------------------------- + +def test_erode_dilate_with_inf(): + """+Inf and -Inf must flow through min/max without becoming NaN.""" + data = np.array([ + [1., 5., 3.], + [4., np.inf, 6.], + [2., 9., 7.], + ], dtype=np.float64) + agg = create_test_raster(data) + + eroded = morph_erode(agg, kernel=_KERNEL_3x3, boundary='nearest') + dilated = morph_dilate(agg, kernel=_KERNEL_3x3, boundary='nearest') + # +Inf is the local max, never the local min. + assert eroded.data[1, 1] == 1.0 + assert np.isinf(dilated.data[1, 1]) + # A neighbour of +Inf takes it as its dilation max but not its erosion min. + assert np.isinf(dilated.data[0, 1]) + assert not np.isinf(eroded.data[0, 1]) + + data_neg = data.copy() + data_neg[1, 1] = -np.inf + agg_neg = create_test_raster(data_neg) + eroded_neg = morph_erode(agg_neg, kernel=_KERNEL_3x3, boundary='nearest') + # -Inf is the local min. + assert eroded_neg.data[1, 1] == -np.inf + + +@dask_array_available +def test_erode_dilate_inf_dask_matches_numpy(): + """Inf handling must match between numpy and dask backends.""" + data = np.array([ + [1., 5., 3., 2.], + [4., np.inf, 6., 1.], + [2., 9., 7., 5.], + [3., 1., 4., -np.inf], + ], dtype=np.float64) + numpy_agg = create_test_raster(data, backend='numpy') + dask_agg = create_test_raster(data, backend='dask') + for fn in (morph_erode, morph_dilate): + np_res = fn(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + dk_res = fn(dask_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + np_res.data, dk_res.data.compute(), equal_nan=True, + ) + + +@cuda_and_cupy_available +def test_erode_dilate_inf_cupy_matches_numpy(): + data = np.array([ + [1., 5., 3., 2.], + [4., np.inf, 6., 1.], + [2., 9., 7., 5.], + [3., 1., 4., -np.inf], + ], dtype=np.float64) + numpy_agg = create_test_raster(data, backend='numpy') + cupy_agg = create_test_raster(data, backend='cupy') + for fn in (morph_erode, morph_dilate): + np_res = fn(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + cp_res = fn(cupy_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + np_res.data, cp_res.data.get(), equal_nan=True, + ) + + +# --------------------------------------------------------------------------- +# All-NaN raster (issue #3404) +# --------------------------------------------------------------------------- + +def test_all_nan_raster_stays_nan(): + """Every cell sees only NaN neighbours, so output is all NaN.""" + data = np.full((4, 4), np.nan, dtype=np.float64) + agg = create_test_raster(data) + for fn in (morph_erode, morph_dilate, morph_opening, morph_closing): + result = fn(agg, kernel=_KERNEL_3x3, boundary='nearest') + assert np.all(np.isnan(result.data)) + + +@dask_array_available +def test_all_nan_raster_dask_matches_numpy(): + data = np.full((6, 6), np.nan, dtype=np.float64) + numpy_agg = create_test_raster(data, backend='numpy') + dask_agg = create_test_raster(data, backend='dask') + for fn in (morph_erode, morph_dilate): + np_res = fn(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + dk_res = fn(dask_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + np_res.data, dk_res.data.compute(), equal_nan=True, + ) + + +# --------------------------------------------------------------------------- +# Degenerate strip rasters (issue #3404) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("data", [ + np.array([[1.], [5.], [3.], [2.], [7.]], dtype=np.float64), # Nx1 column + np.array([[1., 5., 3., 2., 7.]], dtype=np.float64), # 1xN row +]) +def test_strip_raster_numpy(data): + """Single-column / single-row rasters keep their shape and stay finite.""" + agg = create_test_raster(data) + for fn in (morph_erode, morph_dilate): + result = fn(agg, kernel=_KERNEL_3x3, boundary='nearest') + assert result.shape == data.shape + assert not np.any(np.isnan(result.data)) + + +@dask_array_available +@pytest.mark.parametrize("data", [ + np.array([[1.], [5.], [3.], [2.], [7.]], dtype=np.float64), # Nx1 column + np.array([[1., 5., 3., 2., 7.]], dtype=np.float64), # 1xN row +]) +def test_strip_raster_dask_matches_numpy(data): + """Strip rasters exercise the map_overlap path with a degenerate axis.""" + numpy_agg = create_test_raster(data, backend='numpy') + dask_agg = create_test_raster(data, backend='dask') + for fn in (morph_erode, morph_dilate): + np_res = fn(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + dk_res = fn(dask_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + np_res.data, dk_res.data.compute(), equal_nan=True, + ) + + +# --------------------------------------------------------------------------- +# Integer-dtype input (issue #3404) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("dtype", [np.int32, np.int64, np.uint8]) +def test_integer_input_promoted(dtype): + """Integer rasters are accepted and promoted to float64.""" + data = np.array([ + [1, 5, 3], + [4, 8, 6], + [2, 9, 7], + ], dtype=dtype) + agg = create_test_raster(data) + result = morph_erode(agg, kernel=_KERNEL_3x3, boundary='nearest') + assert result.dtype == np.float64 + # Interior min of the 3x3 neighbourhood is 1. + assert result.data[1, 1] == 1.0 + + # --------------------------------------------------------------------------- # Dataset support # ---------------------------------------------------------------------------