Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .claude/sweep-error-handling-state.csv
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
module,last_inspected,issue,severity_max,categories_found,notes
bump,2026-07-02,,HIGH,1;2;3;4,"bump() agg template unvalidated: plain ndarray -> ArrayTypeFunctionMapping 'Unsupported Array Type'; 3D/1D DataArray -> 'too many values to unpack'. count/spread unvalidated (internal numpy errors / silent). Added _validate_raster(agg,ndim=2) + _validate_scalar for count,spread. all 4 backends verified (CUDA present)."
geotiff,2026-07-02,3604,MEDIUM,2;4,"to_geotiff 0D/1D DataArray raised opaque IndexError from _coords.py coords_to_transform (dims[-2]) instead of clean 'Expected 2D or 3D' ValueError; numpy path + 4D DataArray already clean. Fixed via early ndim guard before dispatch (eager/vrt/gpu) + 3 tests; PR #3604. Read-side param validation + typed-error hierarchy + allow_rotated/allow_invalid_nodata VRT+chunked opt-in threading verified clean (CUDA available, GPU paths run). gh issue create blocked by auto-mode; PR opened. Cat 2+4."
module,last_inspected,issue,severity_max,categories_found,notes
bump,2026-07-02,,HIGH,1;2;3;4,"bump() agg template unvalidated: plain ndarray -> ArrayTypeFunctionMapping 'Unsupported Array Type'; 3D/1D DataArray -> 'too many values to unpack'. count/spread unvalidated (internal numpy errors / silent). Added _validate_raster(agg,ndim=2) + _validate_scalar for count,spread. all 4 backends verified (CUDA present)."
convolution,2026-07-02,,HIGH,1;2;3;4,"convolve_2d/convolution_2d skipped kernel + DataArray validation: None/1D/3D/list kernel -> numba TypingError, even kernel silently off-center (custom_kernel rejects it), numpy agg -> memoryview astype error. Fixed via _validate_kernel + _validate_raster; branch deep-sweep-error-handling-convolution-2026-07-02 pushed to fork; issue/PR create blocked by auto-mode, open from parent. MEDIUM(unfixed): annulus_kernel inner>outer -> cryptic np.pad 'index cant contain negative values'. LOW: circle_kernel cellsize=0 ZeroDivisionError, cellsize<0 cryptic linspace; calc_cellsize non-DataArray -> AttributeError attrs. cupy verified."
geotiff,2026-07-02,3604,MEDIUM,2;4,"to_geotiff 0D/1D DataArray raised opaque IndexError from _coords.py coords_to_transform (dims[-2]) instead of clean 'Expected 2D or 3D' ValueError; numpy path + 4D DataArray already clean. Fixed via early ndim guard before dispatch (eager/vrt/gpu) + 3 tests; PR #3604. Read-side param validation + typed-error hierarchy + allow_rotated/allow_invalid_nodata VRT+chunked opt-in threading verified clean (CUDA available, GPU paths run). gh issue create blocked by auto-mode; PR opened. Cat 2+4."
30 changes: 30 additions & 0 deletions xrspatial/convolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,34 @@ def custom_kernel(kernel):
return kernel


def _validate_kernel(kernel, func_name='convolve_2d'):
"""Validate a convolution kernel: a 2D array with odd side lengths.

Duck-typed on ``ndim``/``shape`` so numpy and cupy kernels both pass.
Rejects up front so a malformed kernel raises a clear ValueError
instead of an inscrutable numba ``TypingError`` (or silent off-center
output for even side lengths). Mirrors ``custom_kernel``'s odd-shape
contract.
"""
if not hasattr(kernel, 'ndim') or not hasattr(kernel, 'shape'):
raise ValueError(
f"{func_name}(): `kernel` must be a 2D array with odd side "
f"lengths, got {type(kernel).__module__}."
f"{type(kernel).__qualname__}"
)
if kernel.ndim != 2:
raise ValueError(
f"{func_name}(): `kernel` must be a 2D array, got {kernel.ndim}D "
f"with shape {tuple(kernel.shape)}"
)
rows, cols = kernel.shape
if rows % 2 == 0 or cols % 2 == 0:
raise ValueError(
f"{func_name}(): `kernel` must have odd side lengths so it has a "
f"well-defined center, got shape {(rows, cols)}"
)


