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 @@ -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<min_val guards were never exercised: quantile/natural_breaks/maximum_breaks 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)."
Expand Down
191 changes: 189 additions & 2 deletions xrspatial/tests/test_convolution.py
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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)
Loading