@@ -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
510612def _variety_reference_data ():
0 commit comments