Skip to content

Commit a5cb6c3

Browse files
committed
Add NaN tests for focal_stats CUDA kernels (#1092)
- test_focal_stats_nan_handling_1092: verifies all 7 stats (mean, sum, min, max, std, var, range) skip NaN neighbors across all 4 backends. - test_focal_stats_all_nan_window_1092: all-NaN window gives NaN for mean/min/max and 0 for sum (matching numpy nansum behavior). - Fixed sum kernel to return 0 (not NaN) for all-NaN windows, matching numpy nansum semantics.
1 parent b627c89 commit a5cb6c3

File tree

2 files changed

+103
-3
lines changed

2 files changed

+103
-3
lines changed

xrspatial/focal.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -855,7 +855,6 @@ def _focal_sum_cuda(data, kernel, out):
855855
dc = kernel.shape[1] // 2
856856

857857
s = 0.0
858-
found = False
859858
for k in range(kernel.shape[0]):
860859
for h in range(kernel.shape[1]):
861860
w = kernel[k, h]
@@ -870,9 +869,8 @@ def _focal_sum_cuda(data, kernel, out):
870869
if v != v: # NaN check
871870
continue
872871
s += w * v
873-
found = True
874872

875-
out[i, j] = s if found else math.nan
873+
out[i, j] = s # nansum: 0 when all NaN (matches numpy)
876874

877875

878876
def _focal_stats_func_cupy(data, kernel, func=_focal_max_cuda):

xrspatial/tests/test_focal.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,108 @@ def test_focal_stats_dask_cupy():
505505
equal_nan=True, rtol=1e-4)
506506

507507

508+
# --- focal_stats NaN handling (issue-1092) --------------------------------
509+
510+
@pytest.mark.parametrize("backend", ['numpy', 'cupy', 'dask+numpy', 'dask+cupy'])
511+
def test_focal_stats_nan_handling_1092(backend):
512+
"""All backends should skip NaN neighbors, not propagate them.
513+
514+
Regression test for #1092: CUDA kernels propagated NaN through
515+
arithmetic instead of skipping.
516+
"""
517+
from xrspatial.tests.general_checks import has_cuda_and_cupy
518+
if 'cupy' in backend and not has_cuda_and_cupy():
519+
pytest.skip("Requires CUDA and CuPy")
520+
if 'dask' in backend and da is None:
521+
pytest.skip("Requires Dask")
522+
523+
data = np.array([
524+
[1.0, np.nan, 3.0],
525+
[4.0, 5.0, 6.0],
526+
[7.0, 8.0, 9.0],
527+
])
528+
kernel = custom_kernel(np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]))
529+
530+
agg = create_test_raster(data, backend=backend, chunks=(3, 3))
531+
result = focal_stats(agg, kernel,
532+
stats_funcs=['mean', 'sum', 'min', 'max', 'std', 'var', 'range'])
533+
534+
if hasattr(result.data, 'compute'):
535+
result = result.compute()
536+
537+
def _val(stat, r, c):
538+
d = result.sel(stats=stat).data
539+
if hasattr(d, 'get'):
540+
d = d.get()
541+
return float(np.asarray(d)[r, c])
542+
543+
# Center pixel (1,1): kernel hits [NaN, 4, 5, 6, 8] -> skip NaN -> [4,5,6,8]
544+
center_vals = np.array([4.0, 5.0, 6.0, 8.0])
545+
atol = 1e-3 # float32 tolerance
546+
547+
mean_val = _val('mean', 1, 1)
548+
sum_val = _val('sum', 1, 1)
549+
min_val = _val('min', 1, 1)
550+
max_val = _val('max', 1, 1)
551+
std_val = _val('std', 1, 1)
552+
var_val = _val('var', 1, 1)
553+
range_val = _val('range', 1, 1)
554+
555+
assert abs(mean_val - np.nanmean(center_vals)) < atol, f"mean={mean_val}"
556+
assert abs(sum_val - np.nansum(center_vals)) < atol, f"sum={sum_val}"
557+
assert abs(min_val - np.nanmin(center_vals)) < atol, f"min={min_val}"
558+
assert abs(max_val - np.nanmax(center_vals)) < atol, f"max={max_val}"
559+
assert abs(std_val - np.nanstd(center_vals)) < atol, f"std={std_val}"
560+
assert abs(var_val - np.nanvar(center_vals)) < atol, f"var={var_val}"
561+
assert abs(range_val - (np.nanmax(center_vals) - np.nanmin(center_vals))) < atol, (
562+
f"range={range_val}"
563+
)
564+
565+
# Top-left corner (0,0): kernel hits [NaN, 4, 1] (cross pattern)
566+
# NaN is from data[0,1] (up direction is OOB, left is OOB)
567+
# Wait: the cross kernel at (0,0) covers:
568+
# up=(-1,0)=OOB, down=(1,0)=4, left=(0,-1)=OOB, right=(0,1)=NaN, center=(0,0)=1
569+
# So valid values = [1, 4], NaN is skipped
570+
corner_vals = np.array([1.0, 4.0])
571+
mean_corner = _val('mean', 0, 0)
572+
assert abs(mean_corner - np.nanmean(corner_vals)) < atol, (
573+
f"corner mean={mean_corner}"
574+
)
575+
576+
577+
@pytest.mark.parametrize("backend", ['numpy', 'cupy'])
578+
def test_focal_stats_all_nan_window_1092(backend):
579+
"""A pixel whose entire kernel window is NaN should produce NaN."""
580+
from xrspatial.tests.general_checks import has_cuda_and_cupy
581+
if 'cupy' in backend and not has_cuda_and_cupy():
582+
pytest.skip("Requires CUDA and CuPy")
583+
584+
data = np.array([
585+
[np.nan, np.nan, np.nan],
586+
[np.nan, np.nan, np.nan],
587+
[np.nan, np.nan, 1.0],
588+
])
589+
kernel = custom_kernel(np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]]))
590+
591+
agg = create_test_raster(data, backend=backend)
592+
result = focal_stats(agg, kernel, stats_funcs=['mean', 'sum', 'min', 'max'])
593+
594+
if hasattr(result.data, 'compute'):
595+
result = result.compute()
596+
597+
def _val(stat, r, c):
598+
d = result.sel(stats=stat).data
599+
if hasattr(d, 'get'):
600+
d = d.get()
601+
return float(np.asarray(d)[r, c])
602+
603+
# Center pixel (1,1): kernel hits [NaN, NaN, NaN, NaN, NaN] -> all NaN
604+
assert np.isnan(_val('mean', 1, 1))
605+
assert _val('sum', 1, 1) == 0.0 # nansum of all-NaN = 0 (numpy behavior)
606+
assert np.isnan(_val('min', 1, 1))
607+
assert np.isnan(_val('max', 1, 1))
608+
609+
508610
# --- focal variety (issue-1040) ------------------------------------------
509611

510612
def _variety_reference_data():

0 commit comments

Comments
 (0)