From 86fa9acfc8c4f04e32724f73a03d130c63ba7b70 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Thu, 25 Jun 2026 11:31:27 -0700 Subject: [PATCH 1/2] Add Inf/-Inf regression tests for fire module The fire kernels guard only against NaN (v != v), so +Inf and -Inf inputs flow through the per-pixel arithmetic. The resulting outputs are well defined and match across numpy, cupy, dask+numpy, and dask+cupy, but no test covered them. Lock that contract with 28 tests: a per-function numpy contract check plus four-backend parity for dnbr, rdnbr, burn_severity_class, fireline_intensity, flame_length, rate_of_spread, and kbdi. Test-only change; no source behavior is modified. Update the test-coverage sweep state row for fire. --- .claude/sweep-test-coverage-state.csv | 2 +- xrspatial/tests/test_fire.py | 106 ++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/.claude/sweep-test-coverage-state.csv b/.claude/sweep-test-coverage-state.csv index aa9b0d49e..79c96ac6e 100644 --- a/.claude/sweep-test-coverage-state.csv +++ b/.claude/sweep-test-coverage-state.csv @@ -6,7 +6,7 @@ corridor,2026-06-22,,HIGH,1;3;4;5,"Deep-sweep 2026-06-22 test-coverage on a CUDA 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)." dasymetric,2026-06-20,3407;3406,HIGH,2;3;4;5,"deep-sweep test-coverage on a CUDA host (CUDA available, GPU tests ran). Module is well covered (813 test loc / 834 src): 4-backend equivalence for disaggregate weighted+binary, conservation, NaN/nodata/negative-weight, limiting_variable + cupy/dask NotImplemented guards, pycnophylactic numpy+cupy+dask-raises, validate_disaggregation all backends, memory guards (#1261). Filed #3407 (test-only) for real gaps and added 4 new test classes (11 passed, 2 xfailed). Cat5 HIGH: metadata (attrs res/crs + coords) never asserted -> TestMetadataPreservation (numpy/dask). Cat3 HIGH: true 1x1 raster untested (only 1x2 strip) -> TestSinglePixel for disaggregate weighted/binary + pycnophylactic (degenerate no-shift smoothing) + dask parity. Cat2 MEDIUM: Inf weight collapses zone total to 0 (silent conservation break) -> TestInfWeight pins current behaviour. Cat4 MEDIUM: 3-class limiting_variable (multi-break + per-class caps) untested despite docstring -> TestLimitingVariableThreeClass. SOURCE BUG found (filed #3406, NOT fixed - test-only sweep): pycnophylactic raises ValueError (np.nanmax on zero-size array) when no pixel is valid for smoothing (all-NaN zones or no zone id in values); disaggregate handles same input gracefully (all-NaN). Pinned with TestPycnophylacticEmptyValid xfail(strict, raises=ValueError) -> flips red when #3406 fixed. LOW (documented, not fixed): non-square cellsize never exercised (all tests use res 0.5/0.5); disaggregate cupy/dask+cupy 1x1 + metadata not separately added (eager numpy gap was the real one, GPU dispatch already covered by TestCrossBackend)." diffusion,2026-06-20,3422,HIGH,1;2;3;4,"Pass 1 (2026-06-20, deep-sweep test-coverage, CUDA host). diffuse() dispatch table registers all 4 backends but test_diffusion.py only exercised numpy + dask+numpy. Cat 1 HIGH: cupy (_diffuse_cupy/_diffuse_step_gpu) and dask+cupy (_diffuse_dask_cupy/_diffuse_chunk_cupy) registered but never invoked -- no test ran them. Cat 4 HIGH: boundary accepts nan/nearest/reflect/wrap; only nearest+wrap tested, reflect had none. Cat 3 HIGH: 1x1 single-pixel and Nx1/1xN strip rasters never tested. Cat 2 MEDIUM: NaN tested numpy-only; Inf and all-NaN inputs untested. Filed #3422, added 14 tests (PR #3424, test-only, source untouched): cupy/dask+cupy parity vs numpy (incl. spatially-varying alpha + NaN propagation), reflect boundary across all 4 backends, 1x1 + Nx1 + 1xN (numpy + chunked dask strip), all-NaN stays NaN, Inf contamination smoke test. All 14 RAN+PASSED on a CUDA host; the 4 cupy/dask+cupy tests genuinely executed (not skipped); full file 39 passed. All paths verified correct before the tests were added -- coverage gap, not a bug. LOW (documented, not fixed): non-square cellsize (res[0]!=res[1]) never exercised -- diffuse uses res[0] as dx and assumes square cells; empty 0-row/0-col raster untested; asv benchmark absent; 'nan' boundary-mode edge=NaN behaviour not directly asserted on diffuse (covered indirectly via wrap/nearest)." -fire,2026-06-19,,HIGH,1;5,"Cat1 HIGH: dask+cupy dispatch registered but untested for rdnbr/burn_severity_class/fireline_intensity/flame_length/rate_of_spread/kbdi (only dnbr had it). Cat5 MEDIUM: only dnbr asserted attrs/coords/dims preservation. Added 6 test_numpy_equals_dask_cupy + 6 test_output_preserves_metadata; all 66 fire tests pass on a CUDA host. Cat2 LOW (not fixed): Inf inputs untested (pure per-cell math, low risk)." +fire,2026-06-25,,HIGH,2,"Deep-sweep 2026-06-25 test-coverage on a CUDA host. Backend matrix already complete: all 7 public funcs (dnbr/rdnbr/burn_severity_class/fireline_intensity/flame_length/rate_of_spread/kbdi) x 4 backends present and green (Cat 1 no gap). NaN covered (per-func nan_propagation + #3394 dtype parity). Cat 4 covered: rate_of_spread tests all 13 fuel models + invalid 0/14; kbdi annual_precip invalid 0/-100; fireline heat_content default+custom. Cat 5 covered via general_output_checks on every func. Found one gap: Cat 2 +Inf/-Inf inputs were untested on every function. Probed all 4 backends live: behavior is fully consistent and well-defined (no divergence, no bug) -- e.g. dnbr inf-inf->nan, burn_severity_class +inf->7/-inf->1, kbdi prev=inf clamps to 800, rate_of_spread slope=inf->nan. Added test-only regression: per-func numpy Inf contract (locks exact values) + 4-backend Inf parity (28 new tests, all RAN and PASSED on GPU). No source change; the kernels' only finite guard is v!=v so these lock that contract. Cat 3 1x1/strip: per-pixel kernels (no neighborhood window) so no degeneracy risk, and 1x1/1xN already exercised by kbdi/rdnbr/flame tests -> LOW, not added." focal,2026-06-10,3220;3219;3225,HIGH,1;2;3;4,"Deep-sweep 2026-06-10 on CUDA host, all 4 backends executed. Filed #3220 (coverage) and added 36 tests in PR branch: Inf inputs for mean/focal_stats (HIGH Cat2 - no Inf test existed anywhere), mean NaN input (HIGH Cat2 - default excludes=[nan] semantics never asserted), 1x1 + 1xN/Nx1 strips (HIGH Cat3), empty 0-row raster numpy-only (MEDIUM Cat3), mean passes=2 == mean(mean) and excludes sentinel -9999 behavioral tests (MEDIUM Cat4), dask+cupy non-default boundary modes for mean/apply/focal_stats (MEDIUM Cat1/4). Bugs surfaced, filed separately (NOT fixed here): #3219 hotspots silently returns all zeros on Inf input (nan global std passes the std==0 guard, all 4 backends); #3225 empty raster works on numpy but crashes cupy (raw CudaAPIError) and dask (map_overlap depth ValueError). hotspots+Inf and non-numpy empty behavior left unpinned until those are fixed. Backend matrix for the 4 public funcs was already solid (all 4 backends + parity); boundary modes covered except dask+cupy. Siblings filed #3214-3217 same day (dtype/docstring/apply-default-func) - no overlap." geotiff,2026-06-12,3266,MEDIUM,1,"Pass 22 (2026-06-12, deep-sweep test-coverage): delta audit of the ~20 commits since pass 21 (06-09..06-12, mostly pack/unpack fixes + #3241 GPU streaming writer + coregister #3254/#3248). Filed #3266 (tests). Cat 1 MEDIUM: pack=True gained working gpu/dask+gpu support in #3240, but three pack features were tested numpy+dask only: float32 width preservation (#3080, test_pack_float_width_3080.py), nodata kwarg fill (#3168, test_pack_nodata_kwarg_3168.py), band-subset per-band SCALE/OFFSET rewrite (#3161, test_pack_band_subset_3161.py). Live probe on this CUDA host: all six gpu/dask+gpu legs pass today (no source bug, pure coverage gap). Added one gpu/dask-gpu parametrized round-trip test per file (6 tests, requires_gpu, RUN+passing locally) and fixed two stale docstrings claiming unpack/pack is CPU-only (wrong since #3075/#3240). Verified NOT gaps this pass: #3128 int64 sentinel tests cover eager+dask+gpu; #3241 streaming writer landed with byte-identical band-first/band-last/BytesIO/small-buffer tests; #3104 scale-zero rejection has gpu legs; #3169 revived the dead compression-corpus oracle gate. Out of scope: coregister=True lives in accessor.py (excluded module); its multi-band + polar gaps are documented as experimental caveats in docs/source/reference/geotiff.rst (#3248). LOW (carried, documented not fixed): Inf as the declared nodata sentinel never tested. || PREVIOUS: Pass 21 (2026-06-09, deep-sweep test-coverage): filed #3114 (tests) + #3112 (source bug). Cat 1 HIGH: to_geotiff(pack=True) round-trip was tested only on numpy and dask+numpy (write/test_pack_3064.py); #3075 made unpack=True work on gpu and dask+gpu reads, but no test packed a GPU-read array back. Live probe on this CUDA host: BOTH GPU legs crash today -- eager gpu raises AttributeError (cupy has no astype, the known cupy 13.6/xarray 2025.12 where/astype incompat) and dask+gpu raises TypeError (numpy fill value inside cupy.where) -- both from _pack's out.fillna(nodata) in _attrs.py; _writers/gpu.py says the pre-dispatch re-pack is supposed to make every write path work. Source bug filed as #3112; test-only PR adds test_pack_round_trip_gpu (gpu + dask-gpu params, requires_gpu, xfail(strict=True) on #3112 so the fix flips them loudly) and fixes the stale module docstring claiming GPU rejects mask_and_scale. Ran on CUDA host: 13 passed, 2 xfailed. Verified NOT gaps this pass (probed before flagging): empty/zero-band writer guard is covered (test_basic.py 2075/2095 blocks incl. gpu + dask + streaming entry points); degenerate shapes covered on all 4 read backends (read/test_degenerate_shapes.py); overview_resampling all 7 modes parametrized; missing_sources raise/warn + invalid, band_nodata first/invalid, unpack on all 4 backends, masked/parse_coordinates/lock/cache/default_name/name-deprecation all exercised; attrs contract per-backend (attrs/test_contract.py). LOW (documented, not fixed): Inf as the declared nodata sentinel is never tested (only one nan+inf data round-trip in test_edge_cases.py). || PREVIOUS: Pass 20 (2026-06-06, deep-sweep test-coverage): filed #2984 and added test_writer.py degenerate-shape GPU write coverage (Cat 1 backend + Cat 3 geometric edge). Read side already covers 1x1/1xN/Nx1 on all 4 backends (read/test_degenerate_shapes.py) and the dask streaming writer covers them (integration/test_dask_pipeline.py); the GPU write path was the gap (smallest shape in gpu/test_writer.py was 2x2). Added test_write_geotiff_gpu_degenerate_round_trip (1x1/1xN/Nx1 x none/deflate) + test_to_geotiff_dask_gpu_degenerate_round_trip (dask+cupy via gpu=True). 9 new tests RUN+passing on a CUDA host. Verified paths work first (not a source bug); transform supplied explicitly via attrs. Wider tree audit (~92k test LOC vs ~33k source): rioxarray-compat (#2961), bbox NaN/Inf/rotated, 8-backend parity matrix, codec round-trips already covered -- no other real gaps. | Pass (2026-06-05 test-coverage sweep): mature module (~31k src / ~124k test LOC, 9 test dirs). Exhaustive existing coverage -- parity/test_backend_matrix.py runs all 4 backends + VRT + HTTP + fsspec; golden_corpus full-manifest parity; read_rioxarray_compat_2961 covers masked/mask_and_scale/parse_coordinates/default_name on eager+dask. Cat1+Cat3 gap found (MEDIUM): degenerate-shape READS (1x1/1xN/Nx1) were tested only on the eager numpy reader (test_edge_cases.py) and the dask streaming WRITE path (integration/test_dask_pipeline.py); the windowed dask READ (chunks=) and GPU READ (gpu=True) on a single-pixel dimension were never exercised (smallest dask-read source in read/test_tiling is 8x8/2x32, parity fixtures 32x32/64x64). Probed: paths work today, no source bug -- pure coverage gap. Added read/test_degenerate_shapes.py (18 tests): dask read x{chunks 1,3,4} x{1x1,1xN,Nx1} + coord/transform/crs parity + GPU read + dask+gpu read. GPU cells RAN and PASSED on this CUDA host (grid-size-1 launch validated). Fixture supplies explicit attrs['transform'] (writer cannot infer pixel size from a 1-element coord axis). Branch deep-sweep-test-coverage-geotiff-degenerate-read-01. NOTE: pre-existing union-merge CRLF/duplicate-record corruption in this CSV left untouched -- appended one clean record; DictReader last-write-wins picks this one." idw,2026-06-04,2919,HIGH,1;4,"cupy/dask+cupy backends untested (Cat1 HIGH); GPU k-reject error path untested (Cat4 MED). Added 6 GPU tests, validated on CUDA host. Inf-in-points (Cat2) and attrs-preservation (Cat5) are LOW, documented not fixed." diff --git a/xrspatial/tests/test_fire.py b/xrspatial/tests/test_fire.py index 383627b10..47b05ad81 100644 --- a/xrspatial/tests/test_fire.py +++ b/xrspatial/tests/test_fire.py @@ -849,3 +849,109 @@ def test_dask_numpy_dtype_matches_numpy(call): assert dk_r.dtype == np_r.dtype # and must match what the graph actually computes assert dk_r.data.compute().dtype == np_r.dtype + + +# --------------------------------------------------------------------------- +# Inf / -Inf handling (Cat 2 edge-case coverage) +# --------------------------------------------------------------------------- +# None of these kernels guard against non-finite-but-not-NaN inputs: the only +# skip test is ``v != v`` (NaN), so +Inf / -Inf flow through the arithmetic. +# The resulting contract is well defined and identical on all four backends. +# These tests lock that contract so a later change to the finite-value guard +# (e.g. swapping ``v != v`` for ``not isfinite(v)``) cannot change behavior +# silently. +_INF = np.float32(np.inf) + + +def _dnbr_inf_call(backend): + # pre=+inf,-inf,finite,+inf ; post=finite,finite,+inf,+inf + pre = create_test_raster( + np.array([[_INF, -_INF, 0.5, _INF]], dtype='f4'), backend) + post = create_test_raster( + np.array([[0.1, 0.1, _INF, _INF]], dtype='f4'), backend) + return dnbr(pre, post) + + +def _rdnbr_inf_call(backend): + d = create_test_raster(np.array([[_INF, -_INF, 0.5]], dtype='f4'), backend) + p = create_test_raster(np.array([[500.0, 500.0, _INF]], dtype='f4'), backend) + return rdnbr(d, p) + + +def _bsc_inf_call(backend): + v = create_test_raster(np.array([[_INF, -_INF]], dtype='f4'), backend) + return burn_severity_class(v) + + +def _fli_inf_call(backend): + fuel = create_test_raster(np.array([[_INF, 2.0, _INF]], dtype='f4'), backend) + spread = create_test_raster( + np.array([[0.1, _INF, _INF]], dtype='f4'), backend) + return fireline_intensity(fuel, spread) + + +def _fl_inf_call(backend): + v = create_test_raster(np.array([[_INF, -_INF]], dtype='f4'), backend) + return flame_length(v) + + +def _ros_inf_call(backend): + slope = create_test_raster(np.array([[_INF, 10.0, 10.0]], dtype='f4'), backend) + wind = create_test_raster(np.array([[10.0, _INF, 10.0]], dtype='f4'), backend) + moist = create_test_raster( + np.array([[0.06, 0.06, _INF]], dtype='f4'), backend) + return rate_of_spread(slope, wind, moist) + + +def _kbdi_inf_call(backend): + prev = create_test_raster(np.array([[_INF, 100.0, 100.0]], dtype='f4'), backend) + temp = create_test_raster(np.array([[30.0, _INF, 30.0]], dtype='f4'), backend) + precip = create_test_raster(np.array([[0.0, 0.0, _INF]], dtype='f4'), backend) + return kbdi(prev, temp, precip, annual_precip=1500.0) + + +_INF_CONTRACTS = [ + # (call_builder, expected_numpy_output) + (_dnbr_inf_call, np.array([[np.inf, -np.inf, -np.inf, np.nan]], dtype='f4')), + (_rdnbr_inf_call, np.array([[np.inf, -np.inf, 0.0]], dtype='f4')), + (_bsc_inf_call, np.array([[7, 1]], dtype=np.int8)), + (_fli_inf_call, np.array([[np.inf, np.inf, np.inf]], dtype='f4')), + (_fl_inf_call, np.array([[np.inf, 0.0]], dtype='f4')), + (_ros_inf_call, np.array([[np.nan, np.inf, np.nan]], dtype='f4')), + (_kbdi_inf_call, np.array([[800.0, 800.0, 43.95906]], dtype='f4')), +] + + +@pytest.mark.parametrize("call,expected", _INF_CONTRACTS) +def test_inf_contract_numpy(call, expected): + result = call('numpy') + np.testing.assert_allclose(result.data, expected, rtol=1e-5, + equal_nan=True) + + +@dask_array_available +@pytest.mark.parametrize("call", [c for c, _ in _INF_CONTRACTS]) +def test_inf_numpy_equals_dask(call): + np_r = call('numpy') + dk_r = call('dask+numpy') + np.testing.assert_allclose(dk_r.data.compute(), np_r.data, + rtol=1e-5, equal_nan=True) + + +@cuda_and_cupy_available +@pytest.mark.parametrize("call", [c for c, _ in _INF_CONTRACTS]) +def test_inf_numpy_equals_cupy(call): + np_r = call('numpy') + cp_r = call('cupy') + np.testing.assert_allclose(cp_r.data.get(), np_r.data, + rtol=1e-5, equal_nan=True) + + +@dask_array_available +@cuda_and_cupy_available +@pytest.mark.parametrize("call", [c for c, _ in _INF_CONTRACTS]) +def test_inf_numpy_equals_dask_cupy(call): + np_r = call('numpy') + dc_r = call('dask+cupy') + np.testing.assert_allclose(dc_r.data.compute().get(), np_r.data, + rtol=1e-5, equal_nan=True) From 63ad5d9bf810221dabc8af2e28afe8fb4d472190 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Thu, 25 Jun 2026 12:48:53 -0700 Subject: [PATCH 2/2] Document kbdi Inf contract value in fire tests Address review nit on PR #3500: the kbdi numpy Inf contract pinned a bare 43.95906. Add a comment explaining it is the post-clamp drought- factor re-accumulation at temp=30, annual_precip=1500. Comment-only. --- xrspatial/tests/test_fire.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/xrspatial/tests/test_fire.py b/xrspatial/tests/test_fire.py index 47b05ad81..3df67c824 100644 --- a/xrspatial/tests/test_fire.py +++ b/xrspatial/tests/test_fire.py @@ -918,6 +918,9 @@ def _kbdi_inf_call(backend): (_fli_inf_call, np.array([[np.inf, np.inf, np.inf]], dtype='f4')), (_fl_inf_call, np.array([[np.inf, 0.0]], dtype='f4')), (_ros_inf_call, np.array([[np.nan, np.inf, np.nan]], dtype='f4')), + # kbdi: prev=inf and temp=inf both saturate the 800 clamp; precip=inf + # zeroes the deficit, which then re-accumulates the drought factor to + # ~43.96 at temp=30, annual_precip=1500. (_kbdi_inf_call, np.array([[800.0, 800.0, 43.95906]], dtype='f4')), ]