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,231 @@
1
+ """Analysis results: payload decoding, per-building aggregation and file output.
2
+
3
+ Headless counterpart of the plugin's QGIS result writer. Results are plain
4
+ numpy arrays on a :class:`~zero2geospatial3d.grid.Grid`; they can be
5
+
6
+ * summarised per building with :func:`aggregate_by_id` (mean / min / max /
7
+ area, as the viewer does for its ``*_roof`` fields),
8
+ * written as an ESRI ASCII grid (:func:`write_ascii_grid`, no dependencies),
9
+ a ``.npy`` + JSON sidecar, or a GeoTIFF when ``rasterio`` is installed
10
+ (:func:`write_geotiff`, ``pip install zero2geospatial3d-sdk[geo]``),
11
+ * received from the browser viewer (``grid`` / ``features`` / ``routes``
12
+ payloads posted to ``/api/result``) with :func:`save_result`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import json
19
+ import os
20
+ import re
21
+ from typing import Any, Optional
22
+
23
+ import numpy as np
24
+
25
+ from .grid import Grid
26
+
27
+ __all__ = [
28
+ "ResultError",
29
+ "decode_float32",
30
+ "encode_float32",
31
+ "aggregate_by_id",
32
+ "grid_stats",
33
+ "write_ascii_grid",
34
+ "write_geotiff",
35
+ "write_grid_npy",
36
+ "save_result",
37
+ ]
38
+
39
+
40
+ class ResultError(RuntimeError):
41
+ """A malformed result payload."""
42
+
43
+
44
+ def decode_float32(data_b64: str, count: int) -> np.ndarray:
45
+ """Little-endian float32 array from the viewer's base64 payload."""
46
+ raw = base64.b64decode(data_b64)
47
+ if len(raw) != count * 4:
48
+ raise ResultError(f"grid payload has {len(raw)} bytes, expected {count * 4}")
49
+ return np.frombuffer(raw, dtype="<f4").astype(np.float32)
50
+
51
+
52
+ def encode_float32(values: np.ndarray) -> str:
53
+ """Inverse of :func:`decode_float32`."""
54
+ return base64.b64encode(np.asarray(values, dtype="<f4").tobytes()).decode("ascii")
55
+
56
+
57
+ def grid_stats(values: np.ndarray) -> dict[str, float]:
58
+ """count/min/max/mean/std of the finite values (zeros when there are none)."""
59
+ v = np.asarray(values, dtype=float)
60
+ v = v[np.isfinite(v)]
61
+ if not v.size:
62
+ return {"count": 0, "min": 0.0, "max": 0.0, "mean": 0.0, "std": 0.0}
63
+ return {
64
+ "count": int(v.size),
65
+ "min": float(v.min()),
66
+ "max": float(v.max()),
67
+ "mean": float(v.mean()),
68
+ "std": float(v.std()),
69
+ }
70
+
71
+
72
+ def aggregate_by_id(
73
+ ids: np.ndarray, values: np.ndarray, cell_area: float = 1.0
74
+ ) -> dict[int, dict[str, float]]:
75
+ """Per-id ``mean``, ``min``, ``max``, ``total`` (value x area) and ``area``.
76
+
77
+ Id 0 (open ground) and non-finite values are skipped.
78
+ """
79
+ ids = np.asarray(ids).ravel()
80
+ vals = np.asarray(values, dtype=float).ravel()
81
+ ok = (ids != 0) & np.isfinite(vals)
82
+ out: dict[int, dict[str, float]] = {}
83
+ if not ok.any():
84
+ return out
85
+ ids = ids[ok]
86
+ vals = vals[ok]
87
+ order = np.argsort(ids, kind="stable")
88
+ ids = ids[order]
89
+ vals = vals[order]
90
+ uniq, start = np.unique(ids, return_index=True)
91
+ ends = list(start[1:]) + [len(ids)]
92
+ for u, a, b in zip(uniq, start, ends):
93
+ chunk = vals[a:b]
94
+ out[int(u)] = {
95
+ "mean": float(chunk.mean()),
96
+ "min": float(chunk.min()),
97
+ "max": float(chunk.max()),
98
+ "total": float(chunk.sum() * cell_area),
99
+ "area": float(len(chunk) * cell_area),
100
+ }
101
+ return out
102
+
103
+
104
+ def _key(text: Any) -> str:
105
+ return re.sub(r"[^a-z0-9_]+", "_", str(text or "").lower()).strip("_")[:60] or "result"
106
+
107
+
108
+ def _unique_path(path: str) -> str:
109
+ if not os.path.exists(path):
110
+ return path
111
+ stem, ext = os.path.splitext(path)
112
+ k = 2
113
+ while os.path.exists(f"{stem}_{k}{ext}"):
114
+ k += 1
115
+ return f"{stem}_{k}{ext}"
116
+
117
+
118
+ def write_ascii_grid(
119
+ path: str,
120
+ grid: Grid,
121
+ values: Optional[np.ndarray] = None,
122
+ origin: tuple[float, float] = (0.0, 0.0),
123
+ nodata: float = -9999.0,
124
+ ) -> str:
125
+ """Write an ESRI ASCII grid (``.asc``) in the scene's CRS (``origin`` added)."""
126
+ v = grid.values if values is None else np.asarray(values, dtype=float)
127
+ left = origin[0] + grid.min_x - grid.cell / 2
128
+ bottom = origin[1] + grid.max_y + grid.cell / 2 - grid.rows * grid.cell
129
+ with open(path, "w", encoding="ascii") as handle:
130
+ handle.write(f"ncols {grid.cols}\nnrows {grid.rows}\n")
131
+ handle.write(f"xllcorner {left!r}\nyllcorner {bottom!r}\n")
132
+ handle.write(f"cellsize {grid.cell!r}\nNODATA_value {nodata!r}\n")
133
+ for row in np.where(np.isfinite(v), v, nodata):
134
+ handle.write(" ".join(f"{x:.6g}" for x in row) + "\n")
135
+ return path
136
+
137
+
138
+ def write_grid_npy(
139
+ path: str,
140
+ grid: Grid,
141
+ values: Optional[np.ndarray] = None,
142
+ meta: Optional[dict[str, Any]] = None,
143
+ ) -> str:
144
+ """Save ``values`` as ``.npy`` and the georeference as a ``.json`` sidecar."""
145
+ v = grid.values if values is None else np.asarray(values)
146
+ np.save(path, v)
147
+ side = os.path.splitext(path)[0] + ".json"
148
+ info = {
149
+ "cols": grid.cols,
150
+ "rows": grid.rows,
151
+ "cell": grid.cell,
152
+ "minX": grid.min_x,
153
+ "maxY": grid.max_y,
154
+ }
155
+ info.update(meta or {})
156
+ with open(side, "w", encoding="utf-8") as handle:
157
+ json.dump(info, handle, indent=1)
158
+ return path
159
+
160
+
161
+ def write_geotiff(
162
+ path: str,
163
+ grid: Grid,
164
+ values: Optional[np.ndarray] = None,
165
+ *,
166
+ crs: Optional[str] = None,
167
+ origin: tuple[float, float] = (0.0, 0.0),
168
+ ) -> str:
169
+ """Write a float32 GeoTIFF (needs ``rasterio``; NaN is no data)."""
170
+ try:
171
+ import rasterio
172
+ from rasterio.transform import from_origin
173
+ except ImportError as exc: # pragma: no cover - optional dependency
174
+ raise ImportError(
175
+ "write_geotiff needs rasterio: pip install zero2geospatial3d-sdk[geo]"
176
+ ) from exc
177
+ v = (grid.values if values is None else np.asarray(values)).astype("float32")
178
+ left = origin[0] + grid.min_x - grid.cell / 2
179
+ top = origin[1] + grid.max_y + grid.cell / 2
180
+ with rasterio.open( # pragma: no cover - optional dependency
181
+ path,
182
+ "w",
183
+ driver="GTiff",
184
+ width=grid.cols,
185
+ height=grid.rows,
186
+ count=1,
187
+ dtype="float32",
188
+ crs=crs or None,
189
+ transform=from_origin(left, top, grid.cell, grid.cell),
190
+ nodata=float("nan"),
191
+ compress="deflate",
192
+ ) as dst:
193
+ dst.write(v, 1)
194
+ return path # pragma: no cover
195
+
196
+
197
+ def save_result(payload: dict[str, Any], out_dir: str) -> str:
198
+ """Store one viewer result payload under ``out_dir``; returns the file path.
199
+
200
+ ``grid`` payloads become ``<key>.npy`` + ``<key>.json``; ``features`` and
201
+ ``routes`` payloads are written as JSON.
202
+ """
203
+ if not isinstance(payload, dict):
204
+ raise ResultError("payload must be a dict")
205
+ kind = payload.get("type")
206
+ os.makedirs(out_dir, exist_ok=True)
207
+ name = str(payload.get("name") or payload.get("layer") or "result")
208
+ key = _key(payload.get("key") or name)
209
+ if kind == "grid":
210
+ g = payload.get("grid") or {}
211
+ try:
212
+ cols, rows, cell = int(g["cols"]), int(g["rows"]), float(g["cell"])
213
+ except (KeyError, TypeError, ValueError) as exc:
214
+ raise ResultError("grid payload without cols/rows/cell") from exc
215
+ if cols <= 0 or rows <= 0 or cols * rows > 40_000_000 or not cell > 0:
216
+ raise ResultError("grid size out of range")
217
+ values = decode_float32(payload["data"], cols * rows).reshape(rows, cols)
218
+ grid = Grid(values, cell, float(g.get("minX", 0.0)), float(g.get("maxY", 0.0)))
219
+ path = _unique_path(os.path.join(out_dir, f"{key}.npy"))
220
+ return write_grid_npy(
221
+ path,
222
+ grid,
223
+ values,
224
+ {"name": name, "unit": payload.get("unit", ""), "style": payload.get("style") or {}},
225
+ )
226
+ if kind in ("features", "routes"):
227
+ path = _unique_path(os.path.join(out_dir, f"{key}.json"))
228
+ with open(path, "w", encoding="utf-8") as handle:
229
+ json.dump(payload, handle, ensure_ascii=False)
230
+ return path
231
+ raise ResultError(f"unknown result type {kind!r}")
@@ -0,0 +1,141 @@
1
+ """Road line sources shared by the noise and traffic dispersion models.
2
+
3
+ Given polylines are sampled into points that carry their share of road length
4
+ times relative traffic; without roads, street centrelines are found between
5
+ the buildings (the ridge of the distance to the nearest solid building).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from typing import Any, Optional, Sequence
12
+
13
+ import numpy as np
14
+
15
+ __all__ = ["STREET_TRAFFIC", "road_weight", "sample_road_lines", "street_centrelines", "is_solid"]
16
+
17
+ #: Relative traffic of the streets found when no roads are given (a residential
18
+ #: street, as for OSM ``highway=residential``).
19
+ STREET_TRAFFIC = 0.4
20
+
21
+ Source = dict[str, float]
22
+
23
+
24
+ def road_weight(weights: Optional[Sequence[float]], q: int) -> float:
25
+ """Relative traffic of polyline ``q``: 1 without weights, never negative."""
26
+ if weights is None:
27
+ return 1.0
28
+ try:
29
+ w = float(weights[q])
30
+ except (TypeError, ValueError, IndexError):
31
+ return 1.0
32
+ return max(0.0, w) if math.isfinite(w) else 1.0
33
+
34
+
35
+ def sample_road_lines(
36
+ roads: Optional[Sequence[Sequence[Sequence[float]]]],
37
+ weights: Optional[Sequence[float]],
38
+ step: float,
39
+ ) -> tuple[list[Source], bool]:
40
+ """Points about ``step`` metres apart along road polylines.
41
+
42
+ Each point's ``length`` is the road length it stands for times the road's
43
+ traffic. ``given`` is True when at least one usable polyline was passed,
44
+ even if all carry zero traffic (those roads stay silent, no fallback).
45
+ """
46
+ sources: list[Source] = []
47
+ given = False
48
+ if roads is None:
49
+ return sources, given
50
+ for q, poly in enumerate(roads):
51
+ if poly is None or len(poly) < 2:
52
+ continue
53
+ given = True
54
+ w = road_weight(weights, q)
55
+ if w == 0:
56
+ continue
57
+ for p in range(len(poly) - 1):
58
+ x1, y1 = float(poly[p][0]), float(poly[p][1])
59
+ x2, y2 = float(poly[p + 1][0]), float(poly[p + 1][1])
60
+ length = math.hypot(x2 - x1, y2 - y1)
61
+ if not length > 0:
62
+ continue
63
+ count = max(1, int(math.floor(length / step + 0.5)))
64
+ seg = length / count
65
+ for k in range(count):
66
+ t = (k + 0.5) / count
67
+ sources.append(
68
+ {"x": x1 + t * (x2 - x1), "y": y1 + t * (y2 - y1), "length": seg * w}
69
+ )
70
+ return sources, given
71
+
72
+
73
+ def is_solid(heights: np.ndarray, porosity: Optional[np.ndarray]) -> np.ndarray:
74
+ """Boolean mask of solid obstacle cells (height > 0 and not porous foliage)."""
75
+ solid = heights > 0
76
+ if porosity is not None:
77
+ solid &= ~(porosity > 0)
78
+ return solid
79
+
80
+
81
+ def street_centrelines(
82
+ heights: Any,
83
+ porosity: Any,
84
+ cols: int,
85
+ rows: int,
86
+ cell: float,
87
+ min_x: float,
88
+ max_y: float,
89
+ max_half_width: float = 25.0,
90
+ ) -> list[Source]:
91
+ """Street centreline sources between buildings (empty without buildings).
92
+
93
+ One source per ridge cell of the two-pass chamfer distance to the nearest
94
+ solid building, standing for ``cell`` metres of residential street.
95
+ """
96
+ h = np.asarray(heights, dtype=float).reshape(rows, cols)
97
+ por = None if porosity is None else np.asarray(porosity, dtype=float).reshape(rows, cols)
98
+ solid = is_solid(h, por)
99
+ if not solid.any():
100
+ return []
101
+ big = 1e9
102
+ dist = np.where(solid, 0.0, big)
103
+ a, b = cell, cell * math.sqrt(2)
104
+ # Two-pass chamfer distance; row-wise loops keep the sequential dependency.
105
+ for r in range(rows):
106
+ row = dist[r]
107
+ if r > 0:
108
+ up = dist[r - 1]
109
+ cand = up + a
110
+ cand[1:] = np.minimum(cand[1:], up[:-1] + b)
111
+ cand[:-1] = np.minimum(cand[:-1], up[1:] + b)
112
+ np.minimum(row, cand, out=row)
113
+ for c in range(1, cols):
114
+ v = row[c - 1] + a
115
+ if v < row[c]:
116
+ row[c] = v
117
+ for r in range(rows - 1, -1, -1):
118
+ row = dist[r]
119
+ if r < rows - 1:
120
+ dn = dist[r + 1]
121
+ cand = dn + a
122
+ cand[:-1] = np.minimum(cand[:-1], dn[1:] + b)
123
+ cand[1:] = np.minimum(cand[1:], dn[:-1] + b)
124
+ np.minimum(row, cand, out=row)
125
+ for c in range(cols - 2, -1, -1):
126
+ v = row[c + 1] + a
127
+ if v < row[c]:
128
+ row[c] = v
129
+ sources: list[Source] = []
130
+ for r in range(1, rows - 1):
131
+ for c in range(1, cols - 1):
132
+ d = dist[r, c]
133
+ if d < cell or d > max_half_width:
134
+ continue
135
+ ridge_x = d >= dist[r, c - 1] and d > dist[r, c + 1]
136
+ ridge_y = d >= dist[r - 1, c] and d > dist[r + 1, c]
137
+ if ridge_x or ridge_y:
138
+ sources.append(
139
+ {"x": min_x + c * cell, "y": max_y - r * cell, "length": cell * STREET_TRAFFIC}
140
+ )
141
+ return sources