From b71dc8fca1f0c938452f8e933986f5a7e9d2de51 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Sun, 21 Jun 2026 06:56:32 -0700 Subject: [PATCH] Test aspect Inf and all-NaN elevation input across backends (#3439) Add coverage for two untested Cat-2 inputs in test_aspect.py: - Inf / -Inf elevation cells: assert the planar output never contains Inf, and numpy/cupy/dask+numpy/dask+cupy agree. - All-NaN raster: aspect/northness/eastness return all-NaN with the shape preserved, on all four backends. Both behaviors are defined and consistent across backends today, so these pin the current contract. Test-only; no source change. Update sweep-test-coverage-state.csv aspect row. Closes #3439. --- .claude/sweep-test-coverage-state.csv | 2 +- xrspatial/tests/test_aspect.py | 95 +++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index 58d02b279..eb9bf7bc8 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -1,5 +1,5 @@ module,last_inspected,issue,severity_max,categories_found,notes -aspect,2026-06-02,2742;2829,HIGH,3;4,"#2742: degenerate shapes (1x1/Nx1/1xN) + geodesic boundary modes; tests added all 4 backends, GPU-validated. #2829: northness/eastness method='geodesic' branch was untested (planar only); added correctness (diagonal surface where planar!=geodesic) + 4-backend parity, GPU-validated. all-NaN planar/geodesic returns all-NaN (correct). Inf input -> silent -1/flat on spike cell: possible source bug, out of scope for test-only sweep, not filed. Dedup: rectangular-cell oracle #2781 + cell-size #2780 already merged, not duplicated." +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=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)." 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)." diff --git a/xrspatial/tests/test_aspect.py b/xrspatial/tests/test_aspect.py index 9bebe73cd..00a32afeb 100644 --- a/xrspatial/tests/test_aspect.py +++ b/xrspatial/tests/test_aspect.py @@ -585,6 +585,101 @@ def test_name_consistent_dask_numpy(name): _assert_name_derived('dask+numpy', name) +# ---- Inf and all-NaN elevation input (issue #3439) ---- +# +# The suite exercises NaN inputs (qgis fixture, derived-function propagation) +# but never an Inf cell or an entirely-NaN raster. Both run without error +# today and agree across the four backends; these pin that contract so a +# backend can't silently diverge on Inf or all-NaN input. + +def _inf_raster(): + data = np.ones((6, 7), dtype=np.float64) * 100.0 + data[2, 3] = np.inf + data[3, 1] = -np.inf + return data + + +def test_inf_input_numpy_finite_neighbors(): + """An Inf cell does not crash; the planar interior is finite or -1/NaN, + never Inf.""" + agg = create_test_raster(_inf_raster(), backend='numpy') + out = _to_numpy(aspect(agg)) + assert not np.any(np.isinf(out)) + + +@dask_array_available +def test_inf_input_numpy_equals_dask(): + data = _inf_raster() + numpy_agg = create_test_raster(data, backend='numpy') + dask_agg = create_test_raster(data, backend='dask+numpy', chunks=(3, 4)) + np_out = _to_numpy(aspect(numpy_agg)) + da_out = _to_numpy(aspect(dask_agg)) + np.testing.assert_allclose(np_out, da_out, equal_nan=True, rtol=1e-5) + + +@cuda_and_cupy_available +def test_inf_input_numpy_equals_cupy(): + data = _inf_raster() + numpy_agg = create_test_raster(data, backend='numpy') + cupy_agg = create_test_raster(data, backend='cupy') + np_out = _to_numpy(aspect(numpy_agg)) + cu_out = _to_numpy(aspect(cupy_agg)) + np.testing.assert_allclose(np_out, cu_out, equal_nan=True, rtol=1e-5) + + +@dask_array_available +@cuda_and_cupy_available +def test_inf_input_numpy_equals_dask_cupy(): + data = _inf_raster() + numpy_agg = create_test_raster(data, backend='numpy') + dask_cupy_agg = create_test_raster(data, backend='dask+cupy', chunks=(3, 4)) + np_out = _to_numpy(aspect(numpy_agg)) + dc_out = _to_numpy(aspect(dask_cupy_agg)) + np.testing.assert_allclose(np_out, dc_out, equal_nan=True, rtol=1e-5) + + +@pytest.mark.parametrize("func", [aspect, northness, eastness]) +def test_all_nan_input_numpy(func): + """An entirely-NaN raster comes back all-NaN with the shape preserved.""" + data = np.full((6, 7), np.nan, dtype=np.float64) + agg = create_test_raster(data, backend='numpy') + result = func(agg) + general_output_checks(agg, result) + assert result.shape == (6, 7) + assert np.all(np.isnan(result.data)) + + +@dask_array_available +@pytest.mark.parametrize("func", [aspect, northness, eastness]) +def test_all_nan_input_dask_numpy(func): + data = np.full((6, 7), np.nan, dtype=np.float64) + agg = create_test_raster(data, backend='dask+numpy', chunks=(3, 4)) + result = func(agg) + assert result.shape == (6, 7) + assert np.all(np.isnan(_to_numpy(result))) + + +@cuda_and_cupy_available +@pytest.mark.parametrize("func", [aspect, northness, eastness]) +def test_all_nan_input_cupy(func): + data = np.full((6, 7), np.nan, dtype=np.float64) + agg = create_test_raster(data, backend='cupy') + result = func(agg) + assert result.shape == (6, 7) + assert np.all(np.isnan(_to_numpy(result))) + + +@dask_array_available +@cuda_and_cupy_available +@pytest.mark.parametrize("func", [aspect, northness, eastness]) +def test_all_nan_input_dask_cupy(func): + data = np.full((6, 7), np.nan, dtype=np.float64) + agg = create_test_raster(data, backend='dask+cupy', chunks=(3, 4)) + result = func(agg) + assert result.shape == (6, 7) + assert np.all(np.isnan(_to_numpy(result))) + + @cuda_and_cupy_available @pytest.mark.parametrize("name", [None, 'aspect']) def test_name_consistent_cupy(name):