From 7a9596ca8bbc2ad1b752cda8e2c39e921c9e8024 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Mon, 22 Jun 2026 08:54:43 -0700 Subject: [PATCH 1/2] Validate matching shapes for precomputed corridor surfaces (#3445) least_cost_corridor(precomputed=True) summed two cost-distance surfaces without checking they shared a shape. Mismatched surfaces were silently aligned by xarray on the coordinate intersection, producing a truncated, wrong-valued corridor. Add a _validate_matching_shape check across the precomputed surfaces, matching what cost_distance already enforces. --- xrspatial/corridor.py | 15 ++++++++++++++- xrspatial/tests/test_corridor.py | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/xrspatial/corridor.py b/xrspatial/corridor.py index 83bc16acf..a407fecc8 100644 --- a/xrspatial/corridor.py +++ b/xrspatial/corridor.py @@ -25,7 +25,7 @@ import xarray as xr from xrspatial.cost_distance import cost_distance -from xrspatial.utils import _validate_raster +from xrspatial.utils import _validate_matching_shape, _validate_raster def _scalar_to_float(da_scalar): @@ -185,6 +185,19 @@ def least_cost_corridor( # ------------------------------------------------------------------ if precomputed: cd_surfaces = list(sources) + # cost_distance() guarantees matching shapes when it runs, but the + # precomputed path bypasses it. Without this check, surfaces of + # differing shape get silently aligned on the intersection of their + # coordinates by xarray, producing a truncated, wrong-valued corridor. + expected_shape = cd_surfaces[0].shape + for i, cd in enumerate(cd_surfaces[1:], start=1): + _validate_matching_shape( + cd, + expected_shape, + func_name="least_cost_corridor", + name=f"precomputed surface {i}", + expected_name="precomputed surface 0", + ) else: cd_surfaces = [ cost_distance( diff --git a/xrspatial/tests/test_corridor.py b/xrspatial/tests/test_corridor.py index 056479018..a2ddf1d80 100644 --- a/xrspatial/tests/test_corridor.py +++ b/xrspatial/tests/test_corridor.py @@ -374,3 +374,30 @@ def test_single_source_in_list_raises(): src = _make_raster(np.ones((3, 3))) with pytest.raises(ValueError, match="at least 2"): least_cost_corridor(friction, sources=[src]) + + +def test_precomputed_mismatched_shape_raises(): + """Precomputed surfaces of differing shape raise instead of aligning. + + Without the shape check, xarray silently aligns the two surfaces on the + intersection of their coordinates and returns a truncated corridor with + wrong values (e.g. 4x4 + 3x3 -> an all-zero 3x3 result). + """ + friction = _make_raster(np.ones((4, 4))) + cd_a = _make_raster(np.ones((4, 4))) + cd_b = _make_raster(np.ones((3, 3))) + with pytest.raises(ValueError, match="does not match"): + least_cost_corridor(friction, cd_a, cd_b, precomputed=True) + + +def test_precomputed_mismatched_shape_pairwise_raises(): + """Pairwise precomputed surfaces of differing shape raise.""" + friction = _make_raster(np.ones((4, 4))) + sources = [ + _make_raster(np.ones((4, 4))), + _make_raster(np.ones((3, 3))), + ] + with pytest.raises(ValueError, match="does not match"): + least_cost_corridor( + friction, sources=sources, precomputed=True, pairwise=True + ) From a987a4879e7c1d7dc64a72c35d1890474519a43a Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Mon, 22 Jun 2026 08:56:24 -0700 Subject: [PATCH 2/2] Address review: note coord-mismatch scope, add dask shape test (#3445) --- xrspatial/corridor.py | 2 ++ xrspatial/tests/test_corridor.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/xrspatial/corridor.py b/xrspatial/corridor.py index a407fecc8..89b660614 100644 --- a/xrspatial/corridor.py +++ b/xrspatial/corridor.py @@ -189,6 +189,8 @@ def least_cost_corridor( # precomputed path bypasses it. Without this check, surfaces of # differing shape get silently aligned on the intersection of their # coordinates by xarray, producing a truncated, wrong-valued corridor. + # Like cost_distance, this guards shape only; mismatched coordinates + # on same-shape surfaces are out of scope. expected_shape = cd_surfaces[0].shape for i, cd in enumerate(cd_surfaces[1:], start=1): _validate_matching_shape( diff --git a/xrspatial/tests/test_corridor.py b/xrspatial/tests/test_corridor.py index a2ddf1d80..6253f36e9 100644 --- a/xrspatial/tests/test_corridor.py +++ b/xrspatial/tests/test_corridor.py @@ -401,3 +401,13 @@ def test_precomputed_mismatched_shape_pairwise_raises(): least_cost_corridor( friction, sources=sources, precomputed=True, pairwise=True ) + + +@pytest.mark.skipif(da is None, reason="dask not installed") +def test_precomputed_mismatched_shape_dask_raises(): + """The shape check fires on dask surfaces without triggering a compute.""" + friction = _make_raster(np.ones((4, 4)), backend="dask+numpy", chunks=(4, 4)) + cd_a = _make_raster(np.ones((4, 4)), backend="dask+numpy", chunks=(4, 4)) + cd_b = _make_raster(np.ones((3, 3)), backend="dask+numpy", chunks=(3, 3)) + with pytest.raises(ValueError, match="does not match"): + least_cost_corridor(friction, cd_a, cd_b, precomputed=True)