From 6c6c0e91d77bda2801878290b8785dfc2cca7e0d Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 20 Jun 2026 10:27:52 -0700 Subject: [PATCH 1/2] Add multispectral test coverage for true_color edges and evi/savi validation (#3431) Cover previously-untested branches found by /sweep-test-coverage: - true_color NaN/nodata alpha mask across numpy/dask+numpy/cupy/dask+cupy - true_color all-equal input (range_val==0 divide-by-zero guard) - true_color non-default nodata/c/th parameters - evi validation guards (c1/c2 numeric, soil_factor range, gain>=0) - savi soil_factor out-of-range Test-only; no source changes. GPU variants executed locally (CUDA available). --- .claude/sweep-test-coverage-state.csv | 1 + xrspatial/tests/test_multispectral.py | 125 ++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index f540b6dba..3800c26dd 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -11,6 +11,7 @@ interpolate-kriging,2026-06-04,2920;2921,HIGH,1;2;3;4;5,"Single public fn krigin 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" +multispectral,2026-06-20,3431,MEDIUM,2;3;4,true_color NaN/alpha + all-equal range_val==0 + nondefault nodata/c/th; evi & savi validation error paths; GPU tests ran (cupy+dask+cupy) 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_multispectral.py b/xrspatial/tests/test_multispectral.py index f83abc2a8..7b8cc548d 100644 --- a/xrspatial/tests/test_multispectral.py +++ b/xrspatial/tests/test_multispectral.py @@ -1165,3 +1165,128 @@ def test_osavi_approaches_ndvi_for_large_dn(nir_data, red_data, qgis_ndvi): osavi_vals[mask] = np.nan np.testing.assert_allclose( osavi_vals, ndvi_vals, equal_nan=True, rtol=1e-3) + + +# true_color edge cases and parameters (#3431) ---------- +def _true_color_to_numpy(result): + # Pull a true_color result back to a host numpy array regardless of + # backend (numpy / cupy / dask+numpy / dask+cupy). + data = result.data + if hasattr(data, 'compute'): + data = data.compute() + if hasattr(data, 'get'): + data = data.get() + return np.asarray(data) + + +@pytest.fixture +def true_color_nan_bands(backend): + # Red band carries a NaN and a value at/below the default nodata=1 so + # both legs of the alpha mask (isnan(r) OR r <= nodata) are exercised. + red = np.array([[np.nan, 5000.], [1.0, 8000.]], dtype=np.float64) + green = np.array([[3000., 5000.], [4000., 8000.]], dtype=np.float64) + blue = np.array([[2000., 5000.], [3000., 8000.]], dtype=np.float64) + r = create_test_raster(red, backend=backend, chunks=(2, 2)) + g = create_test_raster(green, backend=backend, chunks=(2, 2)) + b = create_test_raster(blue, backend=backend, chunks=(2, 2)) + return r, g, b + + +@pytest.mark.parametrize("backend", ["numpy", "dask+numpy"]) +def test_true_color_nan_alpha_cpu(true_color_nan_bands): + # NaN input and r <= nodata input must both drive the alpha channel + # to 0 (transparent); the other pixels are opaque (255). + r, g, b = true_color_nan_bands + result = true_color(r, g, b, nodata=1) + alpha = _true_color_to_numpy(result)[:, :, 3] + expected_alpha = np.array([[0, 255], [0, 255]], dtype=np.uint8) + np.testing.assert_array_equal(alpha, expected_alpha) + + +@cuda_and_cupy_available +@pytest.mark.parametrize("backend", ["cupy", "dask+cupy"]) +def test_true_color_nan_alpha_gpu(true_color_nan_bands): + r, g, b = true_color_nan_bands + result = true_color(r, g, b, nodata=1) + alpha = _true_color_to_numpy(result)[:, :, 3] + expected_alpha = np.array([[0, 255], [0, 255]], dtype=np.uint8) + np.testing.assert_array_equal(alpha, expected_alpha) + + +@pytest.mark.parametrize("backend", ["numpy", "dask+numpy"]) +def test_true_color_all_equal_input(backend): + # Zero-range input hits the `range_val != 0` false branch in + # _normalize_data_cpu, leaving the RGB channels at the NaN->0 fill. + const = np.full((3, 3), 5000., dtype=np.float64) + r = create_test_raster(const, backend=backend, chunks=(3, 3)) + g = create_test_raster(const, backend=backend, chunks=(3, 3)) + b = create_test_raster(const, backend=backend, chunks=(3, 3)) + result = true_color(r, g, b) + data = _true_color_to_numpy(result) + assert data.shape == (3, 3, 4) + # range_val == 0 -> normalize returns all-NaN -> uint8 cast yields 0 + np.testing.assert_array_equal(data[:, :, :3], np.zeros((3, 3, 3), dtype=np.uint8)) + # all input > nodata default and not NaN, so alpha is fully opaque + np.testing.assert_array_equal(data[:, :, 3], np.full((3, 3), 255, dtype=np.uint8)) + + +@pytest.mark.parametrize("backend", ["numpy", "dask+numpy"]) +def test_true_color_nondefault_params_change_output(backend): + # Non-default nodata / c / th should change the result relative to the + # defaults, confirming the parameters are threaded through. + rng = np.random.default_rng(3431) + arr = rng.uniform(100, 9000, size=(4, 4)).astype(np.float64) + r = create_test_raster(arr, backend=backend, chunks=(4, 4)) + g = create_test_raster(arr, backend=backend, chunks=(4, 4)) + b = create_test_raster(arr, backend=backend, chunks=(4, 4)) + + default = _true_color_to_numpy(true_color(r, g, b)) + tuned = _true_color_to_numpy(true_color(r, g, b, c=5.0, th=0.5)) + assert not np.array_equal(default[:, :, :3], tuned[:, :, :3]) + + # nodata high enough to mask every pixel -> alpha all 0 + masked = _true_color_to_numpy(true_color(r, g, b, nodata=1e9)) + np.testing.assert_array_equal( + masked[:, :, 3], np.zeros((4, 4), dtype=np.uint8)) + + +# evi / savi validation error paths (#3431) ---------- +@pytest.fixture +def _small_bands(): + data = np.array([[0.5, 0.6], [0.4, 0.3]], dtype=np.float64) + a = xr.DataArray(data, dims=['y', 'x']).assign_coords(y=[0, 1], x=[0, 1]) + return a, a.copy(), a.copy() + + +def test_evi_c1_must_be_numeric(_small_bands): + nir, red, blue = _small_bands + with pytest.raises(ValueError, match='c1 must be numeric'): + evi(nir, red, blue, c1='not-a-number') + + +def test_evi_c2_must_be_numeric(_small_bands): + nir, red, blue = _small_bands + with pytest.raises(ValueError, match='c2 must be numeric'): + evi(nir, red, blue, c2='not-a-number') + + +@pytest.mark.parametrize("bad_soil", [1.5, -1.5]) +def test_evi_soil_factor_out_of_range(_small_bands, bad_soil): + nir, red, blue = _small_bands + with pytest.raises(ValueError, match='soil factor must be between'): + evi(nir, red, blue, soil_factor=bad_soil) + + +def test_evi_negative_gain(_small_bands): + nir, red, blue = _small_bands + with pytest.raises(ValueError, match='gain must be greater than 0'): + evi(nir, red, blue, gain=-1.0) + + +@pytest.mark.parametrize("bad_soil", [1.5, -1.5]) +def test_savi_soil_factor_out_of_range(bad_soil): + data = np.array([[0.5, 0.6], [0.4, 0.3]], dtype=np.float64) + nir = xr.DataArray(data, dims=['y', 'x']).assign_coords(y=[0, 1], x=[0, 1]) + red = nir.copy() + with pytest.raises(ValueError, match='soil factor must be between'): + savi(nir, red, soil_factor=bad_soil) From fae6e461e68428f8d5bd8838e6a5f59d473a4851 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sat, 20 Jun 2026 10:30:21 -0700 Subject: [PATCH 2/2] Address review nit: silence expected NaN->uint8 cast warning in true_color helper (#3431) --- xrspatial/tests/test_multispectral.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/xrspatial/tests/test_multispectral.py b/xrspatial/tests/test_multispectral.py index 7b8cc548d..63967b28e 100644 --- a/xrspatial/tests/test_multispectral.py +++ b/xrspatial/tests/test_multispectral.py @@ -1,3 +1,5 @@ +import warnings + import numpy as np import pytest import xarray as xr @@ -1171,12 +1173,20 @@ def test_osavi_approaches_ndvi_for_large_dn(nir_data, red_data, qgis_ndvi): def _true_color_to_numpy(result): # Pull a true_color result back to a host numpy array regardless of # backend (numpy / cupy / dask+numpy / dask+cupy). - data = result.data - if hasattr(data, 'compute'): - data = data.compute() - if hasattr(data, 'get'): - data = data.get() - return np.asarray(data) + # + # The dask path casts the normalized float buffer (which holds NaN for + # nodata/zero-range cells) to uint8 lazily, so the "invalid value + # encountered in cast" RuntimeWarning surfaces here at compute time + # rather than inside true_color's own catch_warnings block. That NaN->0 + # cast is the documented behaviour these tests assert on, so silence it. + with warnings.catch_warnings(): + warnings.simplefilter('ignore', RuntimeWarning) + data = result.data + if hasattr(data, 'compute'): + data = data.compute() + if hasattr(data, 'get'): + data = data.get() + return np.asarray(data) @pytest.fixture