itinera 0.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- itinera/__init__.py +57 -0
- itinera/conductance.py +190 -0
- itinera/corridor.py +35 -0
- itinera/cost_functions.py +95 -0
- itinera/fete.py +35 -0
- itinera/grid_align.py +80 -0
- itinera/lcp.py +45 -0
- itinera/memory.py +37 -0
- itinera/resample.py +44 -0
- itinera/stochastic.py +129 -0
- itinera/validation.py +61 -0
- itinera-0.6.0.dist-info/METADATA +100 -0
- itinera-0.6.0.dist-info/RECORD +15 -0
- itinera-0.6.0.dist-info/WHEEL +4 -0
- itinera-0.6.0.dist-info/licenses/LICENSE +21 -0
itinera/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Itinera — anisotropic least-cost path / corridor / FETE primitives.
|
|
3
|
+
|
|
4
|
+
Pure numpy/scipy, no QGIS and no GDAL. Kept GUI-free so it is unit-testable
|
|
5
|
+
outside QGIS and reused by both the Processing algorithms and the interactive
|
|
6
|
+
map tool. Published to PyPI this is the importable package ``itinera``; inside
|
|
7
|
+
the QGIS plugin it is the private ``core`` package.
|
|
8
|
+
|
|
9
|
+
Note: ``raster_io`` (GDAL) is intentionally NOT re-exported here, so importing
|
|
10
|
+
this package never pulls in ``osgeo``. The plugin imports ``raster_io`` directly
|
|
11
|
+
where it needs raster I/O.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .cost_functions import (
|
|
15
|
+
tobler, tobler_offpath, herzog, naismith, llobera_sluckin,
|
|
16
|
+
)
|
|
17
|
+
from .conductance import (
|
|
18
|
+
build_conductance, build_conductance_friction,
|
|
19
|
+
rowcol_to_node, node_to_rowcol,
|
|
20
|
+
)
|
|
21
|
+
from .lcp import accumulated_cost, least_cost_path
|
|
22
|
+
from .corridor import corridor, corridor_band
|
|
23
|
+
from .fete import fete
|
|
24
|
+
from .validation import pdi
|
|
25
|
+
from .stochastic import (
|
|
26
|
+
stochastic_lcp, add_dem_error, add_global_stochasticity,
|
|
27
|
+
)
|
|
28
|
+
from .grid_align import (
|
|
29
|
+
xy_to_rowcol,
|
|
30
|
+
check_regular_geotransform, assert_regular_geotransform,
|
|
31
|
+
check_grids_aligned, assert_grids_aligned,
|
|
32
|
+
)
|
|
33
|
+
from .memory import estimate_conductance_bytes, format_bytes
|
|
34
|
+
from .resample import block_reduce_mean
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
# cost functions
|
|
38
|
+
"tobler", "tobler_offpath", "herzog", "naismith", "llobera_sluckin",
|
|
39
|
+
# conductance
|
|
40
|
+
"build_conductance", "build_conductance_friction",
|
|
41
|
+
"rowcol_to_node", "node_to_rowcol",
|
|
42
|
+
# paths
|
|
43
|
+
"accumulated_cost", "least_cost_path",
|
|
44
|
+
"corridor", "corridor_band",
|
|
45
|
+
"fete",
|
|
46
|
+
# validation
|
|
47
|
+
"pdi",
|
|
48
|
+
# stochastic
|
|
49
|
+
"stochastic_lcp", "add_dem_error", "add_global_stochasticity",
|
|
50
|
+
# grid / alignment
|
|
51
|
+
"xy_to_rowcol",
|
|
52
|
+
"check_regular_geotransform", "assert_regular_geotransform",
|
|
53
|
+
"check_grids_aligned", "assert_grids_aligned",
|
|
54
|
+
# memory / resample
|
|
55
|
+
"estimate_conductance_bytes", "format_bytes",
|
|
56
|
+
"block_reduce_mean",
|
|
57
|
+
]
|
itinera/conductance.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Build a sparse anisotropic conductance (cost) matrix from a DEM.
|
|
3
|
+
|
|
4
|
+
The graph has one node per raster cell. Edges connect each cell to its
|
|
5
|
+
neighbours (4, 8, 16 or 32 connectivity). Edge weight = cost of moving from
|
|
6
|
+
cell A to cell B, evaluated with a directional cost function, so the matrix is
|
|
7
|
+
asymmetric (true anisotropy).
|
|
8
|
+
|
|
9
|
+
NoData cells are excluded: any edge touching a masked cell is dropped, so they
|
|
10
|
+
become unreachable rather than infinitely cheap.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from scipy.sparse import csr_matrix
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Neighbour offset sets. 16/32 use knight-style moves to reduce the
|
|
18
|
+
# "metric distortion" of grid-restricted paths (Llobera-style).
|
|
19
|
+
_OFFSETS = {
|
|
20
|
+
4: [(-1, 0), (1, 0), (0, -1), (0, 1)],
|
|
21
|
+
8: [(-1, 0), (1, 0), (0, -1), (0, 1),
|
|
22
|
+
(-1, -1), (-1, 1), (1, -1), (1, 1)],
|
|
23
|
+
16: [(-1, 0), (1, 0), (0, -1), (0, 1),
|
|
24
|
+
(-1, -1), (-1, 1), (1, -1), (1, 1),
|
|
25
|
+
(-2, -1), (-2, 1), (2, -1), (2, 1),
|
|
26
|
+
(-1, -2), (-1, 2), (1, -2), (1, 2)],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _edge_blocks(rows, cols, cellsize, neighbours):
|
|
31
|
+
"""Yield (a_idx, b_idx, sl_a, sl_b, dist) for each neighbour offset.
|
|
32
|
+
|
|
33
|
+
Shared scaffolding for the conductance builders. ``a_idx``/``b_idx`` are the
|
|
34
|
+
flat node-index arrays for the two ends of every edge in the block; ``sl_a``
|
|
35
|
+
/``sl_b`` are the matching ``(slice, slice)`` tuples so callers can slice
|
|
36
|
+
their own rasters (DEM, friction, NoData mask); ``dist`` is the edge length
|
|
37
|
+
in metres.
|
|
38
|
+
"""
|
|
39
|
+
if neighbours not in _OFFSETS:
|
|
40
|
+
raise ValueError("neighbours must be one of 4, 8, 16")
|
|
41
|
+
|
|
42
|
+
idx = np.arange(rows * cols).reshape(rows, cols)
|
|
43
|
+
|
|
44
|
+
for dr, dc in _OFFSETS[neighbours]:
|
|
45
|
+
r0, r1 = max(0, -dr), rows - max(0, dr)
|
|
46
|
+
c0, c1 = max(0, -dc), cols - max(0, dc)
|
|
47
|
+
|
|
48
|
+
sl_a = (slice(r0, r1), slice(c0, c1))
|
|
49
|
+
sl_b = (slice(r0 + dr, r1 + dr), slice(c0 + dc, c1 + dc))
|
|
50
|
+
|
|
51
|
+
dist = cellsize * np.hypot(dr, dc)
|
|
52
|
+
yield idx[sl_a], idx[sl_b], sl_a, sl_b, dist
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_conductance(dem, cellsize, cost_fn, neighbours=8, nodata_mask=None,
|
|
56
|
+
multiplier=None):
|
|
57
|
+
"""Return (matrix, n_rows, n_cols).
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
dem : 2D float ndarray (elevations, metres)
|
|
62
|
+
cellsize : float (metres per pixel; assumes square pixels)
|
|
63
|
+
cost_fn : callable(slope, distance) -> cost
|
|
64
|
+
neighbours : 4, 8 or 16
|
|
65
|
+
nodata_mask : optional bool 2D ndarray, True where data is invalid
|
|
66
|
+
multiplier : optional 2D float ndarray (same shape as dem). A per-cell
|
|
67
|
+
barrier / conductance multiplier applied to the slope cost:
|
|
68
|
+
edge cost *= mean(multiplier_A, multiplier_B). Values > 1 discourage,
|
|
69
|
+
values < 1 prefer (e.g. known roads). Cells that are NoData or <= 0 are
|
|
70
|
+
impassable — every edge touching them is dropped (e.g. cliffs, deep
|
|
71
|
+
wadis).
|
|
72
|
+
"""
|
|
73
|
+
rows, cols = dem.shape
|
|
74
|
+
n = rows * cols
|
|
75
|
+
|
|
76
|
+
if nodata_mask is None:
|
|
77
|
+
nodata_mask = ~np.isfinite(dem)
|
|
78
|
+
|
|
79
|
+
if multiplier is not None:
|
|
80
|
+
if multiplier.shape != dem.shape:
|
|
81
|
+
raise ValueError("multiplier and dem must have the same shape")
|
|
82
|
+
mult_invalid = ~np.isfinite(multiplier) | (multiplier <= 0)
|
|
83
|
+
|
|
84
|
+
src_all, dst_all, w_all = [], [], []
|
|
85
|
+
|
|
86
|
+
for a_idx, b_idx, sl_a, sl_b, dist in _edge_blocks(
|
|
87
|
+
rows, cols, cellsize, neighbours):
|
|
88
|
+
slope = (dem[sl_b] - dem[sl_a]) / dist # directional slope A -> B
|
|
89
|
+
cost = cost_fn(slope, dist)
|
|
90
|
+
|
|
91
|
+
# Drop edges touching NoData on either end.
|
|
92
|
+
valid = ~(nodata_mask[sl_a] | nodata_mask[sl_b])
|
|
93
|
+
|
|
94
|
+
if multiplier is not None:
|
|
95
|
+
cost = cost * (0.5 * (multiplier[sl_a] + multiplier[sl_b]))
|
|
96
|
+
valid &= ~(mult_invalid[sl_a] | mult_invalid[sl_b])
|
|
97
|
+
|
|
98
|
+
valid &= np.isfinite(cost)
|
|
99
|
+
|
|
100
|
+
src_all.append(a_idx[valid])
|
|
101
|
+
dst_all.append(b_idx[valid])
|
|
102
|
+
w_all.append(cost[valid])
|
|
103
|
+
|
|
104
|
+
src = np.concatenate(src_all)
|
|
105
|
+
dst = np.concatenate(dst_all)
|
|
106
|
+
w = np.concatenate(w_all)
|
|
107
|
+
|
|
108
|
+
matrix = csr_matrix((w, (src, dst)), shape=(n, n))
|
|
109
|
+
return matrix, rows, cols
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def build_conductance_friction(friction, cellsize, neighbours=8,
|
|
113
|
+
dem=None, cost_fn=None,
|
|
114
|
+
friction_nodata_mask=None):
|
|
115
|
+
"""Build a cost matrix from a friction raster (cost per metre).
|
|
116
|
+
|
|
117
|
+
Two modes:
|
|
118
|
+
|
|
119
|
+
* **Isotropic** (``dem is None``): edge cost =
|
|
120
|
+
``mean(friction_A, friction_B) * distance``. Friction is read as a
|
|
121
|
+
cost-per-metre, so the matrix is symmetric — correct for a genuinely
|
|
122
|
+
isotropic surface (this is *not* a forbidden symmetrisation of a slope
|
|
123
|
+
matrix; see CLAUDE.md constraint 3).
|
|
124
|
+
* **Combined** (``dem`` and ``cost_fn`` supplied): edge cost =
|
|
125
|
+
``mean(friction_A, friction_B) * cost_fn(slope, distance)``. Friction
|
|
126
|
+
acts as a dimensionless multiplier (1.0 = neutral, >1 harder, <1
|
|
127
|
+
preferred) on the anisotropic slope cost, so the matrix stays asymmetric.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
friction : 2D float ndarray (cost per metre, or a multiplier with a DEM)
|
|
132
|
+
cellsize : float (metres per pixel; assumes square pixels)
|
|
133
|
+
neighbours : 4, 8 or 16
|
|
134
|
+
dem : optional 2D float ndarray (elevations, metres), same shape as friction
|
|
135
|
+
cost_fn : callable(slope, distance) -> cost; required when dem is given
|
|
136
|
+
friction_nodata_mask : optional bool 2D ndarray, True where invalid
|
|
137
|
+
|
|
138
|
+
Returns
|
|
139
|
+
-------
|
|
140
|
+
(matrix, n_rows, n_cols)
|
|
141
|
+
"""
|
|
142
|
+
rows, cols = friction.shape
|
|
143
|
+
n = rows * cols
|
|
144
|
+
|
|
145
|
+
if dem is not None:
|
|
146
|
+
if dem.shape != friction.shape:
|
|
147
|
+
raise ValueError("dem and friction must have the same shape")
|
|
148
|
+
if cost_fn is None:
|
|
149
|
+
raise ValueError("cost_fn is required when a dem is supplied")
|
|
150
|
+
|
|
151
|
+
# Non-positive or non-finite friction is impassable (edge dropped).
|
|
152
|
+
if friction_nodata_mask is None:
|
|
153
|
+
friction_nodata_mask = ~np.isfinite(friction) | (friction <= 0)
|
|
154
|
+
dem_nodata_mask = (~np.isfinite(dem)) if dem is not None else None
|
|
155
|
+
|
|
156
|
+
src_all, dst_all, w_all = [], [], []
|
|
157
|
+
|
|
158
|
+
for a_idx, b_idx, sl_a, sl_b, dist in _edge_blocks(
|
|
159
|
+
rows, cols, cellsize, neighbours):
|
|
160
|
+
fric = 0.5 * (friction[sl_a] + friction[sl_b])
|
|
161
|
+
|
|
162
|
+
if dem is None:
|
|
163
|
+
cost = fric * dist
|
|
164
|
+
else:
|
|
165
|
+
slope = (dem[sl_b] - dem[sl_a]) / dist
|
|
166
|
+
cost = fric * cost_fn(slope, dist)
|
|
167
|
+
|
|
168
|
+
valid = ~(friction_nodata_mask[sl_a] | friction_nodata_mask[sl_b])
|
|
169
|
+
if dem_nodata_mask is not None:
|
|
170
|
+
valid &= ~(dem_nodata_mask[sl_a] | dem_nodata_mask[sl_b])
|
|
171
|
+
valid &= np.isfinite(cost)
|
|
172
|
+
|
|
173
|
+
src_all.append(a_idx[valid])
|
|
174
|
+
dst_all.append(b_idx[valid])
|
|
175
|
+
w_all.append(cost[valid])
|
|
176
|
+
|
|
177
|
+
src = np.concatenate(src_all)
|
|
178
|
+
dst = np.concatenate(dst_all)
|
|
179
|
+
w = np.concatenate(w_all)
|
|
180
|
+
|
|
181
|
+
matrix = csr_matrix((w, (src, dst)), shape=(n, n))
|
|
182
|
+
return matrix, rows, cols
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def rowcol_to_node(row, col, n_cols):
|
|
186
|
+
return row * n_cols + col
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def node_to_rowcol(node, n_cols):
|
|
190
|
+
return divmod(node, n_cols)
|
itinera/corridor.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Least-cost corridor (LCC).
|
|
3
|
+
|
|
4
|
+
A corridor is the sum of two accumulated-cost surfaces: one grown from the
|
|
5
|
+
origin, one from the destination. The minimum of that sum traces the optimal
|
|
6
|
+
path; values close to the minimum delineate a band of near-optimal routes
|
|
7
|
+
(Verhagen 2013). Useful when a single line over-states the precision of the
|
|
8
|
+
reconstruction.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
from .lcp import accumulated_cost
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def corridor(matrix, origin, destination):
|
|
16
|
+
"""Return the corridor surface (1D, length = n nodes) and its minimum.
|
|
17
|
+
|
|
18
|
+
corridor[i] = cost(origin -> i) + cost(i -> destination via reversed graph)
|
|
19
|
+
The second term is computed on the transpose, because moving *towards* the
|
|
20
|
+
destination on an anisotropic surface is the reverse of moving *away* from
|
|
21
|
+
it.
|
|
22
|
+
"""
|
|
23
|
+
from_origin = accumulated_cost(matrix, [origin])
|
|
24
|
+
# Reverse the graph so the second surface measures cost *into* destination.
|
|
25
|
+
to_dest = accumulated_cost(matrix.transpose().tocsr(), [destination])
|
|
26
|
+
|
|
27
|
+
surface = from_origin + to_dest
|
|
28
|
+
finite = surface[np.isfinite(surface)]
|
|
29
|
+
minimum = float(finite.min()) if finite.size else np.inf
|
|
30
|
+
return surface, minimum
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def corridor_band(surface, minimum, tolerance):
|
|
34
|
+
"""Boolean mask of cells within `tolerance` (absolute cost) of optimum."""
|
|
35
|
+
return np.isfinite(surface) & (surface <= minimum + tolerance)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Anisotropic cost functions.
|
|
3
|
+
|
|
4
|
+
Each function receives the *directional* slope (dz / horizontal_distance,
|
|
5
|
+
i.e. rise over run, signed: positive = uphill in direction of travel) and the
|
|
6
|
+
horizontal distance between the two cell centres in metres. It returns the
|
|
7
|
+
*cost* of traversing that edge (time in seconds, or an abstract cost).
|
|
8
|
+
|
|
9
|
+
Because slope is directional, A->B and B->A yield different costs => true
|
|
10
|
+
anisotropy. This is the central difference from isotropic MCP approaches.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
# Small epsilon to avoid division by zero in speed-based functions.
|
|
16
|
+
_EPS = 1e-9
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def tobler(slope, distance):
|
|
20
|
+
"""Tobler's Hiking Function (1993).
|
|
21
|
+
|
|
22
|
+
Walking speed v (km/h) = 6 * exp(-3.5 * |slope + 0.05|), where slope is
|
|
23
|
+
dh/dx (tan of the angle). Cost returned is travel time in seconds.
|
|
24
|
+
The +0.05 offset makes the maximum speed occur on a gentle downhill,
|
|
25
|
+
which is why this is genuinely anisotropic.
|
|
26
|
+
"""
|
|
27
|
+
speed_kmh = 6.0 * np.exp(-3.5 * np.abs(slope + 0.05))
|
|
28
|
+
speed_ms = speed_kmh * 1000.0 / 3600.0
|
|
29
|
+
return distance / (speed_ms + _EPS)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def tobler_offpath(slope, distance):
|
|
33
|
+
"""Tobler off-path variant: speed reduced to 0.6 of on-path."""
|
|
34
|
+
speed_kmh = 0.6 * 6.0 * np.exp(-3.5 * np.abs(slope + 0.05))
|
|
35
|
+
speed_ms = speed_kmh * 1000.0 / 3600.0
|
|
36
|
+
return distance / (speed_ms + _EPS)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def herzog(slope, distance):
|
|
40
|
+
"""Herzog (2013) metabolic cost function for wheeled/pedestrian movement.
|
|
41
|
+
|
|
42
|
+
A symmetric-ish polynomial in slope (here s = dh/dx as a fraction).
|
|
43
|
+
Returns an abstract metabolic cost scaled by distance.
|
|
44
|
+
"""
|
|
45
|
+
s = slope
|
|
46
|
+
# Herzog's sixth-order polynomial (cost per metre), normalised so that
|
|
47
|
+
# flat ground ~ 1.0.
|
|
48
|
+
cost_per_m = (1337.8 * s**6 + 278.19 * s**5 - 517.39 * s**4
|
|
49
|
+
- 78.199 * s**3 + 93.419 * s**2 + 19.825 * s + 1.64)
|
|
50
|
+
cost_per_m = np.clip(cost_per_m, _EPS, None)
|
|
51
|
+
return cost_per_m * distance
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def naismith(slope, distance):
|
|
55
|
+
"""Naismith's rule (1892) as a time cost.
|
|
56
|
+
|
|
57
|
+
Base walking 5 km/h on the flat, plus extra time for ascent. Descent is
|
|
58
|
+
treated as flat in the classic rule (anisotropic on the uphill side only).
|
|
59
|
+
"""
|
|
60
|
+
base_ms = 5.0 * 1000.0 / 3600.0
|
|
61
|
+
horiz_time = distance / base_ms
|
|
62
|
+
dz = slope * distance # vertical change over this edge
|
|
63
|
+
ascent = np.where(dz > 0, dz, 0.0)
|
|
64
|
+
# Naismith: +1 hour per 600 m ascent => 6 s per metre of climb.
|
|
65
|
+
return horiz_time + ascent * 6.0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def llobera_sluckin(slope, distance):
|
|
69
|
+
"""Llobera & Sluckin (2007) metabolic energy expenditure (kcal-based)."""
|
|
70
|
+
s = slope
|
|
71
|
+
e = (2.635 + 17.37 * np.abs(s) + 42.37 * s**2
|
|
72
|
+
- 21.43 * s**3 + 14.93 * s**4)
|
|
73
|
+
e = np.clip(e, _EPS, None)
|
|
74
|
+
return e * distance
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Registry consumed by the Processing algorithms (enum order matters for the
|
|
78
|
+
# parameter dropdown, so keep this list stable).
|
|
79
|
+
COST_FUNCTIONS = {
|
|
80
|
+
"tobler": tobler,
|
|
81
|
+
"tobler_offpath": tobler_offpath,
|
|
82
|
+
"herzog": herzog,
|
|
83
|
+
"naismith": naismith,
|
|
84
|
+
"llobera_sluckin": llobera_sluckin,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
COST_FUNCTION_LABELS = [
|
|
88
|
+
"Tobler's Hiking Function",
|
|
89
|
+
"Tobler off-path",
|
|
90
|
+
"Herzog (metabolic)",
|
|
91
|
+
"Naismith's rule",
|
|
92
|
+
"Llobera & Sluckin",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
COST_FUNCTION_KEYS = list(COST_FUNCTIONS.keys())
|
itinera/fete.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""From-Everywhere-To-Everywhere (FETE), White & Barber (2012).
|
|
3
|
+
|
|
4
|
+
Compute least-cost paths between every pair of input points and accumulate how
|
|
5
|
+
often each cell is traversed. High-traffic cells indicate "natural" movement
|
|
6
|
+
corridors that emerge from the terrain rather than from any single O/D pair.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from itertools import combinations
|
|
11
|
+
from .lcp import least_cost_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def fete(matrix, nodes, n_cells, progress=None):
|
|
15
|
+
"""Return a 1D traversal-frequency array (length = n_cells).
|
|
16
|
+
|
|
17
|
+
Parameters
|
|
18
|
+
----------
|
|
19
|
+
matrix : sparse conductance matrix
|
|
20
|
+
nodes : list of node indices (the input points)
|
|
21
|
+
n_cells : total number of raster cells
|
|
22
|
+
progress : optional callable(fraction_0_to_1) for UI feedback
|
|
23
|
+
"""
|
|
24
|
+
freq = np.zeros(n_cells, dtype=np.float64)
|
|
25
|
+
pairs = list(combinations(nodes, 2))
|
|
26
|
+
total = len(pairs)
|
|
27
|
+
|
|
28
|
+
for k, (a, b) in enumerate(pairs):
|
|
29
|
+
path, cost = least_cost_path(matrix, a, b)
|
|
30
|
+
for node in path:
|
|
31
|
+
freq[node] += 1.0
|
|
32
|
+
if progress and total:
|
|
33
|
+
progress((k + 1) / total)
|
|
34
|
+
|
|
35
|
+
return freq
|
itinera/grid_align.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Geo-grid regularity and alignment checks — pure Python, no GDAL/QGIS.
|
|
3
|
+
|
|
4
|
+
Kept GUI-free so it is unit-testable outside QGIS. ``RasterGrid`` (the GDAL
|
|
5
|
+
layer in ``core/raster_io.py``) and the Processing algorithm wrappers call into
|
|
6
|
+
these helpers to fail loudly on rasters that would otherwise produce silently
|
|
7
|
+
wrong indices.
|
|
8
|
+
|
|
9
|
+
A GDAL geotransform is the 6-tuple
|
|
10
|
+
``(x_origin, pixel_width, row_rotation, y_origin, col_rotation, pixel_height)``.
|
|
11
|
+
``RasterGrid.xy_to_rowcol`` and the slope/distance maths assume an axis-aligned
|
|
12
|
+
(unrotated, north-up) grid with square pixels.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
|
|
17
|
+
# Relative tolerance (fraction of a pixel) for comparing origins / pixel sizes.
|
|
18
|
+
_ALIGN_TOL = 1e-6
|
|
19
|
+
# Squareness check is a little more lenient — real DEMs carry rounding noise.
|
|
20
|
+
_SQUARE_TOL = 1e-3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def xy_to_rowcol(gt, x, y):
|
|
24
|
+
"""Map map coordinates (x, y) to integer (row, col) indices.
|
|
25
|
+
|
|
26
|
+
Uses ``math.floor`` (not ``int``) so coordinates just outside the raster to
|
|
27
|
+
the west or north map to -1, not 0 — ``int`` truncates toward zero and would
|
|
28
|
+
wrongly report such points as the first row/column (inside the grid).
|
|
29
|
+
Assumes a north-up, unrotated geotransform (``gt[2] == gt[4] == 0``).
|
|
30
|
+
"""
|
|
31
|
+
col = math.floor((x - gt[0]) / gt[1])
|
|
32
|
+
row = math.floor((y - gt[3]) / gt[5])
|
|
33
|
+
return row, col
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def check_regular_geotransform(gt, square_tol=_SQUARE_TOL):
|
|
37
|
+
"""Return ``(ok, reason)`` for an axis-aligned, square-pixel geotransform."""
|
|
38
|
+
if gt[2] != 0.0 or gt[4] != 0.0:
|
|
39
|
+
return False, "raster is rotated (geotransform rotation terms non-zero)"
|
|
40
|
+
px, py = abs(gt[1]), abs(gt[5])
|
|
41
|
+
if px == 0.0 or py == 0.0:
|
|
42
|
+
return False, "raster has a zero pixel dimension"
|
|
43
|
+
if abs(px - py) > square_tol * max(px, py):
|
|
44
|
+
return False, "raster pixels are not square (%.6g x %.6g)" % (px, py)
|
|
45
|
+
return True, ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def assert_regular_geotransform(gt, square_tol=_SQUARE_TOL):
|
|
49
|
+
"""Raise ValueError unless the geotransform is north-up, unrotated, square."""
|
|
50
|
+
ok, reason = check_regular_geotransform(gt, square_tol)
|
|
51
|
+
if not ok:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"Unsupported raster: %s. Itinera assumes north-up, unrotated, "
|
|
54
|
+
"square pixels in a projected CRS (metres)." % reason)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_grids_aligned(gt_a, shape_a, gt_b, shape_b, align_tol=_ALIGN_TOL):
|
|
58
|
+
"""Return ``(ok, reason)``: same shape, origin and pixel size.
|
|
59
|
+
|
|
60
|
+
``shape_*`` are ``(rows, cols)`` tuples; ``gt_*`` are GDAL 6-tuples.
|
|
61
|
+
"""
|
|
62
|
+
if tuple(shape_a) != tuple(shape_b):
|
|
63
|
+
return False, ("raster sizes differ (%dx%d vs %dx%d)" % (
|
|
64
|
+
shape_a[0], shape_a[1], shape_b[0], shape_b[1]))
|
|
65
|
+
tol = align_tol * max(abs(gt_a[1]), abs(gt_a[5]), 1.0)
|
|
66
|
+
for i, name in ((0, "x-origin"), (1, "pixel width"),
|
|
67
|
+
(3, "y-origin"), (5, "pixel height")):
|
|
68
|
+
if abs(gt_a[i] - gt_b[i]) > tol:
|
|
69
|
+
return False, "%s differs (%.10g vs %.10g)" % (name, gt_a[i], gt_b[i])
|
|
70
|
+
return True, ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def assert_grids_aligned(gt_a, shape_a, gt_b, shape_b, what,
|
|
74
|
+
align_tol=_ALIGN_TOL):
|
|
75
|
+
"""Raise ValueError unless grid B aligns with reference grid A."""
|
|
76
|
+
ok, reason = check_grids_aligned(gt_a, shape_a, gt_b, shape_b, align_tol)
|
|
77
|
+
if not ok:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
"%s must share the same grid as the DEM (identical extent, "
|
|
80
|
+
"resolution and origin): %s." % (what, reason))
|
itinera/lcp.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Least-cost path and accumulated-cost computation."""
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from scipy.sparse.csgraph import dijkstra
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def accumulated_cost(matrix, sources):
|
|
9
|
+
"""Cumulative least-cost surface from one or more source nodes.
|
|
10
|
+
|
|
11
|
+
Returns a 1D array (length = n nodes) of minimum cost to reach each node
|
|
12
|
+
from the nearest source. Unreachable nodes are np.inf.
|
|
13
|
+
"""
|
|
14
|
+
dist = dijkstra(matrix, directed=True, indices=sources, min_only=True)
|
|
15
|
+
return dist
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def least_cost_path(matrix, origin, destination):
|
|
19
|
+
"""Return the node sequence of the cheapest path origin -> destination.
|
|
20
|
+
|
|
21
|
+
Uses Dijkstra with predecessor tracking. Returns (nodes, total_cost).
|
|
22
|
+
nodes is a list from origin to destination (inclusive); empty if no path.
|
|
23
|
+
"""
|
|
24
|
+
dist, predecessors = dijkstra(
|
|
25
|
+
matrix, directed=True, indices=origin,
|
|
26
|
+
return_predecessors=True, min_only=False,
|
|
27
|
+
)
|
|
28
|
+
total = float(dist[destination])
|
|
29
|
+
if not np.isfinite(total):
|
|
30
|
+
return [], np.inf
|
|
31
|
+
|
|
32
|
+
# Walk predecessors back from destination to origin.
|
|
33
|
+
path = []
|
|
34
|
+
node = destination
|
|
35
|
+
guard = 0
|
|
36
|
+
max_steps = matrix.shape[0] + 1
|
|
37
|
+
while node != origin and node >= 0:
|
|
38
|
+
path.append(int(node))
|
|
39
|
+
node = int(predecessors[node])
|
|
40
|
+
guard += 1
|
|
41
|
+
if guard > max_steps:
|
|
42
|
+
return [], np.inf
|
|
43
|
+
path.append(int(origin))
|
|
44
|
+
path.reverse()
|
|
45
|
+
return path, total
|
itinera/memory.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Rough memory estimates for the in-RAM conductance matrix.
|
|
3
|
+
|
|
4
|
+
The whole graph is held in memory (one node per cell, up to ``neighbours``
|
|
5
|
+
directed edges per cell), so large DEMs can exhaust RAM. These pure helpers let
|
|
6
|
+
the algorithm wrappers warn before that happens and let users size a clip or a
|
|
7
|
+
resample factor. Pure Python — unit-testable outside QGIS.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# Soft threshold above which the wrappers warn the user (cells = rows * cols).
|
|
11
|
+
# At 8-neighbour this is already ~1 GB of matrix + build temporaries; treat it
|
|
12
|
+
# as "clip or resample first" rather than a hard limit.
|
|
13
|
+
RECOMMENDED_MAX_CELLS = 4_000_000
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def estimate_conductance_bytes(rows, cols, neighbours):
|
|
17
|
+
"""Estimate peak bytes to build the sparse conductance matrix.
|
|
18
|
+
|
|
19
|
+
Counts the CSR storage (float64 weights + int32 indices + int32 indptr) plus
|
|
20
|
+
the transient COO build arrays (int64 src/dst + float64 weights) that exist
|
|
21
|
+
during construction. An upper-ish estimate (border edges are not subtracted)
|
|
22
|
+
— meant for an order-of-magnitude warning, not exact accounting.
|
|
23
|
+
"""
|
|
24
|
+
n_cells = rows * cols
|
|
25
|
+
n_edges = n_cells * neighbours # upper bound (ignores borders)
|
|
26
|
+
csr = n_edges * (8 + 4) + (n_cells + 1) * 4
|
|
27
|
+
build = n_edges * (8 + 8 + 8) # src + dst + weight temporaries
|
|
28
|
+
return csr + build
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_bytes(n):
|
|
32
|
+
"""Human-readable byte count (e.g. '1.2 GB')."""
|
|
33
|
+
value = float(n)
|
|
34
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
35
|
+
if value < 1024.0 or unit == "TB":
|
|
36
|
+
return "%.1f %s" % (value, unit)
|
|
37
|
+
value /= 1024.0
|
itinera/resample.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""NoData-aware block-mean downsampling of a DEM (pure numpy).
|
|
3
|
+
|
|
4
|
+
Reducing a DEM by an integer factor cuts the cell count (and therefore the
|
|
5
|
+
conductance-matrix RAM) by ``factor**2`` — a simple way to fit a large DEM in
|
|
6
|
+
memory when clipping is not an option. Block-mean is a reasonable elevation
|
|
7
|
+
downsample; NoData (NaN) cells are ignored per block, and a fully-NoData block
|
|
8
|
+
stays NaN.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def block_reduce_mean(dem, factor):
|
|
15
|
+
"""Return ``dem`` downsampled by an integer ``factor`` via block mean.
|
|
16
|
+
|
|
17
|
+
The array is trimmed to a whole multiple of ``factor`` in each dimension
|
|
18
|
+
(trailing rows/cols that don't fill a block are dropped). NaN is treated as
|
|
19
|
+
NoData and excluded from each block's mean; an all-NaN block yields NaN.
|
|
20
|
+
``factor == 1`` returns the input unchanged.
|
|
21
|
+
"""
|
|
22
|
+
if factor < 1:
|
|
23
|
+
raise ValueError("factor must be >= 1")
|
|
24
|
+
if factor == 1:
|
|
25
|
+
return dem
|
|
26
|
+
|
|
27
|
+
rows, cols = dem.shape
|
|
28
|
+
r2 = (rows // factor) * factor
|
|
29
|
+
c2 = (cols // factor) * factor
|
|
30
|
+
if r2 == 0 or c2 == 0:
|
|
31
|
+
raise ValueError("factor is larger than the DEM")
|
|
32
|
+
|
|
33
|
+
trimmed = dem[:r2, :c2]
|
|
34
|
+
mask = np.isfinite(trimmed)
|
|
35
|
+
vals = np.where(mask, trimmed, 0.0)
|
|
36
|
+
|
|
37
|
+
br, bc = r2 // factor, c2 // factor
|
|
38
|
+
s = vals.reshape(br, factor, bc, factor).sum(axis=(1, 3))
|
|
39
|
+
n = mask.reshape(br, factor, bc, factor).sum(axis=(1, 3))
|
|
40
|
+
|
|
41
|
+
out = np.full((br, bc), np.nan, dtype=np.float64)
|
|
42
|
+
nonzero = n > 0
|
|
43
|
+
out[nonzero] = s[nonzero] / n[nonzero]
|
|
44
|
+
return out
|
itinera/stochastic.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Stochastic least-cost paths (Lewis 2021).
|
|
3
|
+
|
|
4
|
+
Two sources of uncertainty are modelled and combined over N Monte-Carlo
|
|
5
|
+
realisations to produce a *probabilistic corridor* — the fraction of iterations
|
|
6
|
+
in which each cell lies on the least-cost path:
|
|
7
|
+
|
|
8
|
+
* **DEM error** — a vertical-accuracy (RMSE) error field is added to the DEM
|
|
9
|
+
each iteration, so the slope (and therefore the cost surface) varies.
|
|
10
|
+
* **Global stochasticity** — edges are randomly dropped each iteration, modelling
|
|
11
|
+
unmodelled local impassability.
|
|
12
|
+
|
|
13
|
+
Both are pure numpy/scipy and reuse the deterministic core (`build_conductance`,
|
|
14
|
+
`least_cost_path`), so there is no duplicated path logic.
|
|
15
|
+
|
|
16
|
+
Note on the DEM-error model: ``leastcostpath`` (R) simulates spatially
|
|
17
|
+
autocorrelated error with a variogram (gstat). Without that dependency we
|
|
18
|
+
approximate it (Hunter & Goodchild 1997 style): white Gaussian noise smoothed
|
|
19
|
+
with a Gaussian kernel whose sigma encodes the autocorrelation range, then
|
|
20
|
+
rescaled so the field's standard deviation equals the target RMSE.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
from scipy.ndimage import gaussian_filter
|
|
25
|
+
from scipy.sparse import csr_matrix
|
|
26
|
+
|
|
27
|
+
from .conductance import build_conductance
|
|
28
|
+
from .lcp import least_cost_path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_dem_error(dem, rmse, autocorr_range, cellsize, rng):
|
|
32
|
+
"""Return ``dem`` plus a spatially-correlated Gaussian error field.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
dem : 2D float ndarray (elevations, metres); NoData as NaN is preserved.
|
|
37
|
+
rmse : float, target vertical RMSE in metres (<= 0 returns ``dem`` unchanged).
|
|
38
|
+
autocorr_range : float, spatial autocorrelation range in metres (0 = white
|
|
39
|
+
noise, no smoothing).
|
|
40
|
+
cellsize : float, metres per pixel.
|
|
41
|
+
rng : numpy.random.Generator.
|
|
42
|
+
"""
|
|
43
|
+
if rmse <= 0:
|
|
44
|
+
return dem
|
|
45
|
+
|
|
46
|
+
finite = np.isfinite(dem)
|
|
47
|
+
noise = rng.standard_normal(dem.shape)
|
|
48
|
+
|
|
49
|
+
sigma = (autocorr_range / cellsize) if (autocorr_range > 0 and cellsize) else 0.0
|
|
50
|
+
if sigma > 0:
|
|
51
|
+
noise = gaussian_filter(noise, sigma=sigma, mode="nearest")
|
|
52
|
+
|
|
53
|
+
# Rescale so the error over the valid area has standard deviation = rmse.
|
|
54
|
+
std = noise[finite].std() if finite.any() else 0.0
|
|
55
|
+
if std > 0:
|
|
56
|
+
noise = noise / std * rmse
|
|
57
|
+
|
|
58
|
+
out = dem.copy()
|
|
59
|
+
out[finite] = dem[finite] + noise[finite]
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def add_global_stochasticity(matrix, drop_fraction, rng):
|
|
64
|
+
"""Return a copy of ``matrix`` with a random fraction of edges removed.
|
|
65
|
+
|
|
66
|
+
Each directed edge is independently dropped with probability
|
|
67
|
+
``drop_fraction`` (0 returns the matrix unchanged; 1 removes every edge).
|
|
68
|
+
Dropping edges can disconnect origin from destination in a given
|
|
69
|
+
realisation — that iteration then contributes no path, which is the intended
|
|
70
|
+
behaviour.
|
|
71
|
+
"""
|
|
72
|
+
if drop_fraction <= 0:
|
|
73
|
+
return matrix
|
|
74
|
+
coo = matrix.tocoo(copy=False)
|
|
75
|
+
keep = rng.random(coo.data.shape[0]) >= drop_fraction
|
|
76
|
+
return csr_matrix(
|
|
77
|
+
(coo.data[keep], (coo.row[keep], coo.col[keep])), shape=coo.shape)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def stochastic_lcp(dem, cellsize, cost_fn, origin, destinations, n_iter, rng,
|
|
81
|
+
rmse=0.0, autocorr_range=0.0, drop_fraction=0.0,
|
|
82
|
+
neighbours=8, multiplier=None, progress=None):
|
|
83
|
+
"""Probabilistic least-cost corridor over N stochastic realisations.
|
|
84
|
+
|
|
85
|
+
Returns ``(prob, n_cells)`` where ``prob[i]`` is the fraction of the
|
|
86
|
+
``n_iter`` realisations in which cell ``i`` lay on a least-cost path from
|
|
87
|
+
``origin`` to any of ``destinations`` (a value in [0, 1]).
|
|
88
|
+
|
|
89
|
+
When ``rmse <= 0`` the conductance matrix is built once and reused (only edge
|
|
90
|
+
dropping varies); otherwise it is rebuilt each iteration on a freshly
|
|
91
|
+
perturbed DEM. ``progress`` is an optional callable(fraction_0_to_1).
|
|
92
|
+
"""
|
|
93
|
+
if n_iter < 1:
|
|
94
|
+
raise ValueError("n_iter must be >= 1")
|
|
95
|
+
|
|
96
|
+
rows, cols = dem.shape
|
|
97
|
+
n_cells = rows * cols
|
|
98
|
+
if np.isscalar(destinations):
|
|
99
|
+
destinations = [destinations]
|
|
100
|
+
|
|
101
|
+
static_matrix = None
|
|
102
|
+
if rmse <= 0:
|
|
103
|
+
static_matrix, _, _ = build_conductance(
|
|
104
|
+
dem, cellsize, cost_fn, neighbours=neighbours, multiplier=multiplier)
|
|
105
|
+
|
|
106
|
+
freq = np.zeros(n_cells, dtype=np.float64)
|
|
107
|
+
|
|
108
|
+
for k in range(n_iter):
|
|
109
|
+
if static_matrix is None:
|
|
110
|
+
dem_k = add_dem_error(dem, rmse, autocorr_range, cellsize, rng)
|
|
111
|
+
matrix = build_conductance(
|
|
112
|
+
dem_k, cellsize, cost_fn,
|
|
113
|
+
neighbours=neighbours, multiplier=multiplier)[0]
|
|
114
|
+
else:
|
|
115
|
+
matrix = static_matrix
|
|
116
|
+
|
|
117
|
+
if drop_fraction > 0:
|
|
118
|
+
matrix = add_global_stochasticity(matrix, drop_fraction, rng)
|
|
119
|
+
|
|
120
|
+
on_path = np.zeros(n_cells, dtype=bool)
|
|
121
|
+
for dest in destinations:
|
|
122
|
+
nodes, _ = least_cost_path(matrix, origin, dest)
|
|
123
|
+
on_path[nodes] = True
|
|
124
|
+
freq[on_path] += 1.0
|
|
125
|
+
|
|
126
|
+
if progress:
|
|
127
|
+
progress((k + 1) / n_iter)
|
|
128
|
+
|
|
129
|
+
return freq / n_iter, n_cells
|
itinera/validation.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Path Deviation Index (PDI) validation.
|
|
3
|
+
|
|
4
|
+
Jan Lewis / Goodchild: the PDI measures how far a modelled path deviates from
|
|
5
|
+
a reference path (e.g. a known Roman road). It is the area between the two
|
|
6
|
+
polylines divided by the length of the reference path -> mean perpendicular
|
|
7
|
+
deviation in map units. Lower is better.
|
|
8
|
+
|
|
9
|
+
This module works on coordinate sequences (Nx2 arrays), independent of the
|
|
10
|
+
raster graph, so it can validate any two lines.
|
|
11
|
+
|
|
12
|
+
Limitations
|
|
13
|
+
-----------
|
|
14
|
+
The area is computed with the shoelace formula on the single polygon formed by
|
|
15
|
+
``modelled`` followed by ``reference`` reversed. This is only a faithful
|
|
16
|
+
"area between the lines" when the two polylines are *similar and roughly
|
|
17
|
+
parallel*: they should share orientation (both digitised in the same
|
|
18
|
+
direction), not self-intersect, and not cross each other. For lines that cross,
|
|
19
|
+
diverge strongly, or double back, the closing polygon self-intersects and the
|
|
20
|
+
shoelace area partially cancels, so the PDI is no longer a meaningful mean
|
|
21
|
+
deviation. Both coordinate sequences must be in the same projected CRS (metres);
|
|
22
|
+
the index is in those map units. A topologically robust area-between-lines
|
|
23
|
+
(handling self-intersections) would need geometry operations beyond the
|
|
24
|
+
numpy-only core and is out of scope — pre-check inputs accordingly.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _polyline_length(coords):
|
|
31
|
+
d = np.diff(coords, axis=0)
|
|
32
|
+
return float(np.sum(np.hypot(d[:, 0], d[:, 1])))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _area_between(path_a, path_b):
|
|
36
|
+
"""Approximate area between two polylines via the shoelace formula on the
|
|
37
|
+
closed polygon formed by A followed by reversed B."""
|
|
38
|
+
poly = np.vstack([path_a, path_b[::-1]])
|
|
39
|
+
x, y = poly[:, 0], poly[:, 1]
|
|
40
|
+
return 0.5 * abs(np.dot(x, np.roll(y, -1)) - np.dot(y, np.roll(x, -1)))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pdi(modelled, reference):
|
|
44
|
+
"""Return the Path Deviation Index.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
modelled, reference : Nx2 / Mx2 arrays of (x, y) coordinates.
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
dict with keys: pdi, area, reference_length.
|
|
53
|
+
"""
|
|
54
|
+
modelled = np.asarray(modelled, dtype=float)
|
|
55
|
+
reference = np.asarray(reference, dtype=float)
|
|
56
|
+
|
|
57
|
+
area = _area_between(modelled, reference)
|
|
58
|
+
ref_len = _polyline_length(reference)
|
|
59
|
+
value = area / ref_len if ref_len > 0 else np.inf
|
|
60
|
+
|
|
61
|
+
return {"pdi": value, "area": area, "reference_length": ref_len}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: itinera
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Anisotropic least-cost path, corridor (LCC), FETE and PDI primitives for movement modelling — pure numpy/scipy.
|
|
5
|
+
Project-URL: Homepage, https://github.com/leiverkus/itinera
|
|
6
|
+
Project-URL: Repository, https://github.com/leiverkus/itinera
|
|
7
|
+
Project-URL: Issues, https://github.com/leiverkus/itinera/issues
|
|
8
|
+
Author-email: Patrick Leiverkus <patrick.leiverkus@uni-oldenburg.de>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: anisotropic,archaeology,corridor,cost surface,fete,least cost path,movement,tobler
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Requires-Dist: numpy
|
|
21
|
+
Requires-Dist: scipy
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# Itinera
|
|
25
|
+
|
|
26
|
+
Anisotropic **least-cost path** (LCP), **corridor** (LCC),
|
|
27
|
+
**From-Everywhere-To-Everywhere** (FETE), **stochastic / probabilistic** paths
|
|
28
|
+
and **Path Deviation Index** (PDI) primitives for movement modelling — a pure
|
|
29
|
+
**numpy / scipy** library extracted from the
|
|
30
|
+
[Itinera QGIS plugin](https://github.com/leiverkus/itinera).
|
|
31
|
+
|
|
32
|
+
Cost is **directional** (uphill ≠ downhill): each DEM cell becomes a graph node,
|
|
33
|
+
edge weights come from a directional cost function of the signed slope, so the
|
|
34
|
+
conductance matrix is **asymmetric** — true anisotropy. Paths are solved with
|
|
35
|
+
`scipy.sparse.csgraph.dijkstra`.
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install itinera
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Only `numpy` and `scipy` are required.
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
import numpy as np
|
|
47
|
+
from itinera import build_conductance, least_cost_path, tobler, rowcol_to_node
|
|
48
|
+
|
|
49
|
+
# A small DEM (elevations in metres) on a 10 m grid.
|
|
50
|
+
dem = np.random.default_rng(0).random((50, 50)) * 100.0
|
|
51
|
+
|
|
52
|
+
matrix, rows, cols = build_conductance(
|
|
53
|
+
dem, cellsize=10.0, cost_fn=tobler, neighbours=8)
|
|
54
|
+
|
|
55
|
+
origin = rowcol_to_node(0, 0, cols)
|
|
56
|
+
dest = rowcol_to_node(49, 49, cols)
|
|
57
|
+
path, total_cost = least_cost_path(matrix, origin, dest) # path = node indices
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Turn node indices back into (row, col) with `node_to_rowcol(node, cols)`.
|
|
61
|
+
|
|
62
|
+
## What's included
|
|
63
|
+
|
|
64
|
+
- **Cost functions**: `tobler`, `tobler_offpath`, `herzog`, `naismith`,
|
|
65
|
+
`llobera_sluckin` — each `(slope, distance) -> cost`.
|
|
66
|
+
- **Conductance**: `build_conductance` (slope, optional barrier/multiplier),
|
|
67
|
+
`build_conductance_friction` (friction raster, optional DEM).
|
|
68
|
+
- **Paths**: `accumulated_cost`, `least_cost_path`, `corridor` / `corridor_band`,
|
|
69
|
+
`fete`.
|
|
70
|
+
- **Stochastic**: `stochastic_lcp`, `add_dem_error`, `add_global_stochasticity`.
|
|
71
|
+
- **Validation**: `pdi`.
|
|
72
|
+
- **Grid helpers**: `xy_to_rowcol`, `check_/assert_regular_geotransform`,
|
|
73
|
+
`check_/assert_grids_aligned`.
|
|
74
|
+
- **Utilities**: `estimate_conductance_bytes`, `format_bytes`,
|
|
75
|
+
`block_reduce_mean`.
|
|
76
|
+
|
|
77
|
+
## Scope: bring your own raster I/O
|
|
78
|
+
|
|
79
|
+
This is a numerics library — it works on **numpy arrays + a GDAL-style
|
|
80
|
+
geotransform tuple**. It does **not** depend on GDAL and does **not** read or
|
|
81
|
+
write raster files. Load your DEM with whatever you already use
|
|
82
|
+
(`rasterio`, `osgeo.gdal`, `xarray`/`rioxarray`, …) and pass the array in.
|
|
83
|
+
|
|
84
|
+
Use a **projected CRS in metres** so slope and distance are metric.
|
|
85
|
+
|
|
86
|
+
## Relation to the QGIS plugin
|
|
87
|
+
|
|
88
|
+
The same code ships inside the
|
|
89
|
+
[Itinera QGIS plugin](https://github.com/leiverkus/itinera) (where it is the
|
|
90
|
+
plugin's private `core` package, plus QGIS/GDAL wrappers). The PyPI package and
|
|
91
|
+
the QGIS plugin share the top-level import name `itinera`; they are meant for
|
|
92
|
+
**separate environments**. Don't `pip install itinera` into the Python
|
|
93
|
+
interpreter that runs QGIS — the plugin already bundles this code, and the two
|
|
94
|
+
would shadow each other on `sys.path`.
|
|
95
|
+
|
|
96
|
+
## Licence
|
|
97
|
+
|
|
98
|
+
MIT — see [LICENSE](LICENSE). References for the methods (Tobler, Naismith,
|
|
99
|
+
Herzog, Llobera & Sluckin, White & Barber, Lewis, Goodchild & Hunter) are in
|
|
100
|
+
[`docs/REFERENCES.md`](https://github.com/leiverkus/itinera/blob/main/docs/REFERENCES.md).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
itinera/__init__.py,sha256=_KAUjG_G1rU3jMLRt9IbYj5d0VqL0iBlWBz4t1hH2C0,1971
|
|
2
|
+
itinera/conductance.py,sha256=gayKVtFWLiWukzDd0Irpt7BMz84d1RTRRngsEjFiqXA,6962
|
|
3
|
+
itinera/corridor.py,sha256=W0p6nTm6rOOXT_KsVra48Uq6TzeuYDJ38-I-rufZLUQ,1378
|
|
4
|
+
itinera/cost_functions.py,sha256=KQNtmw1npRZJaTUKFcuS5WtZEx5icE3cSkCqe-yIiPA,3231
|
|
5
|
+
itinera/fete.py,sha256=Ofxykon4kxodIoAzqDXMu-QP_SlA-o9BJiVyrhCOvqI,1113
|
|
6
|
+
itinera/grid_align.py,sha256=QnAHlipZlLeNgwjRXh0sGCUHZ8sECY7hU19tycN59nw,3399
|
|
7
|
+
itinera/lcp.py,sha256=AvkAhxKzqNdFeGGhEIuiLZeCNtubRL_DH2pRM7VOJiU,1413
|
|
8
|
+
itinera/memory.py,sha256=Su66yXOpFQ8oVS0E2SQUcBv6C3cTcdTjLpCwnhc2Ff0,1587
|
|
9
|
+
itinera/resample.py,sha256=HvsTqKOFwLr3ih0wXNVLo7ffcfZq0jXHPiyev81q5Ds,1526
|
|
10
|
+
itinera/stochastic.py,sha256=EpozGW2QM4G7x-xE446D5uS1FxtpEFHQrm2a_HKZzT0,4891
|
|
11
|
+
itinera/validation.py,sha256=Ebee9sTEFgGQk9dCLR2J-KwhVxG0W8bWU1TR9zTF6UM,2344
|
|
12
|
+
itinera-0.6.0.dist-info/METADATA,sha256=u47DNKzM6Tg-SVSjeLeFw2Y4wzPDX2IfGO8aN4no-V0,4101
|
|
13
|
+
itinera-0.6.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
itinera-0.6.0.dist-info/licenses/LICENSE,sha256=fseWi5ej02eIb3nyWOsK_MIuBfK5uOFipv5WCGkrwCQ,1074
|
|
15
|
+
itinera-0.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Leiverkus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|