|
| 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