From 58ab5fab67371899384866289d256e16386a151c Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Thu, 25 Jun 2026 11:34:38 -0700 Subject: [PATCH 1/2] test(classify): cover all-NaN degenerate input across classifiers Adds all-NaN regression tests for the classifiers that lacked them. std_mean and maximum_breaks already return an all-NaN result and get plain passing tests. head_tail_breaks, percentiles, and box_plot crash on all-NaN input (issue #3510); their tests are xfail (non-strict) until that is fixed. Test-only change, no source modified. --- xrspatial/tests/test_classify.py | 44 ++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/xrspatial/tests/test_classify.py b/xrspatial/tests/test_classify.py index d563d9685..b247af477 100644 --- a/xrspatial/tests/test_classify.py +++ b/xrspatial/tests/test_classify.py @@ -1252,3 +1252,47 @@ def test_generate_sample_indices_large_is_deterministic(): a = _generate_sample_indices(20_000_000, 20_000) b = _generate_sample_indices(20_000_000, 20_000) np.testing.assert_array_equal(a, b) + + +# =================================================================== +# Degenerate all-NaN input for the remaining classifiers +# =================================================================== +# equal_interval and natural_breaks already cover all-NaN. The other +# classifiers were not exercised on an all-non-finite raster, where the +# finite mask removes every element. std_mean and maximum_breaks degrade +# to an all-NaN result; head_tail_breaks, percentiles, and box_plot +# currently raise an opaque reduction error (issue #3510), so their tests +# are xfail until that is fixed. Flip them to plain assertions when #3510 +# lands. strict=False so a concurrent fix does not break main via XPASS. + +def test_std_mean_all_nan(): + agg = xr.DataArray(np.full((4, 5), np.nan)) + result = std_mean(agg) + assert np.all(np.isnan(result.data)) + + +def test_maximum_breaks_all_nan(): + agg = xr.DataArray(np.full((4, 5), np.nan)) + result = maximum_breaks(agg, k=5) + assert np.all(np.isnan(result.data)) + + +@pytest.mark.xfail(reason="all-NaN input crashes; see issue #3510", strict=False) +def test_head_tail_breaks_all_nan(): + agg = xr.DataArray(np.full((4, 5), np.nan)) + result = head_tail_breaks(agg) + assert np.all(np.isnan(result.data)) + + +@pytest.mark.xfail(reason="all-NaN input crashes; see issue #3510", strict=False) +def test_percentiles_all_nan(): + agg = xr.DataArray(np.full((4, 5), np.nan)) + result = percentiles(agg) + assert np.all(np.isnan(result.data)) + + +@pytest.mark.xfail(reason="all-NaN input crashes; see issue #3510", strict=False) +def test_box_plot_all_nan(): + agg = xr.DataArray(np.full((4, 5), np.nan)) + result = box_plot(agg) + assert np.all(np.isnan(result.data)) From a8cbf28fd97bcfd65cef2f1d431e138473c984b1 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Thu, 25 Jun 2026 13:00:24 -0700 Subject: [PATCH 2/2] test(classify): add dask all-NaN coverage + silence std_mean warnings Addresses review on PR #3515. The all-NaN degenerate path diverges by backend, so the numpy-only tests missed cases: - std_mean, maximum_breaks: all-NaN on numpy and dask -> passing dask tests - head_tail_breaks: crashes eager but the dask path has a total_count == 0 guard and returns all-NaN -> plain passing dask test (documents #3510 is eager-only) - percentiles, box_plot: crash on dask too -> xfail(#3510) dask tests Also wraps the std_mean all-NaN tests in warnings.catch_warnings() to drop the empty-slice RuntimeWarnings. Test-only, no source modified. --- xrspatial/tests/test_classify.py | 65 +++++++++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/xrspatial/tests/test_classify.py b/xrspatial/tests/test_classify.py index b247af477..651af4be7 100644 --- a/xrspatial/tests/test_classify.py +++ b/xrspatial/tests/test_classify.py @@ -1260,14 +1260,19 @@ def test_generate_sample_indices_large_is_deterministic(): # equal_interval and natural_breaks already cover all-NaN. The other # classifiers were not exercised on an all-non-finite raster, where the # finite mask removes every element. std_mean and maximum_breaks degrade -# to an all-NaN result; head_tail_breaks, percentiles, and box_plot -# currently raise an opaque reduction error (issue #3510), so their tests -# are xfail until that is fixed. Flip them to plain assertions when #3510 -# lands. strict=False so a concurrent fix does not break main via XPASS. +# to an all-NaN result; on the eager (numpy/cupy) backends head_tail_breaks, +# percentiles, and box_plot currently raise an opaque reduction error +# (issue #3510), so their tests are xfail until that is fixed. Flip them to +# plain assertions when #3510 lands. strict=False so a concurrent fix does +# not break main via XPASS. See the dask section below for the per-backend +# split (head_tail_breaks already degrades cleanly on dask). def test_std_mean_all_nan(): + import warnings agg = xr.DataArray(np.full((4, 5), np.nan)) - result = std_mean(agg) + with warnings.catch_warnings(): + warnings.simplefilter('ignore', RuntimeWarning) + result = std_mean(agg) assert np.all(np.isnan(result.data)) @@ -1296,3 +1301,53 @@ def test_box_plot_all_nan(): agg = xr.DataArray(np.full((4, 5), np.nan)) result = box_plot(agg) assert np.all(np.isnan(result.data)) + + +# All-NaN on the dask backend. The dask paths are separate implementations, +# and they do not match the eager paths on this degenerate input: +# std_mean, maximum_breaks, and head_tail_breaks all return all-NaN on dask +# (head_tail_breaks's dask path has a total_count == 0 guard the eager path +# lacks, so it does not hit the #3510 crash), while percentiles and box_plot +# crash on dask too. Pin all of it so the per-backend behaviour is explicit +# and the #3510 fix can target only the eager paths. + +@dask_array_available +def test_std_mean_all_nan_dask(): + import warnings + agg = xr.DataArray(da.full((4, 5), np.nan, chunks=(2, 5))) + with warnings.catch_warnings(): + warnings.simplefilter('ignore', RuntimeWarning) + result = std_mean(agg) + assert np.all(np.isnan(result.data.compute())) + + +@dask_array_available +def test_maximum_breaks_all_nan_dask(): + agg = xr.DataArray(da.full((4, 5), np.nan, chunks=(2, 5))) + result = maximum_breaks(agg, k=5) + assert np.all(np.isnan(result.data.compute())) + + +@dask_array_available +def test_head_tail_breaks_all_nan_dask(): + # Diverges from the eager path: dask returns all-NaN cleanly (#3510 is + # eager-only). Plain passing test, not xfail. + agg = xr.DataArray(da.full((4, 5), np.nan, chunks=(2, 5))) + result = head_tail_breaks(agg) + assert np.all(np.isnan(result.data.compute())) + + +@dask_array_available +@pytest.mark.xfail(reason="all-NaN input crashes; see issue #3510", strict=False) +def test_percentiles_all_nan_dask(): + agg = xr.DataArray(da.full((4, 5), np.nan, chunks=(2, 5))) + result = percentiles(agg) + assert np.all(np.isnan(result.data.compute())) + + +@dask_array_available +@pytest.mark.xfail(reason="all-NaN input crashes; see issue #3510", strict=False) +def test_box_plot_all_nan_dask(): + agg = xr.DataArray(da.full((4, 5), np.nan, chunks=(2, 5))) + result = box_plot(agg) + assert np.all(np.isnan(result.data.compute()))