# Numba parallel=True kernels must not be launched concurrently from multiple
# Python threads: the default 'workqueue' threading layer is not threadsafe and
# aborts the process (SIGABRT on macOS) when two host threads enter a parallel
Expand Down Expand Up @@ -558,6 +586,7 @@ def convolve_2d(data, kernel, boundary='nan'):
agg = xr.DataArray(data)
_validate_raster(agg, func_name='convolve_2d', ndim=2)
_validate_boundary(boundary)
_validate_kernel(kernel, func_name='convolve_2d')
mapper = ArrayTypeFunctionMapping(
numpy_func=_convolve_2d_numpy_boundary,
cupy_func=_convolve_2d_cupy,
Expand Down Expand Up @@ -699,6 +728,7 @@ def convolution_2d(agg, kernel, name='convolution_2d', boundary='nan'):
"""

# wrapper of convolve_2d
_validate_raster(agg, func_name='convolution_2d', ndim=2)
out = convolve_2d(agg.data, kernel, boundary)
return xr.DataArray(out,
name=name,
Expand Down
47 changes: 46 additions & 1 deletion xrspatial/tests/test_convolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pytest
import xarray as xr

from xrspatial.convolution import circle_kernel, convolve_2d, custom_kernel
from xrspatial.convolution import circle_kernel, convolution_2d, convolve_2d, custom_kernel
from xrspatial.tests.general_checks import cuda_and_cupy_available

KERNEL = circle_kernel(1, 1, 1)
Expand Down Expand Up @@ -86,6 +86,51 @@ def test_convolve_2d_accepts_float64():
assert np.isnan(out[0, 0])


DATA = np.arange(25, dtype=np.float64).reshape(5, 5)


@pytest.mark.parametrize("bad_kernel", [
None,
np.ones(3, dtype=np.float64), # 1D
np.ones((3, 3, 3), dtype=np.float64), # 3D
[[0, 1, 0], [1, 1, 1], [0, 1, 0]], # python list, not an array
])
def test_convolve_2d_rejects_bad_kernel(bad_kernel):
# Bad kernels used to crash deep in numba with a cryptic TypingError.
# convolve_2d must reject them up front with a clear message that names
# `kernel`.
with pytest.raises(ValueError, match="kernel"):
convolve_2d(DATA, bad_kernel)


@pytest.mark.parametrize("even_kernel", [
np.ones((2, 2), dtype=np.float64),
np.ones((4, 4), dtype=np.float64),
np.ones((2, 3), dtype=np.float64),
])
def test_convolve_2d_rejects_even_kernel(even_kernel):
# An even side length has no well-defined center; convolve_2d used to
# silently produce an off-center result. custom_kernel already rejects
# even kernels, so convolve_2d must too.
with pytest.raises(ValueError, match="odd"):
convolve_2d(DATA, even_kernel)


def test_convolution_2d_rejects_non_dataarray():
# Passing a plain numpy array used to fail with an inscrutable
# "'memoryview' object has no attribute 'astype'"; validate up front.
with pytest.raises(TypeError, match="DataArray"):
convolution_2d(DATA, KERNEL)


def test_convolution_2d_accepts_dataarray():
# Positive path unchanged.
agg = xr.DataArray(DATA, dims=['y', 'x'])
out = convolution_2d(agg, KERNEL)
assert isinstance(out, xr.DataArray)
assert out.shape == agg.shape


def test_convolve_2d_uses_correlation_convention():
# Golden values verified against scipy.ndimage 1.16.1: convolve_2d
# applies the kernel by cross-correlation (kernel NOT flipped), so it
Expand Down
Loading