From e5aa3c664b3be77c5c7be5a69e3a774bbaa1cf90 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Thu, 4 Jun 2026 08:42:20 -0700 Subject: [PATCH] Add GPU and edge-case test coverage for spline() TestSpline only exercised numpy and dask+numpy. The cupy and dask+cupy paths (including the _tps_cuda_kernel CUDA kernel) ran with no tests, unlike kriging which has full GPU parity coverage. Adds, all test-only: - test_cupy_matches_numpy, test_dask_cupy_matches_numpy (GPU parity, guarded with cuda_and_cupy_available / dask_array_available) - test_two_point_affine_fit covering the n==2 least-squares branch in _tps_build_and_solve - test_output_metadata asserting coords/dims/attrs/name preservation Verified all four spline backends green on a CUDA host (45 passed). deep-sweep test-coverage (scope=spline-only); GH issue creation was denied by the auto-mode classifier, so no issue is linked. --- .claude/sweep-test-coverage-state.csv | 1 + xrspatial/tests/test_interpolation.py | 56 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 758743067..986865bd3 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -42,3 +42,4 @@ resample,2026-05-29,2547;2615,HIGH,1;2;3;5,"Pass 2 (2026-05-29): added test_resa slope,2026-05-29,2697,MEDIUM,3,"PR #2703: added degenerate-shape tests (1x1/1xN/Nx1) for all 4 planar backends + geodesic; no live bug, pins all-NaN+shape contract. CUDA host: cupy/dask+cupy ran. Backend/NaN/param/metadata coverage already complete." zonal,2026-05-29,2619,MEDIUM,1,"Pass 2 (2026-05-29): one Cat 1 MEDIUM backend-coverage gap remained after pass 1 -- 3D crosstab on cupy / dask+cupy. The 3D GPU paths (_crosstab_cupy / _crosstab_dask_cupy with a 3D categorical values array, layer=, agg='count') were reachable and correct but untested; the existing 3D crosstab tests (test_crosstab_3d_count, test_crosstab_3d_agg_method, test_nodata_values_crosstab_3d) only parametrize numpy / dask+numpy. Added 3 parity tests to test_zonal_backend_coverage_2026_05_27.py (test_crosstab_3d_count_cupy_matches_numpy, test_crosstab_3d_count_dask_cupy_matches_numpy, test_crosstab_3d_nodata_cupy_matches_numpy) asserting cupy and dask+cupy results match numpy for agg='count' including a nodata_values case. All passed live on a CUDA host. Issue #2619, PR #2625. Test-only, no source change. Remaining LOW (documented, not fixed): get_full_extent has no direct unit test (exercised indirectly via suggest_zonal_canvas); non-square cellsize handling not exercised. Pass 1 (2026-05-27): added test_zonal_backend_coverage_2026_05_27.py with 32 tests, all passing on a CUDA host. Closes Cat 1 HIGH backend-coverage gaps: crosstab cupy + dask+cupy (_crosstab_cupy / _crosstab_dask_cupy were dispatched but never invoked by tests), regions cupy + dask+cupy (_regions_cupy via cupyx.scipy.ndimage + _regions_dask_cupy), trim dask+numpy + cupy + dask+cupy (_trim_bounds_dask isnan path and cupy data.get() path), crop dask+numpy + cupy + dask+cupy (_crop_bounds_dask + cupy data.get() path), apply 3D cupy + dask+cupy (per-layer kernel launch over the third axis in _apply_cupy and _apply_dask_cupy). Existing test_zonal.py covered only numpy + dask+numpy for crosstab/regions/trim/crop and 2D-only for cupy apply. Closes Cat 3 MEDIUM 1x1 / 1xN / Nx1 strip edge cases for trim, crop, and regions. Closes Cat 4 LOW pins: regions(neighborhood=6) ValueError, suggest_zonal_canvas(crs='Geographic') aspect-ratio pin and invalid-crs KeyError, crosstab cupy zone_ids/cat_ids filter, crosstab cupy agg='percentage'. Closes Cat 5 MEDIUM: regions coords/attrs propagation across numpy + dask+numpy, trim/crop name='trim'/'crop' default + attrs preservation. Also pins the documented numpy-vs-dask trim asymmetry on NaN sentinel (numpy _trim does equality which never matches NaN; dask _trim_bounds_dask has dedicated isnan branch). Mutation against the cupy.asnumpy() conversion in _crosstab_cupy flipped test_crosstab_cupy_matches_numpy red. Source untouched." focal,2026-05-29,2732,HIGH,1,"Pass (2026-05-29): added test_hotspots_dask_cupy to test_focal.py closing Cat 1 HIGH backend-coverage gap. hotspots() registers dask_cupy_func=_hotspots_dask_cupy (focal.py L1414) but no test invoked it, while mean/apply/focal_stats each have a dedicated dask+cupy test. New test compares dask+cupy vs numpy on chunk interior (matches test_apply_dask_cupy/test_focal_stats_dask_cupy style). RUN on CUDA host: passes; spy confirmed routing through _hotspots_dask_cupy; path matches numpy exactly so no source fix needed. LOW (documented not fixed): Inf/-Inf inputs untested across focal funcs; 1x1 raster not explicitly tested for mean/apply/hotspots (focal_stats 1x1 covered by test_variety_single_cell). Issue #2732." +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 diff --git a/xrspatial/tests/test_interpolation.py b/xrspatial/tests/test_interpolation.py index d0d54f5e4..2aafb4cfc 100644 --- a/xrspatial/tests/test_interpolation.py +++ b/xrspatial/tests/test_interpolation.py @@ -208,6 +208,37 @@ def test_single_point(self): result = spline([0.5], [0.5], [42.0], template, smoothing=0.0) np.testing.assert_allclose(result.values, 42.0, atol=1e-6) + def test_two_point_affine_fit(self): + """n == 2 falls back to a least-squares affine fit. + + With two points the full TPS system is underdetermined, so + _tps_build_and_solve fits z = a0 + a1*x + a2*y instead. Two + points on the x-axis with z = 10 and 20 define the gradient + along x; the midpoint should read 15. + """ + x = np.array([0.0, 2.0]) + y = np.array([0.0, 0.0]) + z = np.array([10.0, 20.0]) + template = _make_template([0.0], [0.0, 1.0, 2.0]) + result = spline(x, y, z, template, smoothing=0.0) + np.testing.assert_allclose( + result.values, [[10.0, 15.0, 20.0]], atol=1e-6) + + def test_output_metadata(self): + """Output DataArray preserves template coords, dims, and name.""" + x, y, z = _grid_points() + template = _make_template([0.0, 1.0, 2.0], [0.0, 1.0, 2.0]) + template.attrs['res'] = (1.0, 1.0) + result = spline(x, y, z, template, name='my_spline') + assert result.name == 'my_spline' + assert result.dims == template.dims + assert result.shape == template.shape + assert result.attrs == template.attrs + np.testing.assert_array_equal(result.coords['x'].values, + template.coords['x'].values) + np.testing.assert_array_equal(result.coords['y'].values, + template.coords['y'].values) + @dask_array_available def test_dask_matches_numpy(self): x, y, z = _grid_points() @@ -219,6 +250,31 @@ def test_dask_matches_numpy(self): np.testing.assert_allclose( np_result.values, da_result.values, rtol=1e-10) + @cuda_and_cupy_available + def test_cupy_matches_numpy(self): + """CuPy backend produces same results as numpy.""" + x, y, z = _grid_points() + np_template = _make_template([0.0, 1.0, 2.0], [0.0, 1.0, 2.0]) + cp_template = _make_template([0.0, 1.0, 2.0], [0.0, 1.0, 2.0], + backend='cupy') + np_result = spline(x, y, z, np_template) + cp_result = spline(x, y, z, cp_template) + np.testing.assert_allclose( + np_result.values, _to_numpy(cp_result), rtol=1e-10) + + @cuda_and_cupy_available + @dask_array_available + def test_dask_cupy_matches_numpy(self): + """Dask+CuPy backend produces same results as numpy.""" + x, y, z = _grid_points() + np_template = _make_template([0.0, 1.0, 2.0], [0.0, 1.0, 2.0]) + dc_template = _make_template([0.0, 1.0, 2.0], [0.0, 1.0, 2.0], + backend='dask_cupy', chunks=(2, 2)) + np_result = spline(x, y, z, np_template) + dc_result = spline(x, y, z, dc_template) + np.testing.assert_allclose( + np_result.values, _to_numpy(dc_result), rtol=1e-10) + # =================================================================== # Kriging tests