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,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
|