itinera 0.6.0__tar.gz

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.
@@ -0,0 +1,24 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+
7
+ # Virtual environments
8
+ .venv/
9
+ venv/
10
+ env/
11
+
12
+ # Test / tooling caches
13
+ .pytest_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # Build artefacts (plugin zip is built into ../)
18
+ *.zip
19
+
20
+ # OS / editor
21
+ .DS_Store
22
+ *.swp
23
+ .idea/
24
+ .vscode/
itinera-0.6.0/LICENSE ADDED
@@ -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.
itinera-0.6.0/PKG-INFO ADDED
@@ -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,77 @@
1
+ # Itinera
2
+
3
+ Anisotropic **least-cost path** (LCP), **corridor** (LCC),
4
+ **From-Everywhere-To-Everywhere** (FETE), **stochastic / probabilistic** paths
5
+ and **Path Deviation Index** (PDI) primitives for movement modelling — a pure
6
+ **numpy / scipy** library extracted from the
7
+ [Itinera QGIS plugin](https://github.com/leiverkus/itinera).
8
+
9
+ Cost is **directional** (uphill ≠ downhill): each DEM cell becomes a graph node,
10
+ edge weights come from a directional cost function of the signed slope, so the
11
+ conductance matrix is **asymmetric** — true anisotropy. Paths are solved with
12
+ `scipy.sparse.csgraph.dijkstra`.
13
+
14
+ ```bash
15
+ pip install itinera
16
+ ```
17
+
18
+ Only `numpy` and `scipy` are required.
19
+
20
+ ## Quick start
21
+
22
+ ```python
23
+ import numpy as np
24
+ from itinera import build_conductance, least_cost_path, tobler, rowcol_to_node
25
+
26
+ # A small DEM (elevations in metres) on a 10 m grid.
27
+ dem = np.random.default_rng(0).random((50, 50)) * 100.0
28
+
29
+ matrix, rows, cols = build_conductance(
30
+ dem, cellsize=10.0, cost_fn=tobler, neighbours=8)
31
+
32
+ origin = rowcol_to_node(0, 0, cols)
33
+ dest = rowcol_to_node(49, 49, cols)
34
+ path, total_cost = least_cost_path(matrix, origin, dest) # path = node indices
35
+ ```
36
+
37
+ Turn node indices back into (row, col) with `node_to_rowcol(node, cols)`.
38
+
39
+ ## What's included
40
+
41
+ - **Cost functions**: `tobler`, `tobler_offpath`, `herzog`, `naismith`,
42
+ `llobera_sluckin` — each `(slope, distance) -> cost`.
43
+ - **Conductance**: `build_conductance` (slope, optional barrier/multiplier),
44
+ `build_conductance_friction` (friction raster, optional DEM).
45
+ - **Paths**: `accumulated_cost`, `least_cost_path`, `corridor` / `corridor_band`,
46
+ `fete`.
47
+ - **Stochastic**: `stochastic_lcp`, `add_dem_error`, `add_global_stochasticity`.
48
+ - **Validation**: `pdi`.
49
+ - **Grid helpers**: `xy_to_rowcol`, `check_/assert_regular_geotransform`,
50
+ `check_/assert_grids_aligned`.
51
+ - **Utilities**: `estimate_conductance_bytes`, `format_bytes`,
52
+ `block_reduce_mean`.
53
+
54
+ ## Scope: bring your own raster I/O
55
+
56
+ This is a numerics library — it works on **numpy arrays + a GDAL-style
57
+ geotransform tuple**. It does **not** depend on GDAL and does **not** read or
58
+ write raster files. Load your DEM with whatever you already use
59
+ (`rasterio`, `osgeo.gdal`, `xarray`/`rioxarray`, …) and pass the array in.
60
+
61
+ Use a **projected CRS in metres** so slope and distance are metric.
62
+
63
+ ## Relation to the QGIS plugin
64
+
65
+ The same code ships inside the
66
+ [Itinera QGIS plugin](https://github.com/leiverkus/itinera) (where it is the
67
+ plugin's private `core` package, plus QGIS/GDAL wrappers). The PyPI package and
68
+ the QGIS plugin share the top-level import name `itinera`; they are meant for
69
+ **separate environments**. Don't `pip install itinera` into the Python
70
+ interpreter that runs QGIS — the plugin already bundles this code, and the two
71
+ would shadow each other on `sys.path`.
72
+
73
+ ## Licence
74
+
75
+ MIT — see [LICENSE](LICENSE). References for the methods (Tobler, Naismith,
76
+ Herzog, Llobera & Sluckin, White & Barber, Lewis, Goodchild & Hunter) are in
77
+ [`docs/REFERENCES.md`](https://github.com/leiverkus/itinera/blob/main/docs/REFERENCES.md).
@@ -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
+ ]
@@ -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)
@@ -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())
@@ -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
@@ -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))
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,36 @@
1
+ [general]
2
+ name=Itinera – Least-Cost Pathways
3
+ qgisMinimumVersion=3.28
4
+ qgisMaximumVersion=4.99
5
+ description=Least-Cost Path, Corridor (LCC), FETE and PDI validation for archaeological movement modelling.
6
+ about=Anisotropic cost-surface analysis using scipy.sparse Dijkstra. Provides Processing algorithms (Slope Cost Surface, Friction Cost Surface, LCP, Stochastic LCP, Corridor, FETE, PDI Validation, Resample DEM) plus an interactive map-click tool for point-to-point LCP. No external pip dependencies required (uses numpy/scipy bundled with QGIS). Released under the MIT licence.
7
+ version=0.6.0
8
+ author=Patrick Leiverkus (DAO)
9
+ email=patrick.leiverkus@uni-oldenburg.de
10
+ homepage=https://github.com/leiverkus/itinera
11
+ tracker=https://github.com/leiverkus/itinera/issues
12
+ repository=https://github.com/leiverkus/itinera
13
+ tags=archaeology,least cost path,corridor,fete,cost surface,movement,tobler,anisotropic
14
+ category=Analysis
15
+ icon=icon.png
16
+ experimental=True
17
+ deprecated=False
18
+ hasProcessingProvider=yes
19
+ changelog=
20
+ 0.6.0 - The pure numpy/scipy core is now also published as the `itinera` PyPI library (pip install itinera), built single-source from core/ via hatchling. No change to the QGIS plugin itself.
21
+ 0.5.9 - Dev tooling: flake8 is now a CI check (max-line-length 88); excluded maintainer/dev-only files (CLAUDE.md, setup.cfg, pytest.ini, requirements-dev.txt) from the packaged zip. No runtime change.
22
+ 0.5.8 - QGIS 4 / Qt6 runtime fixes: the settings dialog crashed on QDialogButtonBox.Ok (now scoped StandardButton), and the LCP output used QVariant.Int which PyQt6 removed (now QMetaType on Qt6, QVariant on Qt5). Verified on QGIS 3.28 and 4.0.
23
+ 0.5.7 - Code hygiene: removed unused imports (flake8 F401) and added a setup.cfg ignoring W503/W504. No behaviour change.
24
+ 0.5.6 - Declared QGIS 4 compatibility via qgisMaximumVersion=4.99 (without it QGIS assumed a 3.99 maximum and marked the plugin incompatible on QGIS 4). Verified loading on QGIS 4.0.
25
+ 0.5.5 - Documentation: added QGIS 3.28+/4.0 and Qt5/Qt6 compatibility badges to the README.
26
+ 0.5.4 - QGIS 4 / Qt6 compatibility: the settings dialog used exec_(), which PyQt6 removed; switched to exec() (works on QGIS 3 and 4). No other changes.
27
+ 0.5.3 - The "Interactive LCP settings…" button now has its own gear icon, distinct from the path icon of the LCP tool button.
28
+ 0.5.2 - Replaced the blank placeholder plugin icon with a real, visible one, so the two interactive-tool buttons are findable on the Plugins toolbar. Clarified in the docs where those buttons live.
29
+ 0.5.1 - Documentation only: README status badges, future-directions wording, and removal of the internal publishing guide from the README and the packaged zip. No code changes.
30
+ 0.5.0 - New "Resample DEM (block mean)" algorithm and a memory pre-flight warning for large DEMs; stochastic_lcp guards against n_iter < 1; added a user manual (docs/MANUAL.md) and verified references (docs/REFERENCES.md, references.bib). Corrected stochastic citation to Lewis 2021.
31
+ 0.4.0 - Interactive LCP tool is now configurable: an "Interactive LCP settings…" toolbar/menu action picks the cost function and neighbourhood (parity with the Processing algorithms); the graph cache rebuilds only when settings change.
32
+ 0.3.0 - New Stochastic least-cost path (Lewis 2023): N Monte-Carlo realisations with spatially-correlated DEM error (RMSE-scaled) and/or random edge dropping, accumulating a probabilistic corridor in [0,1]. Seed for reproducibility; supports the barrier/multiplier raster.
33
+ 0.2.2 - Fix: xy_to_rowcol now floors instead of truncating, so points just west/north of the raster are correctly out of bounds (not wrongly snapped to row/col 0).
34
+ 0.2.1 - Fixes: point layers are reprojected to the DEM CRS before cell lookup (algorithms + map tool); barrier/friction rasters are validated against the DEM CRS and geotransform (not just pixel count); rotated/non-square rasters are rejected with a clear error. Documented PDI limitations.
35
+ 0.2.0 - Optional barrier / multiplier raster on the slope-based algorithms (slope cost surface, LCP, LCC, FETE): edge cost is scaled by the mean of the two cells' values (>1 discourages, <1 prefers, e.g. known roads); NoData or <=0 cells are impassable (cliffs, deep wadis).
36
+ 0.1.0 - Initial release: anisotropic LCP, LCC corridor, FETE, PDI validation, slope & friction cost surfaces, interactive two-click LCP map tool. Pure numpy/scipy/GDAL (no external pip dependencies).
@@ -0,0 +1,61 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.21"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "itinera"
7
+ dynamic = ["version"]
8
+ description = "Anisotropic least-cost path, corridor (LCC), FETE and PDI primitives for movement modelling — pure numpy/scipy."
9
+ readme = "README-pypi.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Patrick Leiverkus", email = "patrick.leiverkus@uni-oldenburg.de" },
15
+ ]
16
+ keywords = [
17
+ "least cost path", "corridor", "fete", "cost surface",
18
+ "movement", "tobler", "anisotropic", "archaeology",
19
+ ]
20
+ dependencies = ["numpy", "scipy"]
21
+ classifiers = [
22
+ "Development Status :: 4 - Beta",
23
+ "Intended Audience :: Science/Research",
24
+ "License :: OSI Approved :: MIT License",
25
+ "Operating System :: OS Independent",
26
+ "Programming Language :: Python :: 3",
27
+ "Topic :: Scientific/Engineering :: GIS",
28
+ "Topic :: Scientific/Engineering :: Mathematics",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/leiverkus/itinera"
33
+ Repository = "https://github.com/leiverkus/itinera"
34
+ Issues = "https://github.com/leiverkus/itinera/issues"
35
+
36
+ # Single-source the version from metadata.txt (line: version=X.Y.Z).
37
+ [tool.hatch.version]
38
+ path = "metadata.txt"
39
+ pattern = "^version=(?P<version>[^\\s]+)$"
40
+
41
+ # Wheel: ship the on-disk core/ directory as the import package `itinera`,
42
+ # dropping the one GDAL-dependent module (nothing else imports it).
43
+ [tool.hatch.build.targets.wheel]
44
+ sources = { "core" = "itinera" }
45
+ include = ["core/**/*.py"]
46
+ exclude = ["core/raster_io.py"]
47
+
48
+ # Sdist: the repo root *is* the QGIS plugin, so allowlist exactly what the
49
+ # library needs — otherwise the tarball would slurp algorithms/, gui/, etc.
50
+ [tool.hatch.build.targets.sdist]
51
+ include = [
52
+ "core/**/*.py",
53
+ "metadata.txt",
54
+ "LICENSE",
55
+ "README-pypi.md",
56
+ "pyproject.toml",
57
+ ]
58
+ exclude = [
59
+ "core/raster_io.py",
60
+ "core/**/__pycache__",
61
+ ]