zero2geospatial3d-sdk 0.1.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.
- zero2geospatial3d/__init__.py +105 -0
- zero2geospatial3d/acoustics.py +284 -0
- zero2geospatial3d/classify.py +379 -0
- zero2geospatial3d/cli.py +367 -0
- zero2geospatial3d/comfort.py +687 -0
- zero2geospatial3d/data/epw_stations.json.gz +0 -0
- zero2geospatial3d/daylight.py +337 -0
- zero2geospatial3d/dispersion.py +249 -0
- zero2geospatial3d/epw.py +162 -0
- zero2geospatial3d/geo.py +138 -0
- zero2geospatial3d/grid.py +119 -0
- zero2geospatial3d/microclimate.py +388 -0
- zero2geospatial3d/morphology.py +283 -0
- zero2geospatial3d/nsga2.py +315 -0
- zero2geospatial3d/osm.py +450 -0
- zero2geospatial3d/osm_tags.py +180 -0
- zero2geospatial3d/py.typed +0 -0
- zero2geospatial3d/results.py +231 -0
- zero2geospatial3d/road_sources.py +141 -0
- zero2geospatial3d/scene.py +555 -0
- zero2geospatial3d/server.py +332 -0
- zero2geospatial3d/shading.py +443 -0
- zero2geospatial3d/solar.py +207 -0
- zero2geospatial3d/terrain.py +610 -0
- zero2geospatial3d/urban_form.py +326 -0
- zero2geospatial3d/walkability.py +380 -0
- zero2geospatial3d/weather.py +373 -0
- zero2geospatial3d/web/index.html +50 -0
- zero2geospatial3d/web/src/analysis/climate.js +278 -0
- zero2geospatial3d/web/src/analysis/compliance.js +345 -0
- zero2geospatial3d/web/src/analysis/gpu.js +431 -0
- zero2geospatial3d/web/src/analysis/optimize.js +208 -0
- zero2geospatial3d/web/src/analysis/runners.js +1644 -0
- zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
- zero2geospatial3d/web/src/app.js +630 -0
- zero2geospatial3d/web/src/core/acoustics.js +341 -0
- zero2geospatial3d/web/src/core/classify.js +158 -0
- zero2geospatial3d/web/src/core/comfort.js +686 -0
- zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
- zero2geospatial3d/web/src/core/dispersion.js +264 -0
- zero2geospatial3d/web/src/core/microclimate.js +372 -0
- zero2geospatial3d/web/src/core/morphology.js +153 -0
- zero2geospatial3d/web/src/core/nsga2.js +181 -0
- zero2geospatial3d/web/src/core/party_walls.js +60 -0
- zero2geospatial3d/web/src/core/road_sources.js +127 -0
- zero2geospatial3d/web/src/core/solar.js +112 -0
- zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
- zero2geospatial3d/web/src/core/terrain_math.js +279 -0
- zero2geospatial3d/web/src/core/urban_form.js +405 -0
- zero2geospatial3d/web/src/core/walkability.js +316 -0
- zero2geospatial3d/web/src/core/weather.js +225 -0
- zero2geospatial3d/web/src/core/wind.js +131 -0
- zero2geospatial3d/web/src/export/check_pdf.js +74 -0
- zero2geospatial3d/web/src/export/exporters.js +556 -0
- zero2geospatial3d/web/src/export/pdf.js +149 -0
- zero2geospatial3d/web/src/export/snapshot.js +234 -0
- zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
- zero2geospatial3d/web/src/main.js +75 -0
- zero2geospatial3d/web/src/scene/objects.js +645 -0
- zero2geospatial3d/web/src/scene/overlay.js +145 -0
- zero2geospatial3d/web/src/scene/terrain.js +546 -0
- zero2geospatial3d/web/src/scene/viewer.js +275 -0
- zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
- zero2geospatial3d/web/src/style.css +225 -0
- zero2geospatial3d/web/src/ui/building_report.js +404 -0
- zero2geospatial3d/web/src/ui/charts.js +441 -0
- zero2geospatial3d/web/src/ui/colorize.js +101 -0
- zero2geospatial3d/web/src/ui/compare.js +362 -0
- zero2geospatial3d/web/src/ui/dom.js +104 -0
- zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
- zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
- zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
- zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
- zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
- zero2geospatial3d/web/src/ui/sunclock.js +45 -0
- zero2geospatial3d/web/src/ui/tools.js +197 -0
- zero2geospatial3d/web/src/ui/viewtools.js +173 -0
- zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
- zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
- zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
- zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
- zero2geospatial3d/wind.py +178 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
- zero2geospatial3d_sdk-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Terrain analysis on a DEM :class:`~zero2geospatial3d.grid.Grid`.
|
|
2
|
+
|
|
3
|
+
Slope and aspect (Horn 1981), hillshade, contours (marching squares),
|
|
4
|
+
profiles / terrain sections, bathtub and seeded flooding, cut and fill,
|
|
5
|
+
curvature, topographic position (TPI), ruggedness (TRI), flow accumulation
|
|
6
|
+
(priority flood + D8) and the topographic wetness index (TWI).
|
|
7
|
+
|
|
8
|
+
NaN cells are "no data": they never flood, contour, cut or fill, and they
|
|
9
|
+
stay NaN in every derived map (edges are handled by repeating the border
|
|
10
|
+
cell, as in the plugin's viewer).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import heapq
|
|
16
|
+
import math
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Optional, Sequence
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .grid import Grid
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ContourSet",
|
|
26
|
+
"CutFillResult",
|
|
27
|
+
"FloodResult",
|
|
28
|
+
"GridStats",
|
|
29
|
+
"Profile",
|
|
30
|
+
"aspect_classes",
|
|
31
|
+
"balanced_level",
|
|
32
|
+
"contours",
|
|
33
|
+
"crop_grid",
|
|
34
|
+
"curvature",
|
|
35
|
+
"cut_fill",
|
|
36
|
+
"flood",
|
|
37
|
+
"flow_accumulation",
|
|
38
|
+
"grid_stats",
|
|
39
|
+
"hillshade",
|
|
40
|
+
"percentile",
|
|
41
|
+
"point_in_ring",
|
|
42
|
+
"points_in_ring",
|
|
43
|
+
"profile",
|
|
44
|
+
"ruggedness",
|
|
45
|
+
"slope_aspect",
|
|
46
|
+
"slope_aspect_at",
|
|
47
|
+
"tpi",
|
|
48
|
+
"wetness",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
Ring = Sequence[Sequence[float]]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _padded(z: np.ndarray) -> np.ndarray:
|
|
55
|
+
return np.pad(z, 1, mode="edge")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _horn(g: Grid) -> tuple[np.ndarray, np.ndarray]:
|
|
59
|
+
"""Horn gradients (dz/dx towards east, dz/dy towards north), edges repeated."""
|
|
60
|
+
p = _padded(g.values)
|
|
61
|
+
a, b, cc = p[:-2, :-2], p[:-2, 1:-1], p[:-2, 2:]
|
|
62
|
+
d, f = p[1:-1, :-2], p[1:-1, 2:]
|
|
63
|
+
gg, h, i = p[2:, :-2], p[2:, 1:-1], p[2:, 2:]
|
|
64
|
+
dzdx = ((cc + 2 * f + i) - (a + 2 * d + gg)) / (8 * g.cell)
|
|
65
|
+
dzdy = ((a + 2 * b + cc) - (gg + 2 * h + i)) / (8 * g.cell)
|
|
66
|
+
return dzdx, dzdy
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def slope_aspect(g: Grid) -> tuple[np.ndarray, np.ndarray]:
|
|
70
|
+
"""Horn (1981) slope and aspect in degrees.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
``(slope, aspect)`` arrays of shape ``(rows, cols)``. Aspect is the
|
|
74
|
+
downslope bearing clockwise from north, ``-1`` on flat cells.
|
|
75
|
+
"""
|
|
76
|
+
dzdx, dzdy = _horn(g)
|
|
77
|
+
slope = np.degrees(np.arctan(np.hypot(dzdx, dzdy)))
|
|
78
|
+
with np.errstate(invalid="ignore"):
|
|
79
|
+
aspect = np.degrees(np.arctan2(-dzdx, -dzdy))
|
|
80
|
+
aspect = np.where(aspect < 0, aspect + 360, aspect)
|
|
81
|
+
aspect = np.where((dzdx == 0) & (dzdy == 0), -1.0, aspect)
|
|
82
|
+
return slope, aspect
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def slope_aspect_at(g: Grid, c: int, r: int) -> tuple[float, float]:
|
|
86
|
+
"""Horn slope and aspect (degrees, ``-1`` flat) of a single cell."""
|
|
87
|
+
z = g.values
|
|
88
|
+
rows, cols = z.shape
|
|
89
|
+
|
|
90
|
+
def at(cc: int, rr: int) -> float:
|
|
91
|
+
return float(z[min(max(rr, 0), rows - 1), min(max(cc, 0), cols - 1)])
|
|
92
|
+
|
|
93
|
+
dzdx = (
|
|
94
|
+
(at(c + 1, r - 1) + 2 * at(c + 1, r) + at(c + 1, r + 1))
|
|
95
|
+
- (at(c - 1, r - 1) + 2 * at(c - 1, r) + at(c - 1, r + 1))
|
|
96
|
+
) / (8 * g.cell)
|
|
97
|
+
dzdy = (
|
|
98
|
+
(at(c - 1, r - 1) + 2 * at(c, r - 1) + at(c + 1, r - 1))
|
|
99
|
+
- (at(c - 1, r + 1) + 2 * at(c, r + 1) + at(c + 1, r + 1))
|
|
100
|
+
) / (8 * g.cell)
|
|
101
|
+
slope = math.degrees(math.atan(math.hypot(dzdx, dzdy)))
|
|
102
|
+
if dzdx == 0 and dzdy == 0:
|
|
103
|
+
return slope, -1.0
|
|
104
|
+
aspect = math.degrees(math.atan2(-dzdx, -dzdy))
|
|
105
|
+
if aspect < 0:
|
|
106
|
+
aspect += 360
|
|
107
|
+
return slope, aspect
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def hillshade(
|
|
111
|
+
g: Grid, azimuth_deg: float = 315.0, altitude_deg: float = 45.0, z_factor: float = 1.0
|
|
112
|
+
) -> np.ndarray:
|
|
113
|
+
"""Hillshade in ``0..1`` for a light from ``azimuth_deg`` / ``altitude_deg``."""
|
|
114
|
+
zen = math.radians(90 - altitude_deg)
|
|
115
|
+
az = math.radians(azimuth_deg)
|
|
116
|
+
lx = math.sin(zen) * math.sin(az)
|
|
117
|
+
ly = math.sin(zen) * math.cos(az)
|
|
118
|
+
lz = math.cos(zen)
|
|
119
|
+
dzdx, dzdy = _horn(g)
|
|
120
|
+
dzdx = dzdx * z_factor
|
|
121
|
+
dzdy = dzdy * z_factor
|
|
122
|
+
shade = (-dzdx * lx - dzdy * ly + lz) / np.sqrt(dzdx * dzdx + dzdy * dzdy + 1)
|
|
123
|
+
return np.maximum(0.0, shade)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class ContourSet:
|
|
128
|
+
"""Contour segments at one level; ``segments`` is ``(n, 4)``: x1, y1, x2, y2."""
|
|
129
|
+
|
|
130
|
+
level: float
|
|
131
|
+
segments: np.ndarray
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def contours(
|
|
135
|
+
g: Grid, interval: float, base: float = 0.0, max_levels: int = 400
|
|
136
|
+
) -> list[ContourSet]:
|
|
137
|
+
"""Contour line segments by marching squares, sorted by level.
|
|
138
|
+
|
|
139
|
+
Cells with a NaN corner produce no segments; a saddle cell yields two.
|
|
140
|
+
"""
|
|
141
|
+
z = g.values
|
|
142
|
+
if not (interval > 0) or not np.isfinite(z).any():
|
|
143
|
+
return []
|
|
144
|
+
lo = float(np.nanmin(z))
|
|
145
|
+
hi = float(np.nanmax(z))
|
|
146
|
+
k_first = math.ceil((lo - base) / interval)
|
|
147
|
+
k_last = min(math.floor((hi - base) / interval), k_first + max_levels - 1)
|
|
148
|
+
if g.rows < 2 or g.cols < 2:
|
|
149
|
+
return []
|
|
150
|
+
v0 = z[:-1, :-1]
|
|
151
|
+
v1 = z[:-1, 1:]
|
|
152
|
+
v2 = z[1:, 1:]
|
|
153
|
+
v3 = z[1:, :-1]
|
|
154
|
+
valid = np.isfinite(v0) & np.isfinite(v1) & np.isfinite(v2) & np.isfinite(v3)
|
|
155
|
+
nr, nc = v0.shape
|
|
156
|
+
xa = g.min_x + np.arange(nc) * g.cell
|
|
157
|
+
ya = g.max_y - np.arange(nr) * g.cell
|
|
158
|
+
xa, ya = np.meshgrid(xa, ya)
|
|
159
|
+
xb = xa + g.cell
|
|
160
|
+
yb = ya - g.cell
|
|
161
|
+
cell = g.cell
|
|
162
|
+
out: list[ContourSet] = []
|
|
163
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
164
|
+
for k in range(k_first, k_last + 1):
|
|
165
|
+
level = base + k * interval
|
|
166
|
+
b0, b1, b2, b3 = v0 < level, v1 < level, v2 < level, v3 < level
|
|
167
|
+
e = [b0 != b1, b1 != b2, b2 != b3, b3 != b0]
|
|
168
|
+
n = e[0].astype(int) + e[1] + e[2] + e[3]
|
|
169
|
+
ok = valid & ((n == 2) | (n == 4))
|
|
170
|
+
if not ok.any():
|
|
171
|
+
continue
|
|
172
|
+
pts = [
|
|
173
|
+
(xa + cell * (level - v0) / (v1 - v0), ya),
|
|
174
|
+
(xb, ya - cell * (level - v1) / (v2 - v1)),
|
|
175
|
+
(xb - cell * (level - v2) / (v3 - v2), yb),
|
|
176
|
+
(xa, yb + cell * (level - v3) / (v0 - v3)),
|
|
177
|
+
]
|
|
178
|
+
rr, cc = np.nonzero(ok)
|
|
179
|
+
segs: list[list[float]] = []
|
|
180
|
+
for r, c in zip(rr.tolist(), cc.tolist()):
|
|
181
|
+
p: list[float] = []
|
|
182
|
+
for j in range(4):
|
|
183
|
+
if e[j][r, c]:
|
|
184
|
+
p.append(float(pts[j][0][r, c]))
|
|
185
|
+
p.append(float(pts[j][1][r, c]))
|
|
186
|
+
segs.append(p[:4])
|
|
187
|
+
if len(p) == 8:
|
|
188
|
+
segs.append(p[4:])
|
|
189
|
+
out.append(ContourSet(level, np.asarray(segs, dtype=float).reshape(-1, 4)))
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass
|
|
194
|
+
class Profile:
|
|
195
|
+
"""Heights sampled along a polyline: distance ``d``, position ``x, y``, height ``z``."""
|
|
196
|
+
|
|
197
|
+
d: np.ndarray
|
|
198
|
+
x: np.ndarray
|
|
199
|
+
y: np.ndarray
|
|
200
|
+
z: np.ndarray
|
|
201
|
+
|
|
202
|
+
def __len__(self) -> int:
|
|
203
|
+
return int(self.d.size)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def profile(g: Grid, points: Sequence[Sequence[float]], step: Optional[float] = None) -> Profile:
|
|
207
|
+
"""Terrain section: sample heights along a polyline every ``step`` metres.
|
|
208
|
+
|
|
209
|
+
``step`` defaults to half a cell. Gaps in the DEM (NaN) come back as NaN.
|
|
210
|
+
"""
|
|
211
|
+
s = step or g.cell / 2
|
|
212
|
+
ds: list[np.ndarray] = []
|
|
213
|
+
xs: list[np.ndarray] = []
|
|
214
|
+
ys: list[np.ndarray] = []
|
|
215
|
+
dist = 0.0
|
|
216
|
+
for i in range(len(points) - 1):
|
|
217
|
+
x0, y0 = float(points[i][0]), float(points[i][1])
|
|
218
|
+
x1, y1 = float(points[i + 1][0]), float(points[i + 1][1])
|
|
219
|
+
length = math.hypot(x1 - x0, y1 - y0)
|
|
220
|
+
n = max(1, math.ceil(length / s))
|
|
221
|
+
t = np.arange(0 if i == 0 else 1, n + 1) / n
|
|
222
|
+
xs.append(x0 + (x1 - x0) * t)
|
|
223
|
+
ys.append(y0 + (y1 - y0) * t)
|
|
224
|
+
ds.append(dist + length * t)
|
|
225
|
+
dist += length
|
|
226
|
+
if not ds:
|
|
227
|
+
empty = np.zeros(0)
|
|
228
|
+
return Profile(empty, empty.copy(), empty.copy(), empty.copy())
|
|
229
|
+
x = np.concatenate(xs)
|
|
230
|
+
y = np.concatenate(ys)
|
|
231
|
+
return Profile(np.concatenate(ds), x, y, g.sample_many(x, y))
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass
|
|
235
|
+
class FloodResult:
|
|
236
|
+
"""Water depth per cell (NaN where dry) and totals."""
|
|
237
|
+
|
|
238
|
+
depth: np.ndarray
|
|
239
|
+
flooded_cells: int
|
|
240
|
+
area: float
|
|
241
|
+
volume: float
|
|
242
|
+
max_depth: float
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def flood(g: Grid, level: float, seed: Optional[Sequence[float]] = None) -> FloodResult:
|
|
246
|
+
"""Cells under a water ``level``.
|
|
247
|
+
|
|
248
|
+
Without a ``seed`` every cell below the level floods (a plain bathtub).
|
|
249
|
+
With a seed ``(x, y)`` only cells 4-connected to it flood, like water
|
|
250
|
+
rising from that point. NaN cells never flood.
|
|
251
|
+
"""
|
|
252
|
+
z = g.values
|
|
253
|
+
rows, cols = z.shape
|
|
254
|
+
depth = np.full(z.shape, np.nan)
|
|
255
|
+
with np.errstate(invalid="ignore"):
|
|
256
|
+
below = z < level
|
|
257
|
+
if seed is not None:
|
|
258
|
+
c0 = int(round((seed[0] - g.min_x) / g.cell))
|
|
259
|
+
r0 = int(round((g.max_y - seed[1]) / g.cell))
|
|
260
|
+
if c0 < 0 or r0 < 0 or c0 >= cols or r0 >= rows or not below[r0, c0]:
|
|
261
|
+
return FloodResult(depth, 0, 0.0, 0.0, 0.0)
|
|
262
|
+
mask = np.zeros(z.shape, dtype=bool)
|
|
263
|
+
mask[r0, c0] = True
|
|
264
|
+
stack = [(r0, c0)]
|
|
265
|
+
while stack:
|
|
266
|
+
r, c = stack.pop()
|
|
267
|
+
for rr, cc in ((r, c - 1), (r, c + 1), (r - 1, c), (r + 1, c)):
|
|
268
|
+
if 0 <= rr < rows and 0 <= cc < cols and not mask[rr, cc] and below[rr, cc]:
|
|
269
|
+
mask[rr, cc] = True
|
|
270
|
+
stack.append((rr, cc))
|
|
271
|
+
else:
|
|
272
|
+
mask = below
|
|
273
|
+
d = level - z[mask]
|
|
274
|
+
depth[mask] = d
|
|
275
|
+
count = int(mask.sum())
|
|
276
|
+
return FloodResult(
|
|
277
|
+
depth=depth,
|
|
278
|
+
flooded_cells=count,
|
|
279
|
+
area=count * g.cell * g.cell,
|
|
280
|
+
volume=float(d.sum()) * g.cell * g.cell,
|
|
281
|
+
max_depth=float(max(0.0, d.max())) if count else 0.0,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def points_in_ring(x: np.ndarray, y: np.ndarray, ring: Ring) -> np.ndarray:
|
|
286
|
+
"""Vectorised even-odd point-in-polygon test."""
|
|
287
|
+
x = np.asarray(x, dtype=float)
|
|
288
|
+
y = np.asarray(y, dtype=float)
|
|
289
|
+
inside = np.zeros(np.broadcast(x, y).shape, dtype=bool)
|
|
290
|
+
n = len(ring)
|
|
291
|
+
j = n - 1
|
|
292
|
+
for i in range(n):
|
|
293
|
+
xi, yi = float(ring[i][0]), float(ring[i][1])
|
|
294
|
+
xj, yj = float(ring[j][0]), float(ring[j][1])
|
|
295
|
+
dy = (yj - yi) or 1e-12
|
|
296
|
+
cross = ((yi > y) != (yj > y)) & (x < (xj - xi) * (y - yi) / dy + xi)
|
|
297
|
+
inside ^= cross
|
|
298
|
+
j = i
|
|
299
|
+
return inside
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def point_in_ring(x: float, y: float, ring: Ring) -> bool:
|
|
303
|
+
"""Even-odd test of one point against a polygon ring ``[(x, y), ...]``."""
|
|
304
|
+
return bool(points_in_ring(np.array(x), np.array(y), ring))
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@dataclass
|
|
308
|
+
class CutFillResult:
|
|
309
|
+
"""Earthwork volumes (m3) and per-cell ``delta = design - ground`` (NaN outside)."""
|
|
310
|
+
|
|
311
|
+
cut: float
|
|
312
|
+
fill: float
|
|
313
|
+
net: float
|
|
314
|
+
area: float
|
|
315
|
+
delta: np.ndarray
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def cut_fill(g: Grid, ring: Ring, design_level: float) -> CutFillResult:
|
|
319
|
+
"""Cut and fill to bring the ground inside ``ring`` to a flat ``design_level``."""
|
|
320
|
+
z = g.values
|
|
321
|
+
rows, cols = z.shape
|
|
322
|
+
delta = np.full(z.shape, np.nan)
|
|
323
|
+
ring_arr = np.asarray(ring, dtype=float)
|
|
324
|
+
min_x, min_y = ring_arr[:, 0].min(), ring_arr[:, 1].min()
|
|
325
|
+
max_x, max_y = ring_arr[:, 0].max(), ring_arr[:, 1].max()
|
|
326
|
+
c0 = max(0, math.floor((min_x - g.min_x) / g.cell))
|
|
327
|
+
c1 = min(cols - 1, math.ceil((max_x - g.min_x) / g.cell))
|
|
328
|
+
r0 = max(0, math.floor((g.max_y - max_y) / g.cell))
|
|
329
|
+
r1 = min(rows - 1, math.ceil((g.max_y - min_y) / g.cell))
|
|
330
|
+
if c1 < c0 or r1 < r0:
|
|
331
|
+
return CutFillResult(0.0, 0.0, 0.0, 0.0, delta)
|
|
332
|
+
xs = g.min_x + np.arange(c0, c1 + 1) * g.cell
|
|
333
|
+
ys = g.max_y - np.arange(r0, r1 + 1) * g.cell
|
|
334
|
+
xx, yy = np.meshgrid(xs, ys)
|
|
335
|
+
sub = z[r0 : r1 + 1, c0 : c1 + 1]
|
|
336
|
+
inside = points_in_ring(xx, yy, ring) & np.isfinite(sub)
|
|
337
|
+
d = design_level - sub[inside]
|
|
338
|
+
window = delta[r0 : r1 + 1, c0 : c1 + 1]
|
|
339
|
+
window[inside] = d
|
|
340
|
+
a = g.cell * g.cell
|
|
341
|
+
fill = float(d[d > 0].sum()) * a
|
|
342
|
+
cut = float(-d[d <= 0].sum()) * a
|
|
343
|
+
return CutFillResult(cut, fill, fill - cut, int(inside.sum()) * a, delta)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def balanced_level(g: Grid, ring: Ring) -> float:
|
|
347
|
+
"""Design level that balances cut and fill inside ``ring`` (bisection).
|
|
348
|
+
|
|
349
|
+
A polygon smaller than a cell holds no cell centre: the ground at its
|
|
350
|
+
centre is returned.
|
|
351
|
+
"""
|
|
352
|
+
ground = -cut_fill(g, ring, 0.0).delta
|
|
353
|
+
finite = ground[np.isfinite(ground)]
|
|
354
|
+
if finite.size == 0:
|
|
355
|
+
pts = np.asarray(ring, dtype=float)
|
|
356
|
+
cx, cy = pts[:, 0].mean(), pts[:, 1].mean()
|
|
357
|
+
c = max(0, min(g.cols - 1, int(round((cx - g.min_x) / g.cell))))
|
|
358
|
+
r = max(0, min(g.rows - 1, int(round((g.max_y - cy) / g.cell))))
|
|
359
|
+
return float(g.values[r, c])
|
|
360
|
+
lo, hi = float(finite.min()), float(finite.max())
|
|
361
|
+
for _ in range(40):
|
|
362
|
+
mid = (lo + hi) / 2
|
|
363
|
+
if cut_fill(g, ring, mid).net > 0:
|
|
364
|
+
hi = mid
|
|
365
|
+
else:
|
|
366
|
+
lo = mid
|
|
367
|
+
return (lo + hi) / 2
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@dataclass
|
|
371
|
+
class GridStats:
|
|
372
|
+
"""Minimum, maximum and mean of the finite values, and how many there are."""
|
|
373
|
+
|
|
374
|
+
min: float
|
|
375
|
+
max: float
|
|
376
|
+
mean: float
|
|
377
|
+
count: int
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def grid_stats(values: np.ndarray) -> GridStats:
|
|
381
|
+
"""Statistics of the finite values (NaN / inf ignored)."""
|
|
382
|
+
v = np.asarray(values, dtype=float).ravel()
|
|
383
|
+
v = v[np.isfinite(v)]
|
|
384
|
+
if v.size == 0:
|
|
385
|
+
return GridStats(math.inf, -math.inf, math.nan, 0)
|
|
386
|
+
return GridStats(float(v.min()), float(v.max()), float(v.mean()), int(v.size))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ------------------------------------------------------------------ derivatives
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def crop_grid(g: Grid, area: dict, max_cells: int = 4_000_000) -> tuple[Grid, int]:
|
|
393
|
+
"""The part of ``g`` inside ``area`` (``min_x, max_x, min_y, max_y``).
|
|
394
|
+
|
|
395
|
+
The crop is coarsened by whole cells (block means, NaN ignored) until it
|
|
396
|
+
has at most ``max_cells`` cells.
|
|
397
|
+
|
|
398
|
+
Returns:
|
|
399
|
+
``(grid, factor)`` where ``factor`` is the coarsening factor.
|
|
400
|
+
"""
|
|
401
|
+
c0 = max(0, math.floor((area["min_x"] - g.min_x) / g.cell + 0.5))
|
|
402
|
+
c1 = min(g.cols - 1, math.ceil((area["max_x"] - g.min_x) / g.cell - 0.5))
|
|
403
|
+
r0 = max(0, math.floor((g.max_y - area["max_y"]) / g.cell + 0.5))
|
|
404
|
+
r1 = min(g.rows - 1, math.ceil((g.max_y - area["min_y"]) / g.cell - 0.5))
|
|
405
|
+
if c1 < c0 or r1 < r0:
|
|
406
|
+
raise ValueError("The area does not overlap the terrain.")
|
|
407
|
+
w = c1 - c0 + 1
|
|
408
|
+
h = r1 - r0 + 1
|
|
409
|
+
k = max(1, math.ceil(math.sqrt((w * h) / max_cells)))
|
|
410
|
+
cols = math.ceil(w / k)
|
|
411
|
+
rows = math.ceil(h / k)
|
|
412
|
+
sub = g.values[r0 : r1 + 1, c0 : c1 + 1]
|
|
413
|
+
padded = np.full((rows * k, cols * k), np.nan)
|
|
414
|
+
padded[:h, :w] = sub
|
|
415
|
+
blocks = padded.reshape(rows, k, cols, k)
|
|
416
|
+
finite = np.isfinite(blocks)
|
|
417
|
+
s = np.where(finite, blocks, 0.0).sum(axis=(1, 3))
|
|
418
|
+
n = finite.sum(axis=(1, 3))
|
|
419
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
420
|
+
values = np.where(n > 0, s / np.maximum(n, 1), np.nan)
|
|
421
|
+
out = Grid(
|
|
422
|
+
values,
|
|
423
|
+
g.cell * k,
|
|
424
|
+
g.min_x + (c0 + (k - 1) / 2) * g.cell,
|
|
425
|
+
g.max_y - (r0 + (k - 1) / 2) * g.cell,
|
|
426
|
+
g.nodata,
|
|
427
|
+
)
|
|
428
|
+
return out, k
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def curvature(g: Grid) -> np.ndarray:
|
|
432
|
+
"""General curvature (Zevenbergen and Thorne 1987) in 1/100 m.
|
|
433
|
+
|
|
434
|
+
Positive on convex ground (ridges), negative on concave ground (valleys).
|
|
435
|
+
"""
|
|
436
|
+
p = _padded(g.values)
|
|
437
|
+
z5 = g.values
|
|
438
|
+
l2 = g.cell * g.cell
|
|
439
|
+
d = ((p[1:-1, :-2] + p[1:-1, 2:]) / 2 - z5) / l2
|
|
440
|
+
e = ((p[:-2, 1:-1] + p[2:, 1:-1]) / 2 - z5) / l2
|
|
441
|
+
return -2 * (d + e) * 100
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def tpi(g: Grid, radius: float) -> np.ndarray:
|
|
445
|
+
"""Topographic position index (Weiss 2001).
|
|
446
|
+
|
|
447
|
+
Elevation minus the mean of a square window of ``radius`` cells around it
|
|
448
|
+
(the cell itself left out, NaN neighbours ignored).
|
|
449
|
+
"""
|
|
450
|
+
z = g.values
|
|
451
|
+
rows, cols = z.shape
|
|
452
|
+
rad = max(1, int(math.floor(radius + 0.5)))
|
|
453
|
+
finite = np.isfinite(z)
|
|
454
|
+
s = np.zeros((rows + 1, cols + 1))
|
|
455
|
+
n = np.zeros((rows + 1, cols + 1))
|
|
456
|
+
s[1:, 1:] = np.where(finite, z, 0.0).cumsum(0).cumsum(1)
|
|
457
|
+
n[1:, 1:] = finite.cumsum(0).cumsum(1)
|
|
458
|
+
r = np.arange(rows)[:, None]
|
|
459
|
+
c = np.arange(cols)[None, :]
|
|
460
|
+
a = np.maximum(0, r - rad)
|
|
461
|
+
b = np.minimum(rows - 1, r + rad)
|
|
462
|
+
lft = np.maximum(0, c - rad)
|
|
463
|
+
rt = np.minimum(cols - 1, c + rad)
|
|
464
|
+
|
|
465
|
+
def box(t: np.ndarray) -> np.ndarray:
|
|
466
|
+
return t[b + 1, rt + 1] - t[a, rt + 1] - t[b + 1, lft] + t[a, lft]
|
|
467
|
+
|
|
468
|
+
ssum = box(s)
|
|
469
|
+
cnt = box(n)
|
|
470
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
471
|
+
out = np.where(cnt > 1, z - (ssum - z) / (cnt - 1), 0.0)
|
|
472
|
+
return np.where(finite, out, np.nan)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def ruggedness(g: Grid) -> np.ndarray:
|
|
476
|
+
"""Terrain ruggedness index (Riley et al. 1999), in metres."""
|
|
477
|
+
z = g.values
|
|
478
|
+
p = _padded(z)
|
|
479
|
+
rows, cols = z.shape
|
|
480
|
+
total = np.zeros(z.shape)
|
|
481
|
+
for dr in (-1, 0, 1):
|
|
482
|
+
for dc in (-1, 0, 1):
|
|
483
|
+
if dr == 0 and dc == 0:
|
|
484
|
+
continue
|
|
485
|
+
nb = p[1 + dr : 1 + dr + rows, 1 + dc : 1 + dc + cols]
|
|
486
|
+
diff = nb - z
|
|
487
|
+
total += np.where(np.isfinite(nb), diff * diff, 0.0)
|
|
488
|
+
return np.where(np.isfinite(z), np.sqrt(total), np.nan)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def aspect_classes(slope: np.ndarray, aspect: np.ndarray, flat_deg: float = 1.0) -> np.ndarray:
|
|
492
|
+
"""Aspect classes: 0 flat (slope under ``flat_deg``), 1 N, 2 NE, ... 8 NW."""
|
|
493
|
+
slope = np.asarray(slope, dtype=float)
|
|
494
|
+
aspect = np.asarray(aspect, dtype=float)
|
|
495
|
+
with np.errstate(invalid="ignore"):
|
|
496
|
+
cls = 1 + np.mod(np.floor((aspect + 22.5) / 45), 8)
|
|
497
|
+
out = np.where((slope < flat_deg) | (aspect < 0), 0.0, cls)
|
|
498
|
+
return np.where(np.isfinite(slope), out, np.nan)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
_DC = (-1, 0, 1, -1, 1, -1, 0, 1)
|
|
502
|
+
_DR = (-1, -1, -1, 0, 0, 1, 1, 1)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def flow_accumulation(g: Grid) -> tuple[np.ndarray, np.ndarray]:
|
|
506
|
+
"""D8 flow accumulation (cells, each counting itself) on a filled surface.
|
|
507
|
+
|
|
508
|
+
Depressions are filled by priority flood from the edges (Barnes et al.
|
|
509
|
+
2014) with a tiny rise across flats so every cell drains. NaN cells act as
|
|
510
|
+
outlets like the edge; water reaching an edge cell leaves the DEM there.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
``(acc, filled)``, both ``(rows, cols)``; NaN on no-data cells.
|
|
514
|
+
"""
|
|
515
|
+
z = g.values
|
|
516
|
+
rows, cols = z.shape
|
|
517
|
+
n_cells = rows * cols
|
|
518
|
+
zf = z.ravel()
|
|
519
|
+
finite = np.isfinite(zf)
|
|
520
|
+
filled = np.where(finite, zf, -np.inf).astype(float)
|
|
521
|
+
done = ~finite
|
|
522
|
+
# Edge: border cells and cells with a no-data 8-neighbour.
|
|
523
|
+
nanpad = np.pad(~np.isfinite(z), 1, constant_values=True)
|
|
524
|
+
near_nan = np.zeros(z.shape, dtype=bool)
|
|
525
|
+
for dr, dc in zip(_DR, _DC):
|
|
526
|
+
near_nan |= nanpad[1 + dr : 1 + dr + rows, 1 + dc : 1 + dc + cols]
|
|
527
|
+
is_edge = (near_nan.ravel()) & finite
|
|
528
|
+
eps = 1e-5 * max(1.0, g.cell)
|
|
529
|
+
heap: list[tuple[float, int, int]] = []
|
|
530
|
+
seq = 0
|
|
531
|
+
for i in np.flatnonzero(is_edge).tolist():
|
|
532
|
+
done[i] = True
|
|
533
|
+
heap.append((float(filled[i]), seq, i))
|
|
534
|
+
seq += 1
|
|
535
|
+
heapq.heapify(heap)
|
|
536
|
+
order: list[int] = []
|
|
537
|
+
while heap:
|
|
538
|
+
_, _, i = heapq.heappop(heap)
|
|
539
|
+
order.append(i)
|
|
540
|
+
r, c = divmod(i, cols)
|
|
541
|
+
fi = filled[i]
|
|
542
|
+
for k in range(8):
|
|
543
|
+
rr = r + _DR[k]
|
|
544
|
+
cc = c + _DC[k]
|
|
545
|
+
if rr < 0 or cc < 0 or rr >= rows or cc >= cols:
|
|
546
|
+
continue
|
|
547
|
+
j = rr * cols + cc
|
|
548
|
+
if done[j]:
|
|
549
|
+
continue
|
|
550
|
+
done[j] = True
|
|
551
|
+
v = max(zf[j], fi + eps)
|
|
552
|
+
filled[j] = v
|
|
553
|
+
heapq.heappush(heap, (v, seq, j))
|
|
554
|
+
seq += 1
|
|
555
|
+
# Receivers: steepest descent on the filled surface (diagonals over sqrt 2).
|
|
556
|
+
f2 = filled.reshape(rows, cols)
|
|
557
|
+
fpad = np.pad(f2, 1, constant_values=np.nan)
|
|
558
|
+
finpad = np.pad(np.isfinite(z), 1, constant_values=False)
|
|
559
|
+
edgepad = np.pad(is_edge.reshape(rows, cols), 1, constant_values=False)
|
|
560
|
+
edge2 = is_edge.reshape(rows, cols)
|
|
561
|
+
drops = np.full((8, rows, cols), -np.inf)
|
|
562
|
+
for k in range(8):
|
|
563
|
+
dr, dc = _DR[k], _DC[k]
|
|
564
|
+
nb = fpad[1 + dr : 1 + dr + rows, 1 + dc : 1 + dc + cols]
|
|
565
|
+
ok = finpad[1 + dr : 1 + dr + rows, 1 + dc : 1 + dc + cols]
|
|
566
|
+
ok = ok & ~(edge2 & edgepad[1 + dr : 1 + dr + rows, 1 + dc : 1 + dc + cols])
|
|
567
|
+
dist = math.sqrt(2) if (dr and dc) else 1.0
|
|
568
|
+
with np.errstate(invalid="ignore"):
|
|
569
|
+
drops[k] = np.where(ok, (f2 - nb) / dist, -np.inf)
|
|
570
|
+
best_k = np.argmax(drops, axis=0)
|
|
571
|
+
best_d = np.take_along_axis(drops, best_k[None], 0)[0]
|
|
572
|
+
rr_idx = np.arange(rows)[:, None] + np.asarray(_DR)[best_k]
|
|
573
|
+
cc_idx = np.arange(cols)[None, :] + np.asarray(_DC)[best_k]
|
|
574
|
+
receiver = np.where(best_d > 0, rr_idx * cols + cc_idx, -1).ravel().tolist()
|
|
575
|
+
acc = np.zeros(n_cells)
|
|
576
|
+
acc[order] = 1.0
|
|
577
|
+
acc_l = acc.tolist()
|
|
578
|
+
for i in reversed(order):
|
|
579
|
+
j = receiver[i]
|
|
580
|
+
if j >= 0:
|
|
581
|
+
acc_l[j] += acc_l[i]
|
|
582
|
+
acc = np.asarray(acc_l, dtype=float)
|
|
583
|
+
acc[~finite] = np.nan
|
|
584
|
+
filled_out = np.where(np.isfinite(filled), filled, np.nan)
|
|
585
|
+
return acc.reshape(rows, cols), filled_out.reshape(rows, cols)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def wetness(g: Grid, slope_deg: np.ndarray, acc: Optional[np.ndarray] = None) -> np.ndarray:
|
|
589
|
+
"""Topographic wetness index ``ln(a / tan(beta))`` (Beven and Kirkby 1979)."""
|
|
590
|
+
if acc is None:
|
|
591
|
+
acc = flow_accumulation(g)[0]
|
|
592
|
+
acc = np.asarray(acc, dtype=float)
|
|
593
|
+
slope_deg = np.asarray(slope_deg, dtype=float)
|
|
594
|
+
min_tan = math.tan(math.radians(0.1))
|
|
595
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
596
|
+
tan_b = np.maximum(min_tan, np.tan(np.radians(slope_deg)))
|
|
597
|
+
out = np.log((acc * g.cell) / tan_b)
|
|
598
|
+
return np.where(np.isfinite(acc) & np.isfinite(slope_deg), out, np.nan)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def percentile(values: np.ndarray, p: float, sample: int = 200_000) -> float:
|
|
602
|
+
"""Value at the ``p``-th percentile (0..1) of the finite values, from a sample."""
|
|
603
|
+
v = np.asarray(values, dtype=float).ravel()
|
|
604
|
+
step = max(1, v.size // sample)
|
|
605
|
+
s = v[::step]
|
|
606
|
+
s = np.sort(s[np.isfinite(s)])
|
|
607
|
+
if s.size == 0:
|
|
608
|
+
return math.nan
|
|
609
|
+
idx = min(s.size - 1, max(0, int(math.floor(p * (s.size - 1) + 0.5))))
|
|
610
|
+
return float(s[idx])
|