Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/sweep-test-coverage-state.csv
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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."
Expand Down
135 changes: 135 additions & 0 deletions xrspatial/tests/test_multispectral.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import warnings

import numpy as np
import pytest
import xarray as xr
Expand Down Expand Up @@ -1165,3 +1167,136 @@ 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).
#
# 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
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)
Loading