diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 60459b444..33c02e713 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -2,6 +2,7 @@ module,last_inspected,issue,severity_max,categories_found,branch_cov,notes aspect,2026-06-21,3439,MEDIUM,2,,"#3439 (Cat 2): Inf/-Inf elevation input and all-NaN raster were untested. Added Inf-input finite-neighbors + 4-backend parity, and all-NaN -> all-NaN shape-preserved for aspect/northness/eastness on all 4 backends; GPU-validated (CUDA available). Both behaviors defined and consistent across backends -> coverage gap, not a bug (supersedes prior 'Inf possible source bug, not filed' note). Prior: #2742 degenerate shapes + geodesic boundary modes; #2829 northness/eastness geodesic branch -- all still covered." classify,2026-06-20,,MEDIUM,3;4,,"Deep-sweep 2026-06-20 test-coverage on a CUDA host. Backend matrix was already complete: all 10 public classifiers (binary/reclassify/quantile/natural_breaks/equal_interval/std_mean/head_tail_breaks/percentiles/maximum_breaks/box_plot) x 4 backends present and green (Cat 1 no gap). Cat 2 (NaN/Inf) covered: input_data() fixture embeds -inf/nan/+inf at corners on every numpy/dask/cupy test, plus all-nan/all-inf/all-same dedicated tests. Cat 5 covered via general_output_checks(verify_attrs=True) asserting attrs/dims/coords on every backend test. Found two MEDIUM gaps: Cat 3 (no 1x1 single-pixel, no Nx1/1xN strip tests for any classifier) and Cat 4 (the _validate_scalar k=2, equal_interval k>=1). Probed live: single-pixel and strip shapes all work correctly (no source bug -- lone finite pixel -> class 0; binary match -> 1; reclassify -> bin's new_value), so these are untested-but-passing paths. Added test-only (numpy, since the gaps are in shared CPU-side validation + bin-edge logic and the 4-backend dispatch is already fully covered): test_classify_single_pixel, test_binary_single_pixel, test_reclassify_single_pixel, test_classify_nx1_strip, test_classify_1xn_strip, and 4 k-below-min ValueError tests. All RAN and PASSED on the CUDA host; full file 98 passed. classify.py untouched. LOW (documented, not fixed): empty raster (0 rows) raises on numpy equal_interval (zero-size reduction) and differs across backends -- degenerate input, not pinned. Non-square cellsize not exercised (classifiers ignore cellsize, so out of scope)." contour,2026-06-08,2704;2710;3044,MEDIUM,1;2,,"Pass 2 (2026-06-08, deep-sweep test-coverage): re-swept on a CUDA host. Verified issue #2704 is CLOSED (fixed 2026-06-01 via #2749, kernel now uses np.isfinite at contour.py:73-74); the two prior xfail(strict) #2704 pins were already flipped to plain passing assertions in TestInfHandling (test_inf_corner_no_nan_coords / test_neg_inf_corner_no_nan_coords) -- no stale xfail remained. Found one MEDIUM Cat 1+2 gap: no cross-backend parity test fed NaN input -- TestBackendEquivalence uses elevation_raster_no_nans and test_partial_nan is numpy-only, so numpy-interior NaN-skip vs dask NaN-halo (da.overlap) parity at chunk edges was unpinned. Filed #3044, added TestNaNBackendParity (3 tests: dask, cupy, dask+cupy each assert _segments_by_level equality vs numpy on a partial-NaN ramp with a NaN edge row + interior NaN cell inside a non-edge chunk). All 3 RAN and PASSED on a CUDA host; full file 89 passed, 0 skipped. Probed and verified now-resolved: the prior-pass LOW items are no longer real gaps -- the levels=None all-NaN early-return IS asserted (result==[]) on all 4 backends (numpy L148/dask L163/cupy L178/dask+cupy L194) plus geopandas (test_geopandas_all_nan_keeps_crs). LOW (documented, NOT fixed): non-square cellsize (res[0]!=res[1]) still never exercised -- all tests use create_test_raster res (0.5,0.5); probed live that anisotropic coords transform correctly (y scaled 2.0, x scaled 0.5 -> crossing x=1.25, y spans 0..8), works, so it is a LOW coverage gap not a bug. Cat 3 1x1/Nx1/1xN remain rejected by the >=2x2 guard (tested). Test-only PR for #3044; contour.py untouched. | 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)." +convolution,2026-07-02,,HIGH,1;2;3;4;5,72,"Deep-sweep 2026-07-02 test-coverage on a CUDA host. Baseline 52% branch cov, 5 tests (all error-path/numpy-only). convolve_2d dispatches numpy/cupy/dask+numpy/dask+cupy but only the numpy default-boundary path was exercised. HIGH gaps found+closed (test-only, convolution.py untouched): Cat1 backend parity -- added convolution_2d numpy==dask+numpy, numpy==cupy, numpy==dask+cupy (all RAN+PASSED on GPU). Cat4 param coverage -- only default boundary='nan' was tested; added assert_boundary_mode_correctness over nan/nearest/reflect/wrap on numpy+dask and a GPU cupy/dask+cupy==numpy loop over all 4 modes, plus invalid-boundary ValueError. Cat5 metadata -- convolution_2d re-attaches coords/dims/attrs/name but nothing asserted it; added preserves-metadata + custom-name tests. MEDIUM: Cat2 NaN-interior propagation; Cat3 degenerate 1x1/1xN/Nx1 all-NaN (no crash); plus previously-untested public funcs calc_cellsize (res attr + km->m unit conversion), annulus_kernel ring, custom_kernel valid/non-ndarray/even-dims, circle_kernel bad-radius/bad-unit. Full file 26 passed incl 3 GPU tests. branch_cov=72 is CPU-measurable (NUMBA_DISABLE_JIT=1 green run, GPU tests deselected); the @cuda.jit cupy kernel lines are invisible to CPU coverage but are exercised by the 3 passing GPU tests. No source bug surfaced. flake8/isort clean." corridor,2026-06-22,,HIGH,1;3;4;5,,"Deep-sweep 2026-06-22 test-coverage on a CUDA host. least_cost_corridor is the only public function; it delegates all backend math to cost_distance (4 backends) plus pure xarray arithmetic. Backend matrix for the core paths was already complete: symmetry/optimal-path/abs-threshold/rel-threshold/barrier/unreachable each parametrized over numpy/dask+numpy/cupy/dask+cupy. Cat 2 (NaN barriers + all-NaN unreachable) covered. Found gaps (all paths probed live and work -> coverage gaps, not source bugs): Cat 3 HIGH (no Nx1/1xN strip), Cat 5 HIGH (no attrs/coords/dim-name preservation test; corridor reads res for cost_distance math), Cat 1 MEDIUM (no numpy==other-backend exact equivalence assertion -- per-backend property checks only), Cat 4 MEDIUM (connectivity=4 and finite max_cost forwarding untested at corridor level). Added test-only: test_1xn_strip, test_nx1_strip, test_numpy_matches_other_backends (dask+numpy/cupy/dask+cupy), test_connectivity_4, test_max_cost_forwarded, test_attrs_coords_preserved, test_custom_dim_names_preserved. All RAN and PASSED on the CUDA host (cupy + dask+cupy not skipped); full file 41 passed." cost_distance,2026-06-16,3367,MEDIUM,1;2,,"Pass (2026-06-16 deep-sweep test-coverage, CUDA host). cost_distance is heavily tested: 1122 test lines for 1354 src lines, all 4 backends parametrized + regression tests for #1191/#880/#1252/#1262/#3340/#3341/#3343/#3344. Found one MEDIUM Cat 1+2 gap: _cost_distance_dask f_min<=0 early return (all-impassable friction, finite max_cost -> da.full NaN preserving chunks) was unreached -- numpy equiv covered by test_source_on_impassable_cell, iterative dask by test_iterative_narrow_corridor, but the bounded map_overlap wrapper shortcut was not. Filed #3367, added test_dask_all_impassable_friction_returns_nan (all-zero friction, dask+numpy chunks(3,3), max_cost=5; asserts all-NaN, dask-backed, npartitions>1). RAN + PASSED; -W error::UserWarning confirms early return taken (no iterative warning). Full file 85 passed on CUDA host. LOW (documented, not fixed): non-square cellsize numeric correctness untested (_make_meta_raster uses res=(2,3) but test_metadata_preserved checks metadata only)." dasymetric,2026-06-20,3407;3406,HIGH,2;3;4;5,,"deep-sweep test-coverage on a CUDA host (CUDA available, GPU tests ran). Module is well covered (813 test loc / 834 src): 4-backend equivalence for disaggregate weighted+binary, conservation, NaN/nodata/negative-weight, limiting_variable + cupy/dask NotImplemented guards, pycnophylactic numpy+cupy+dask-raises, validate_disaggregation all backends, memory guards (#1261). Filed #3407 (test-only) for real gaps and added 4 new test classes (11 passed, 2 xfailed). Cat5 HIGH: metadata (attrs res/crs + coords) never asserted -> TestMetadataPreservation (numpy/dask). Cat3 HIGH: true 1x1 raster untested (only 1x2 strip) -> TestSinglePixel for disaggregate weighted/binary + pycnophylactic (degenerate no-shift smoothing) + dask parity. Cat2 MEDIUM: Inf weight collapses zone total to 0 (silent conservation break) -> TestInfWeight pins current behaviour. Cat4 MEDIUM: 3-class limiting_variable (multi-break + per-class caps) untested despite docstring -> TestLimitingVariableThreeClass. SOURCE BUG found (filed #3406, NOT fixed - test-only sweep): pycnophylactic raises ValueError (np.nanmax on zero-size array) when no pixel is valid for smoothing (all-NaN zones or no zone id in values); disaggregate handles same input gracefully (all-NaN). Pinned with TestPycnophylacticEmptyValid xfail(strict, raises=ValueError) -> flips red when #3406 fixed. LOW (documented, not fixed): non-square cellsize never exercised (all tests use res 0.5/0.5); disaggregate cupy/dask+cupy 1x1 + metadata not separately added (eager numpy gap was the real one, GPU dispatch already covered by TestCrossBackend)." diff --git a/xrspatial/tests/test_convolution.py b/xrspatial/tests/test_convolution.py index dff4af4a7..4ece407e5 100644 --- a/xrspatial/tests/test_convolution.py +++ b/xrspatial/tests/test_convolution.py @@ -1,9 +1,16 @@ +from functools import partial + import numpy as np import pytest import xarray as xr -from xrspatial.convolution import circle_kernel, convolve_2d, custom_kernel - +from xrspatial.convolution import (annulus_kernel, calc_cellsize, circle_kernel, convolution_2d, + convolve_2d, custom_kernel) +from xrspatial.tests.general_checks import (assert_boundary_mode_correctness, + assert_numpy_equals_cupy, assert_numpy_equals_dask_cupy, + assert_numpy_equals_dask_numpy, create_test_raster, + cuda_and_cupy_available, dask_array_available) +from xrspatial.utils import VALID_BOUNDARY_MODES KERNEL = circle_kernel(1, 1, 1) @@ -48,3 +55,183 @@ def test_convolve_2d_accepts_float64(): # Centre cell is finite; edges are NaN by default boundary mode. assert np.isfinite(out[2, 2]) assert np.isnan(out[0, 0]) + + +# --------------------------------------------------------------------------- +# Backend parity (Cat 1) -- convolve_2d dispatches to numpy / cupy / +# dask+numpy / dask+cupy via ArrayTypeFunctionMapping, but only the numpy +# path was exercised. Assert the other three registered backends match numpy. +# --------------------------------------------------------------------------- + +def _sample_raster_data(): + return np.arange(7 * 8, dtype=np.float64).reshape(7, 8) + + +@dask_array_available +def test_convolution_2d_numpy_equals_dask_numpy(): + data = _sample_raster_data() + numpy_agg = create_test_raster(data, backend='numpy') + dask_agg = create_test_raster(data, backend='dask+numpy') + assert_numpy_equals_dask_numpy( + numpy_agg, dask_agg, partial(convolution_2d, kernel=KERNEL)) + + +@cuda_and_cupy_available +def test_convolution_2d_numpy_equals_cupy(): + data = _sample_raster_data() + numpy_agg = create_test_raster(data, backend='numpy') + cupy_agg = create_test_raster(data, backend='cupy') + assert_numpy_equals_cupy( + numpy_agg, cupy_agg, partial(convolution_2d, kernel=KERNEL)) + + +@cuda_and_cupy_available +def test_convolution_2d_numpy_equals_dask_cupy(): + data = _sample_raster_data() + numpy_agg = create_test_raster(data, backend='numpy') + dask_cupy_agg = create_test_raster(data, backend='dask+cupy') + assert_numpy_equals_dask_cupy( + numpy_agg, dask_cupy_agg, partial(convolution_2d, kernel=KERNEL)) + + +# --------------------------------------------------------------------------- +# Boundary-mode parameter coverage (Cat 4) -- only the default 'nan' mode was +# exercised. 'nearest', 'reflect', and 'wrap' are documented public modes. +# --------------------------------------------------------------------------- + +@dask_array_available +def test_convolution_2d_boundary_modes_numpy_and_dask(): + data = _sample_raster_data() + numpy_agg = create_test_raster(data, backend='numpy') + dask_agg = create_test_raster(data, backend='dask+numpy') + assert_boundary_mode_correctness( + numpy_agg, dask_agg, partial(convolution_2d, kernel=KERNEL), depth=1) + + +@cuda_and_cupy_available +def test_convolution_2d_boundary_modes_gpu_match_numpy(): + data = _sample_raster_data() + numpy_agg = create_test_raster(data, backend='numpy') + cupy_agg = create_test_raster(data, backend='cupy') + dask_cupy_agg = create_test_raster(data, backend='dask+cupy') + for mode in VALID_BOUNDARY_MODES: + expected = convolution_2d(numpy_agg, KERNEL, boundary=mode).data + got_cupy = convolution_2d(cupy_agg, KERNEL, boundary=mode).data.get() + got_dask_cupy = convolution_2d( + dask_cupy_agg, KERNEL, boundary=mode).data.compute().get() + np.testing.assert_allclose(expected, got_cupy, equal_nan=True, rtol=1e-6) + np.testing.assert_allclose( + expected, got_dask_cupy, equal_nan=True, rtol=1e-6) + + +def test_convolve_2d_rejects_invalid_boundary(): + data = _sample_raster_data() + with pytest.raises(ValueError, match="boundary must be one of"): + convolve_2d(data, KERNEL, boundary='bogus') + + +# --------------------------------------------------------------------------- +# Metadata preservation (Cat 5) -- convolution_2d exists to wrap the raw-array +# convolve_2d and re-attach coords/dims/attrs/name. Nothing asserted that. +# --------------------------------------------------------------------------- + +def test_convolution_2d_preserves_metadata(): + data = _sample_raster_data() + agg = create_test_raster(data, backend='numpy') + out = convolution_2d(agg, KERNEL) + assert out.name == 'convolution_2d' + assert out.dims == agg.dims + assert out.attrs == agg.attrs + for coord in agg.coords: + np.testing.assert_allclose(out[coord].data, agg[coord].data) + + +def test_convolution_2d_custom_name(): + data = _sample_raster_data() + agg = create_test_raster(data, backend='numpy') + out = convolution_2d(agg, KERNEL, name='smoothed') + assert out.name == 'smoothed' + + +# --------------------------------------------------------------------------- +# NaN edge case (Cat 2) -- a NaN inside the raster must propagate through the +# kernel window, not be silently ignored. +# --------------------------------------------------------------------------- + +def test_convolve_2d_nan_interior_propagates(): + data = np.arange(7 * 7, dtype=np.float64).reshape(7, 7) + data[3, 3] = np.nan + out = convolve_2d(data, KERNEL) + # Every inner cell whose 3x3 circle window overlaps (3, 3) is NaN. + assert np.isnan(out[3, 3]) + assert np.isnan(out[2, 3]) + assert np.isnan(out[3, 2]) + # A distant inner cell is unaffected and finite. + assert np.isfinite(out[1, 1]) + + +# --------------------------------------------------------------------------- +# Geometric degeneracies (Cat 3) -- a 3x3 kernel on a raster with a dimension +# smaller than the kernel leaves no valid inner cells; output is all-NaN and +# does not crash. +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("shape", [(1, 1), (1, 8), (8, 1)]) +def test_convolve_2d_degenerate_shapes_all_nan(shape): + data = np.ones(shape, dtype=np.float64) + out = convolve_2d(data, KERNEL) + assert out.shape == shape + assert np.all(np.isnan(out)) + + +# --------------------------------------------------------------------------- +# Kernel generators and calc_cellsize -- previously untested public functions. +# --------------------------------------------------------------------------- + +def test_calc_cellsize_from_res_attr(): + data = np.ones((10, 10)) + raster = xr.DataArray(data, attrs={'res': (0.5, 0.5)}) + assert calc_cellsize(raster) == (0.5, 0.5) + + +def test_calc_cellsize_unit_conversion_km(): + # 'unit' attr in km converts cellsize to meters; y is returned abs(). + data = np.ones((3, 4)) + raster = xr.DataArray(data, dims=['y', 'x'], attrs={'unit': 'km'}) + raster['y'] = np.linspace(3, 1, 3) + raster['x'] = np.linspace(1, 4, 4) + cx, cy = calc_cellsize(raster) + assert cx == pytest.approx(1000.0) + assert cy == pytest.approx(1000.0) + + +def test_annulus_kernel_is_ring(): + kernel = annulus_kernel(1, 1, 3, 1) + # Ring shape: centre cell (inner radius) is hollowed out. + assert kernel.shape == (7, 7) + assert kernel[3, 3] == 0 + assert set(np.unique(kernel)).issubset({0.0, 1.0}) + + +def test_custom_kernel_accepts_valid_odd_2d(): + kernel = np.ones((3, 3), dtype=np.float64) + assert custom_kernel(kernel) is kernel + + +def test_custom_kernel_rejects_non_ndarray(): + with pytest.raises(ValueError, match="not a Numpy array"): + custom_kernel([[1, 0, 1], [0, 1, 0], [1, 0, 1]]) + + +def test_custom_kernel_rejects_even_dims(): + with pytest.raises(ValueError, match="improper dimensions"): + custom_kernel(np.ones((2, 2), dtype=np.float64)) + + +@pytest.mark.parametrize("bad_radius, match", [ + (-3, "positive"), + ('3 lightyears', "Distance unit should be one of"), +]) +def test_circle_kernel_rejects_bad_radius(bad_radius, match): + with pytest.raises(ValueError, match=match): + circle_kernel(1, 1, bad_radius)