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.
Files changed (89) hide show
  1. zero2geospatial3d/__init__.py +105 -0
  2. zero2geospatial3d/acoustics.py +284 -0
  3. zero2geospatial3d/classify.py +379 -0
  4. zero2geospatial3d/cli.py +367 -0
  5. zero2geospatial3d/comfort.py +687 -0
  6. zero2geospatial3d/data/epw_stations.json.gz +0 -0
  7. zero2geospatial3d/daylight.py +337 -0
  8. zero2geospatial3d/dispersion.py +249 -0
  9. zero2geospatial3d/epw.py +162 -0
  10. zero2geospatial3d/geo.py +138 -0
  11. zero2geospatial3d/grid.py +119 -0
  12. zero2geospatial3d/microclimate.py +388 -0
  13. zero2geospatial3d/morphology.py +283 -0
  14. zero2geospatial3d/nsga2.py +315 -0
  15. zero2geospatial3d/osm.py +450 -0
  16. zero2geospatial3d/osm_tags.py +180 -0
  17. zero2geospatial3d/py.typed +0 -0
  18. zero2geospatial3d/results.py +231 -0
  19. zero2geospatial3d/road_sources.py +141 -0
  20. zero2geospatial3d/scene.py +555 -0
  21. zero2geospatial3d/server.py +332 -0
  22. zero2geospatial3d/shading.py +443 -0
  23. zero2geospatial3d/solar.py +207 -0
  24. zero2geospatial3d/terrain.py +610 -0
  25. zero2geospatial3d/urban_form.py +326 -0
  26. zero2geospatial3d/walkability.py +380 -0
  27. zero2geospatial3d/weather.py +373 -0
  28. zero2geospatial3d/web/index.html +50 -0
  29. zero2geospatial3d/web/src/analysis/climate.js +278 -0
  30. zero2geospatial3d/web/src/analysis/compliance.js +345 -0
  31. zero2geospatial3d/web/src/analysis/gpu.js +431 -0
  32. zero2geospatial3d/web/src/analysis/optimize.js +208 -0
  33. zero2geospatial3d/web/src/analysis/runners.js +1644 -0
  34. zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
  35. zero2geospatial3d/web/src/app.js +630 -0
  36. zero2geospatial3d/web/src/core/acoustics.js +341 -0
  37. zero2geospatial3d/web/src/core/classify.js +158 -0
  38. zero2geospatial3d/web/src/core/comfort.js +686 -0
  39. zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
  40. zero2geospatial3d/web/src/core/dispersion.js +264 -0
  41. zero2geospatial3d/web/src/core/microclimate.js +372 -0
  42. zero2geospatial3d/web/src/core/morphology.js +153 -0
  43. zero2geospatial3d/web/src/core/nsga2.js +181 -0
  44. zero2geospatial3d/web/src/core/party_walls.js +60 -0
  45. zero2geospatial3d/web/src/core/road_sources.js +127 -0
  46. zero2geospatial3d/web/src/core/solar.js +112 -0
  47. zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
  48. zero2geospatial3d/web/src/core/terrain_math.js +279 -0
  49. zero2geospatial3d/web/src/core/urban_form.js +405 -0
  50. zero2geospatial3d/web/src/core/walkability.js +316 -0
  51. zero2geospatial3d/web/src/core/weather.js +225 -0
  52. zero2geospatial3d/web/src/core/wind.js +131 -0
  53. zero2geospatial3d/web/src/export/check_pdf.js +74 -0
  54. zero2geospatial3d/web/src/export/exporters.js +556 -0
  55. zero2geospatial3d/web/src/export/pdf.js +149 -0
  56. zero2geospatial3d/web/src/export/snapshot.js +234 -0
  57. zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
  58. zero2geospatial3d/web/src/main.js +75 -0
  59. zero2geospatial3d/web/src/scene/objects.js +645 -0
  60. zero2geospatial3d/web/src/scene/overlay.js +145 -0
  61. zero2geospatial3d/web/src/scene/terrain.js +546 -0
  62. zero2geospatial3d/web/src/scene/viewer.js +275 -0
  63. zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
  64. zero2geospatial3d/web/src/style.css +225 -0
  65. zero2geospatial3d/web/src/ui/building_report.js +404 -0
  66. zero2geospatial3d/web/src/ui/charts.js +441 -0
  67. zero2geospatial3d/web/src/ui/colorize.js +101 -0
  68. zero2geospatial3d/web/src/ui/compare.js +362 -0
  69. zero2geospatial3d/web/src/ui/dom.js +104 -0
  70. zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
  71. zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
  72. zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
  73. zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
  74. zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
  75. zero2geospatial3d/web/src/ui/sunclock.js +45 -0
  76. zero2geospatial3d/web/src/ui/tools.js +197 -0
  77. zero2geospatial3d/web/src/ui/viewtools.js +173 -0
  78. zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
  79. zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
  80. zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
  81. zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
  82. zero2geospatial3d/wind.py +178 -0
  83. zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
  84. zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
  85. zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
  86. zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  87. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  88. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
  89. 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])