Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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