Skip to content

Commit d21ef13

Browse files
committed
Add least-cost corridor function (#965)
Computes corridors of low cumulative cost between source locations by summing two cost-distance surfaces and normalizing. Supports absolute and relative thresholding, pre-computed cost-distance inputs, and multi-source pairwise corridors.
1 parent fc9d832 commit d21ef13

File tree

2 files changed

+193
-0
lines changed

2 files changed

+193
-0
lines changed

xrspatial/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from xrspatial.aspect import aspect # noqa
22
from xrspatial.balanced_allocation import balanced_allocation # noqa
33
from xrspatial.bump import bump # noqa
4+
from xrspatial.corridor import least_cost_corridor # noqa
45
from xrspatial.cost_distance import cost_distance # noqa
56
from xrspatial.dasymetric import disaggregate # noqa
67
from xrspatial.dasymetric import pycnophylactic # noqa

xrspatial/corridor.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
"""Least-cost corridor analysis.
2+
3+
Identifies zones of low cumulative traversal cost between two (or more)
4+
source locations on a friction surface. Unlike a single-cell path, a
5+
corridor shows all cells within a cost threshold of the optimal route.
6+
7+
Algorithm
8+
---------
9+
1. Compute ``cost_distance(friction, source_a)`` and
10+
``cost_distance(friction, source_b)``.
11+
2. Sum the two surfaces: ``corridor = cd_a + cd_b``.
12+
3. Normalize: ``corridor - corridor.min()``.
13+
4. Optionally threshold to produce a binary corridor mask.
14+
15+
Because the function is pure arithmetic on ``cost_distance`` outputs,
16+
all four backends (NumPy, CuPy, Dask+NumPy, Dask+CuPy) are supported
17+
natively with no extra kernel code.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import itertools
23+
24+
import numpy as np
25+
import xarray as xr
26+
27+
from xrspatial.cost_distance import cost_distance
28+
from xrspatial.utils import _validate_raster
29+
30+
31+
def _compute_corridor(
32+
cd_a: xr.DataArray,
33+
cd_b: xr.DataArray,
34+
threshold: float | None,
35+
relative: bool,
36+
) -> xr.DataArray:
37+
"""Sum two cost-distance surfaces, normalize, and optionally threshold."""
38+
corridor = cd_a + cd_b
39+
corridor_min = float(corridor.min())
40+
41+
if not np.isfinite(corridor_min):
42+
# Sources are mutually unreachable -- return all-NaN.
43+
return corridor * np.nan
44+
45+
normalized = corridor - corridor_min
46+
47+
if threshold is not None:
48+
if relative:
49+
cutoff = threshold * corridor_min
50+
else:
51+
cutoff = threshold
52+
normalized = normalized.where(normalized <= cutoff)
53+
54+
return normalized
55+
56+
57+
def least_cost_corridor(
58+
friction: xr.DataArray,
59+
source_a: xr.DataArray | None = None,
60+
source_b: xr.DataArray | None = None,
61+
*,
62+
sources: list[xr.DataArray] | None = None,
63+
pairwise: bool = False,
64+
threshold: float | None = None,
65+
relative: bool = False,
66+
precomputed: bool = False,
67+
x: str = "x",
68+
y: str = "y",
69+
connectivity: int = 8,
70+
max_cost: float = np.inf,
71+
) -> xr.DataArray | xr.Dataset:
72+
"""Compute a least-cost corridor between two source regions.
73+
74+
A corridor surface is the sum of two cost-distance fields (one from
75+
each source), normalized by the minimum corridor cost. Cells where
76+
the normalized value falls within *threshold* form the corridor.
77+
78+
Parameters
79+
----------
80+
friction : xr.DataArray
81+
2-D friction (cost) surface. Positive finite values are
82+
passable; NaN or ``<= 0`` marks barriers. Ignored when
83+
*precomputed* is True.
84+
source_a, source_b : xr.DataArray, optional
85+
Source rasters identifying start and end regions. Non-zero
86+
finite values mark source cells (same convention as
87+
``cost_distance``). Required unless *sources* is given.
88+
When *precomputed* is True these are treated as pre-computed
89+
cost-distance surfaces.
90+
sources : list of xr.DataArray, optional
91+
Multiple source rasters for pairwise corridor computation.
92+
Mutually exclusive with *source_a* / *source_b*.
93+
pairwise : bool, default False
94+
When True and *sources* has more than two entries, compute
95+
corridors for every unique pair and return an ``xr.Dataset``
96+
with one variable per pair (named ``corridor_i_j``).
97+
threshold : float, optional
98+
Cost threshold for masking the corridor. Cells whose
99+
normalized corridor cost exceeds this value are set to NaN.
100+
relative : bool, default False
101+
If True, *threshold* is a fraction of the minimum corridor
102+
cost (e.g. 0.10 means within 10 % of optimal). If False,
103+
*threshold* is in absolute cost units.
104+
precomputed : bool, default False
105+
If True, *source_a* / *source_b* (or entries in *sources*)
106+
are already cost-distance surfaces. Skips the internal
107+
``cost_distance`` calls.
108+
x, y : str
109+
Coordinate names forwarded to ``cost_distance``.
110+
connectivity : int, default 8
111+
Pixel connectivity (4 or 8), forwarded to ``cost_distance``.
112+
max_cost : float, default np.inf
113+
Maximum cost budget, forwarded to ``cost_distance``.
114+
115+
Returns
116+
-------
117+
xr.DataArray or xr.Dataset
118+
Normalized corridor surface (float). Values start at 0 along
119+
the optimal corridor and increase with deviation from it.
120+
When *threshold* is set, cells beyond the cutoff are NaN.
121+
With *pairwise=True* and multiple sources, returns a Dataset
122+
keyed by pair indices (``corridor_0_1``, ``corridor_0_2``, ...).
123+
"""
124+
# ------------------------------------------------------------------
125+
# Input validation
126+
# ------------------------------------------------------------------
127+
if sources is not None and (source_a is not None or source_b is not None):
128+
raise ValueError(
129+
"Provide either (source_a, source_b) or sources, not both"
130+
)
131+
132+
if sources is not None:
133+
if len(sources) < 2:
134+
raise ValueError("sources must contain at least 2 entries")
135+
for i, s in enumerate(sources):
136+
_validate_raster(
137+
s, func_name="least_cost_corridor", name=f"sources[{i}]"
138+
)
139+
else:
140+
if source_a is None or source_b is None:
141+
raise ValueError(
142+
"source_a and source_b are required when sources is not given"
143+
)
144+
_validate_raster(
145+
source_a, func_name="least_cost_corridor", name="source_a"
146+
)
147+
_validate_raster(
148+
source_b, func_name="least_cost_corridor", name="source_b"
149+
)
150+
sources = [source_a, source_b]
151+
152+
if not precomputed:
153+
_validate_raster(
154+
friction, func_name="least_cost_corridor", name="friction"
155+
)
156+
157+
if threshold is not None and threshold < 0:
158+
raise ValueError("threshold must be non-negative")
159+
160+
# ------------------------------------------------------------------
161+
# Compute cost-distance surfaces (or use precomputed ones)
162+
# ------------------------------------------------------------------
163+
if precomputed:
164+
cd_surfaces = list(sources)
165+
else:
166+
cd_surfaces = [
167+
cost_distance(
168+
src, friction,
169+
x=x, y=y, connectivity=connectivity, max_cost=max_cost,
170+
)
171+
for src in sources
172+
]
173+
174+
# ------------------------------------------------------------------
175+
# Two-source case: return a single DataArray
176+
# ------------------------------------------------------------------
177+
if len(cd_surfaces) == 2 and not pairwise:
178+
return _compute_corridor(
179+
cd_surfaces[0], cd_surfaces[1], threshold, relative
180+
)
181+
182+
# ------------------------------------------------------------------
183+
# Multi-source pairwise: return a Dataset
184+
# ------------------------------------------------------------------
185+
corridors = {}
186+
for i, j in itertools.combinations(range(len(cd_surfaces)), 2):
187+
name = f"corridor_{i}_{j}"
188+
corridors[name] = _compute_corridor(
189+
cd_surfaces[i], cd_surfaces[j], threshold, relative
190+
)
191+
192+
return xr.Dataset(corridors)

0 commit comments

Comments
 (0)