diff --git a/.claude/sweep-reference-validation-state.csv b/.claude/sweep-reference-validation-state.csv index 93bc87f6b..baa7e1fb2 100644 --- a/.claude/sweep-reference-validation-state.csv +++ b/.claude/sweep-reference-validation-state.csv @@ -1,3 +1,4 @@ -module,last_inspected,issue,severity_max,verdict,tolerance,notes -convolution,2026-07-02,3619,MEDIUM,CONVENTION-DIFF,0.0 exact (float64),scipy 1.16.1; gdal-unavailable astropy-unavailable; cuda True. convolve_2d interior MATCHES scipy.ndimage.correlate exactly (0.0) across nan/nearest/reflect/wrap; cupy parity 0.0. circle_kernel/annulus_kernel match analytic int-truncated disc exactly; calc_cellsize correct. CONVENTION-DIFF: named 'convolution' but computes cross-correlation (kernel not flipped) -> diverges from scipy.ndimage.convolve on asymmetric kernels (built-in kernels are symmetric so common path unaffected). Convention was undocumented -> MEDIUM doc fix + golden test pinning correlation. -geotiff,2026-07-02,,,MATCHES,rtol=1e-6 float64; float32 codecs exact,"rasterio 1.4.4 (GDAL 3.10.3); gdal-cli 3.11.4; tifffile 2026.3.3; richdem-unavailable; osgeo-python-unavailable. Reader/writer round-trip vs rasterio/GDAL: codecs zstd/deflate/lzw/packbits/none + predictor 2&3 dmax=0; CRS EPSG:32611 & 4326 round-trip exact; GeoTransform north-up, non-square px, tiepoint & ModelTransformation all correct; nodata float-NaN->sentinel + int16/uint8/int32/uint16 sentinels exact; BigTIFF magic+data ok; windowed reads match rasterio.Window incl coord alignment; multiband round-trips (band-last is xrspatial's documented convention); COG valid per 'rio cogeo validate'; overview mean matches numpy block-mean to float32 ulp; tifffile tag check: Compression/Predictor/SampleFormat/GeoKeyDir all correct. Existing repo parity suite parity/test_reference.py 31 passed (incl dask+cupy). No divergence; golden_corpus already pins parity." +module,last_inspected,issue,severity_max,verdict,tolerance,notes +convolution,2026-07-02,3619,MEDIUM,CONVENTION-DIFF,0.0 exact (float64),scipy 1.16.1; gdal-unavailable astropy-unavailable; cuda True. convolve_2d interior MATCHES scipy.ndimage.correlate exactly (0.0) across nan/nearest/reflect/wrap; cupy parity 0.0. circle_kernel/annulus_kernel match analytic int-truncated disc exactly; calc_cellsize correct. CONVENTION-DIFF: named 'convolution' but computes cross-correlation (kernel not flipped) -> diverges from scipy.ndimage.convolve on asymmetric kernels (built-in kernels are symmetric so common path unaffected). Convention was undocumented -> MEDIUM doc fix + golden test pinning correlation. +geotiff,2026-07-02,,,MATCHES,rtol=1e-6 float64; float32 codecs exact,"rasterio 1.4.4 (GDAL 3.10.3); gdal-cli 3.11.4; tifffile 2026.3.3; richdem-unavailable; osgeo-python-unavailable. Reader/writer round-trip vs rasterio/GDAL: codecs zstd/deflate/lzw/packbits/none + predictor 2&3 dmax=0; CRS EPSG:32611 & 4326 round-trip exact; GeoTransform north-up, non-square px, tiepoint & ModelTransformation all correct; nodata float-NaN->sentinel + int16/uint8/int32/uint16 sentinels exact; BigTIFF magic+data ok; windowed reads match rasterio.Window incl coord alignment; multiband round-trips (band-last is xrspatial's documented convention); COG valid per 'rio cogeo validate'; overview mean matches numpy block-mean to float32 ulp; tifffile tag check: Compression/Predictor/SampleFormat/GeoKeyDir all correct. Existing repo parity suite parity/test_reference.py 31 passed (incl dask+cupy). No divergence; golden_corpus already pins parity." +pathfinding,2026-07-08,3655,,MATCHES,1e-12 rel (observed <=2e-15),"a_star_search vs scipy 1.16.1 sparse.csgraph.dijkstra + skimage 0.26.0 MCP_Geometric + analytic open-grid truth; 64x64 open/maze/friction/NaN-friction/anisotropic inputs, 4+8 conn; path chains adjacency+increment consistent; backend parity numpy/dask+numpy/cupy/dask+cupy confirmed (CUDA host); no prior golden test -> added golden-value tests (#3655); gdal/richdem/wbt not applicable to pathfinding" diff --git a/xrspatial/tests/test_pathfinding.py b/xrspatial/tests/test_pathfinding.py index 0db74e791..07c2b34a7 100644 --- a/xrspatial/tests/test_pathfinding.py +++ b/xrspatial/tests/test_pathfinding.py @@ -1145,6 +1145,159 @@ def test_multi_stop_dask_cupy_matches_numpy(): assert np.isfinite(computed_opt.get()).any() +# ===================================================================== +# Issue #3655: golden-value reference regression tests +# ===================================================================== +# +# Expected costs below were generated on 2026-07-08 and verified to +# machine precision (rel diff <= 2e-15) against two independent +# reference implementations with the same edge model: +# - scipy 1.16.1 scipy.sparse.csgraph.dijkstra on an explicit grid +# graph (edge weight = geometric distance, or +# distance * mean endpoint friction) +# - scikit-image 0.26.0 skimage.graph.MCP_Geometric with +# sampling=(cellsize_y, cellsize_x) +# plus the closed-form value where one exists (open uniform grids). +# The tests hardcode the verified goal costs so this parity cannot +# silently regress; neither reference library is needed at test time. + +def _golden_maze_8x8(): + # np.random.RandomState(42).rand(8, 8) < 0.3 -> 0.0 (barrier value), + # with (0,0), (7,0), (7,7) forced passable. Written out literally so + # the test does not depend on RandomState reproducibility. + return np.array([ + [1, 1, 1, 1, 0, 0, 0, 1], + [1, 1, 0, 1, 1, 0, 0, 0], + [1, 1, 1, 0, 1, 0, 0, 1], + [1, 1, 0, 1, 1, 0, 1, 0], + [0, 1, 1, 1, 1, 0, 1, 1], + [0, 1, 0, 1, 0, 1, 1, 1], + [1, 0, 1, 1, 1, 1, 1, 1], + [1, 0, 0, 1, 1, 0, 1, 1], + ], dtype=np.float64) + + +def _golden_friction_6x6(): + # np.round(0.5 + 4.5 * np.random.RandomState(7).rand(6, 6), 2), + # written out literally. + return np.array([ + [0.84, 4.01, 2.47, 3.76, 4.90, 2.92], + [2.76, 0.82, 1.71, 2.75, 3.56, 4.12], + [2.21, 0.80, 1.80, 4.59, 1.46, 2.53], + [4.69, 0.61, 3.20, 4.78, 1.54, 2.97], + [4.59, 1.10, 2.86, 3.88, 3.51, 2.60], + [1.42, 2.71, 2.18, 2.65, 2.15, 4.27], + ]) + + +def _golden_raster(data, cx=30.0, cy=30.0): + # Deliberately NOT _make_raster: that helper stores attrs['res'] with + # the y spacing first, while get_dataarray_resolution reads attrs['res'] + # as (cellsize_x, cellsize_y) -- with anisotropic cells that would flip + # cx and cy. No 'res' attr here, so resolution comes from the coords. + import xarray as xr + h, w = data.shape + r = xr.DataArray(data.astype(np.float64), dims=['y', 'x']) + r['y'] = np.linspace((h - 1) * cy, 0, h) # descending, like GeoTIFFs + r['x'] = np.linspace(0, (w - 1) * cx, w) + return r + + +def _pixel_coords(raster, py, px): + return (float(raster['y'].values[py]), float(raster['x'].values[px])) + + +# (connectivity, start px, goal px, expected goal cost) +_GOLDEN_OPEN_GRID = [ + # 6x6 open grid, 30 m square cells; closed form: + # 5 diagonals of sqrt(2)*30, or 10 cardinals of 30 + pytest.param(8, (0, 0), (5, 5), 212.13203435596427, id='open-8conn'), + pytest.param(4, (0, 0), (5, 5), 300.0, id='open-4conn'), +] + +_GOLDEN_MAZE = [ + pytest.param((0, 0), (7, 7), 296.98484809834997, id='maze-corner'), + pytest.param((7, 0), (4, 4), 174.8528137423857, id='maze-mid'), + pytest.param((7, 0), (0, 7), np.nan, id='maze-no-path'), +] + +_GOLDEN_FRICTION = [ + pytest.param((0, 0), (5, 5), 416.6432249718464, id='friction-diag'), + pytest.param((5, 0), (0, 5), 452.5139715437149, + id='friction-anti-diag'), +] + +_GOLDEN_ANISO = [ + # cx=30, cy=10: diagonal = sqrt(30^2 + 10^2) + pytest.param(8, (0, 0), (5, 5), 158.11388300841895, id='aniso-8conn'), + pytest.param(4, (0, 3), (5, 0), 140.0, id='aniso-4conn'), +] + + +@pytest.mark.parametrize("backend", _backends) +@pytest.mark.parametrize("connectivity,start_px,goal_px,expected", + _GOLDEN_OPEN_GRID) +def test_golden_open_grid(backend, connectivity, start_px, goal_px, + expected): + agg = _golden_raster(np.ones((6, 6))) + if backend == 'dask+numpy': + agg.data = da.from_array(agg.data, chunks=(3, 3)) + start = _pixel_coords(agg, *start_px) + goal = _pixel_coords(agg, *goal_px) + path = a_star_search(agg, start, goal, connectivity=connectivity) + goal_cost = float(np.asarray(path.values)[goal_px[0], goal_px[1]]) + np.testing.assert_allclose(goal_cost, expected, rtol=1e-12) + + +@pytest.mark.parametrize("backend", _backends) +@pytest.mark.parametrize("start_px,goal_px,expected", _GOLDEN_MAZE) +def test_golden_barrier_maze(backend, start_px, goal_px, expected): + agg = _golden_raster(_golden_maze_8x8()) + if backend == 'dask+numpy': + agg.data = da.from_array(agg.data, chunks=(4, 4)) + start = _pixel_coords(agg, *start_px) + goal = _pixel_coords(agg, *goal_px) + path = a_star_search(agg, start, goal, barriers=[0]) + vals = np.asarray(path.values) + goal_cost = float(vals[goal_px[0], goal_px[1]]) + if np.isnan(expected): + # no path: the whole output is NaN + assert not np.isfinite(vals).any() + else: + np.testing.assert_allclose(goal_cost, expected, rtol=1e-12) + assert float(vals[start_px[0], start_px[1]]) == 0.0 + + +@pytest.mark.parametrize("backend", _backends) +@pytest.mark.parametrize("start_px,goal_px,expected", _GOLDEN_FRICTION) +def test_golden_friction(backend, start_px, goal_px, expected): + agg = _golden_raster(np.ones((6, 6))) + friction = _golden_raster(_golden_friction_6x6()) + if backend == 'dask+numpy': + agg.data = da.from_array(agg.data, chunks=(3, 3)) + friction.data = da.from_array(friction.data, chunks=(3, 3)) + start = _pixel_coords(agg, *start_px) + goal = _pixel_coords(agg, *goal_px) + path = a_star_search(agg, start, goal, friction=friction) + goal_cost = float(np.asarray(path.values)[goal_px[0], goal_px[1]]) + np.testing.assert_allclose(goal_cost, expected, rtol=1e-12) + + +@pytest.mark.parametrize("backend", _backends) +@pytest.mark.parametrize("connectivity,start_px,goal_px,expected", + _GOLDEN_ANISO) +def test_golden_anisotropic_cellsize(backend, connectivity, start_px, + goal_px, expected): + agg = _golden_raster(np.ones((6, 6)), cx=30.0, cy=10.0) + if backend == 'dask+numpy': + agg.data = da.from_array(agg.data, chunks=(3, 3)) + start = _pixel_coords(agg, *start_px) + goal = _pixel_coords(agg, *goal_px) + path = a_star_search(agg, start, goal, connectivity=connectivity) + goal_cost = float(np.asarray(path.values)[goal_px[0], goal_px[1]]) + np.testing.assert_allclose(goal_cost, expected, rtol=1e-12) + + # ===================================================================== # Issue #1439: input validation # =====================================================================