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,337 @@
|
|
|
1
|
+
"""Daylight to neighbouring windows after BRE 209 and party-wall detection.
|
|
2
|
+
|
|
3
|
+
Seen from the centre of a neighbour's lowest window, in a section at right
|
|
4
|
+
angles to the window wall, new building rising more than 25 degrees above the
|
|
5
|
+
horizontal may take noticeable daylight; it then passes if the window keeps at
|
|
6
|
+
least 0.8 times its former vertical sky component (VSC). The VSC is that of a
|
|
7
|
+
long obstruction at the subtended angle under the CIE overcast sky (closed
|
|
8
|
+
form :func:`vsc_long`), a screening on the safe side.
|
|
9
|
+
|
|
10
|
+
:func:`prepare_rule` is run once for a set of neighbour walls and buildings
|
|
11
|
+
whose heights change; :func:`check_rule` then costs a few operations per
|
|
12
|
+
window, so it can sit inside an optimiser.
|
|
13
|
+
|
|
14
|
+
Rings are sequences of ``(x, y)`` in local metres.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Optional, Sequence
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BRE_FACTOR",
|
|
27
|
+
"VSC_OPEN",
|
|
28
|
+
"VSC_TARGET",
|
|
29
|
+
"vsc_long",
|
|
30
|
+
"ray_to_rings",
|
|
31
|
+
"wall_samples",
|
|
32
|
+
"RulePrep",
|
|
33
|
+
"prepare_rule",
|
|
34
|
+
"check_rule",
|
|
35
|
+
"vsc_directions",
|
|
36
|
+
"allowed_angle",
|
|
37
|
+
"reach25",
|
|
38
|
+
"cover_index",
|
|
39
|
+
"cover_at",
|
|
40
|
+
"is_open",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
Ring = Sequence[Sequence[float]]
|
|
44
|
+
|
|
45
|
+
_TAN25 = math.tan(math.radians(25))
|
|
46
|
+
#: BRE 209: a loss to less than 0.8 times the former value is noticeable.
|
|
47
|
+
BRE_FACTOR = 0.8
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def vsc_long(angle: float) -> float:
|
|
51
|
+
"""VSC (0..1) of a window facing a long obstruction ``angle`` radians high.
|
|
52
|
+
|
|
53
|
+
``VSC(t) = [(pi/2)(1 - sin t) + (4/3) cos^2 t] / (7 pi / 3)`` under the
|
|
54
|
+
CIE standard overcast sky: 39.6 % for an open window, 27.3 % at 25 deg.
|
|
55
|
+
"""
|
|
56
|
+
t = max(0.0, min(math.pi / 2, angle))
|
|
57
|
+
return ((math.pi / 2) * (1 - math.sin(t)) + (4 / 3) * math.cos(t) ** 2) / (7 * math.pi / 3)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
#: VSC of an unobstructed vertical window (0.396).
|
|
61
|
+
VSC_OPEN = vsc_long(0.0)
|
|
62
|
+
#: BRE 209: a window with a VSC of 27 % or more keeps enough daylight.
|
|
63
|
+
VSC_TARGET = 0.27
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _signed_area(ring: Ring) -> float:
|
|
67
|
+
a = 0.0
|
|
68
|
+
j = len(ring) - 1
|
|
69
|
+
for i in range(len(ring)):
|
|
70
|
+
a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1]
|
|
71
|
+
j = i
|
|
72
|
+
return a / 2
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ray_to_rings(px: float, py: float, dx: float, dy: float, rings: Sequence[Ring]) -> float:
|
|
76
|
+
"""Distance along the ray ``p + t d`` (t > 0) to the nearest ring edge, or ``inf``."""
|
|
77
|
+
best = math.inf
|
|
78
|
+
for ring in rings:
|
|
79
|
+
j = len(ring) - 1
|
|
80
|
+
for i in range(len(ring)):
|
|
81
|
+
ax, ay = ring[j][0], ring[j][1]
|
|
82
|
+
ex, ey = ring[i][0] - ax, ring[i][1] - ay
|
|
83
|
+
j = i
|
|
84
|
+
den = dx * ey - dy * ex
|
|
85
|
+
if abs(den) < 1e-12:
|
|
86
|
+
continue
|
|
87
|
+
wx, wy = ax - px, ay - py
|
|
88
|
+
t = (wx * ey - wy * ex) / den
|
|
89
|
+
u = (wx * dy - wy * dx) / den
|
|
90
|
+
if t > 1e-9 and 0 <= u <= 1 and t < best:
|
|
91
|
+
best = t
|
|
92
|
+
return best
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _inside_ring(x: float, y: float, ring: Ring) -> bool:
|
|
96
|
+
odd = False
|
|
97
|
+
j = len(ring) - 1
|
|
98
|
+
for i in range(len(ring)):
|
|
99
|
+
xi, yi = ring[i][0], ring[i][1]
|
|
100
|
+
xj, yj = ring[j][0], ring[j][1]
|
|
101
|
+
if (yi > y) != (yj > y) and x < (xj - xi) * (y - yi) / (yj - yi) + xi:
|
|
102
|
+
odd = not odd
|
|
103
|
+
j = i
|
|
104
|
+
return odd
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _inside(x: float, y: float, rings: Sequence[Ring]) -> bool:
|
|
108
|
+
odd = False
|
|
109
|
+
for ring in rings:
|
|
110
|
+
if _inside_ring(x, y, ring):
|
|
111
|
+
odd = not odd
|
|
112
|
+
return odd
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def wall_samples(ring: Ring, step: float = 3.0) -> list[dict[str, float]]:
|
|
116
|
+
"""Window samples every ``step`` metres on the walls, with outward normals.
|
|
117
|
+
|
|
118
|
+
Returns dicts with ``x``, ``y``, ``nx``, ``ny``; edges shorter than 1 m
|
|
119
|
+
are skipped. Works for either ring orientation.
|
|
120
|
+
"""
|
|
121
|
+
out: list[dict[str, float]] = []
|
|
122
|
+
ccw = _signed_area(ring) > 0
|
|
123
|
+
j = len(ring) - 1
|
|
124
|
+
for i in range(len(ring)):
|
|
125
|
+
ax, ay = ring[j][0], ring[j][1]
|
|
126
|
+
ex, ey = ring[i][0] - ax, ring[i][1] - ay
|
|
127
|
+
j = i
|
|
128
|
+
length = math.hypot(ex, ey)
|
|
129
|
+
if length < 1:
|
|
130
|
+
continue
|
|
131
|
+
nx = (ey if ccw else -ey) / length
|
|
132
|
+
ny = (-ex if ccw else ex) / length
|
|
133
|
+
n = max(1, int(math.floor(length / step + 0.5)))
|
|
134
|
+
for k in range(n):
|
|
135
|
+
s = (k + 0.5) / n
|
|
136
|
+
out.append({"x": ax + ex * s, "y": ay + ey * s, "nx": nx + 0.0, "ny": ny + 0.0})
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class RulePrep:
|
|
142
|
+
"""Window/target pairs prepared by :func:`prepare_rule`."""
|
|
143
|
+
|
|
144
|
+
windows: int
|
|
145
|
+
sample_z: np.ndarray
|
|
146
|
+
now_tan: np.ndarray
|
|
147
|
+
owner: np.ndarray
|
|
148
|
+
pair_start: np.ndarray
|
|
149
|
+
pair_target: np.ndarray
|
|
150
|
+
pair_dist: np.ndarray
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def prepare_rule(
|
|
154
|
+
neighbours: Sequence[dict[str, Any]],
|
|
155
|
+
targets: Sequence[dict[str, Any]],
|
|
156
|
+
tops_now: Sequence[float],
|
|
157
|
+
*,
|
|
158
|
+
step: float = 3.0,
|
|
159
|
+
max_dist: float = 200.0,
|
|
160
|
+
party_wall: float = 1.0,
|
|
161
|
+
) -> RulePrep:
|
|
162
|
+
"""Pair neighbour windows with the target buildings in front of them.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
neighbours: ``[{"ring": ring, "window_z": z}, ...]`` outer ring and
|
|
166
|
+
absolute window-centre height of each neighbour.
|
|
167
|
+
targets: ``[{"rings": [ring, ...]}, ...]`` footprints whose heights change.
|
|
168
|
+
tops_now: current absolute roof heights of the targets.
|
|
169
|
+
max_dist: targets farther than this are ignored.
|
|
170
|
+
party_wall: hits closer than this are a shared wall, not a window.
|
|
171
|
+
"""
|
|
172
|
+
sample_z: list[float] = []
|
|
173
|
+
now_tan: list[float] = []
|
|
174
|
+
owner: list[int] = []
|
|
175
|
+
pair_start = [0]
|
|
176
|
+
pair_target: list[int] = []
|
|
177
|
+
pair_dist: list[float] = []
|
|
178
|
+
for k, nb in enumerate(neighbours):
|
|
179
|
+
wz = float(nb.get("window_z", nb.get("windowZ", 0.0)))
|
|
180
|
+
for s in wall_samples(nb["ring"], step):
|
|
181
|
+
before = len(pair_target)
|
|
182
|
+
shared = False
|
|
183
|
+
for ti, t in enumerate(targets):
|
|
184
|
+
off = party_wall * 0.5
|
|
185
|
+
if _inside(s["x"] + s["nx"] * off, s["y"] + s["ny"] * off, t["rings"]):
|
|
186
|
+
shared = True
|
|
187
|
+
break
|
|
188
|
+
d = ray_to_rings(s["x"], s["y"], s["nx"], s["ny"], t["rings"])
|
|
189
|
+
if d < party_wall:
|
|
190
|
+
shared = True
|
|
191
|
+
break
|
|
192
|
+
if d <= max_dist:
|
|
193
|
+
pair_target.append(ti)
|
|
194
|
+
pair_dist.append(d)
|
|
195
|
+
if shared or len(pair_target) == before:
|
|
196
|
+
del pair_target[before:]
|
|
197
|
+
del pair_dist[before:]
|
|
198
|
+
continue
|
|
199
|
+
now = 0.0
|
|
200
|
+
for p in range(before, len(pair_target)):
|
|
201
|
+
now = max(now, (tops_now[pair_target[p]] - wz) / pair_dist[p])
|
|
202
|
+
sample_z.append(wz)
|
|
203
|
+
now_tan.append(now)
|
|
204
|
+
owner.append(k)
|
|
205
|
+
pair_start.append(len(pair_target))
|
|
206
|
+
return RulePrep(
|
|
207
|
+
windows=len(sample_z),
|
|
208
|
+
sample_z=np.asarray(sample_z, dtype=float),
|
|
209
|
+
now_tan=np.asarray(now_tan, dtype=float),
|
|
210
|
+
owner=np.asarray(owner, dtype=np.int64),
|
|
211
|
+
pair_start=np.asarray(pair_start, dtype=np.int64),
|
|
212
|
+
pair_target=np.asarray(pair_target, dtype=np.int64),
|
|
213
|
+
pair_dist=np.asarray(pair_dist, dtype=float),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def check_rule(prep: RulePrep, tops: Sequence[float]) -> dict[str, float]:
|
|
218
|
+
"""Check a design against the 25-degree / 0.8 x VSC rule.
|
|
219
|
+
|
|
220
|
+
Returns ``{"failing", "excess_deg", "worst_deg", "neighbours_hit"}`` where
|
|
221
|
+
``excess_deg`` sums how far failing windows' obstructions rise above the
|
|
222
|
+
highest angle they would still pass at.
|
|
223
|
+
"""
|
|
224
|
+
failing = 0
|
|
225
|
+
excess = 0.0
|
|
226
|
+
worst = 0.0
|
|
227
|
+
hit: set[int] = set()
|
|
228
|
+
for s in range(prep.windows):
|
|
229
|
+
z = prep.sample_z[s]
|
|
230
|
+
tan = 0.0
|
|
231
|
+
for p in range(int(prep.pair_start[s]), int(prep.pair_start[s + 1])):
|
|
232
|
+
v = (tops[int(prep.pair_target[p])] - z) / prep.pair_dist[p]
|
|
233
|
+
if v > tan:
|
|
234
|
+
tan = v
|
|
235
|
+
if tan > worst:
|
|
236
|
+
worst = tan
|
|
237
|
+
if tan <= _TAN25 + 1e-12:
|
|
238
|
+
continue
|
|
239
|
+
allowed = allowed_angle(float(prep.now_tan[s]))
|
|
240
|
+
angle = math.atan(tan)
|
|
241
|
+
if angle > allowed + 1e-9:
|
|
242
|
+
failing += 1
|
|
243
|
+
excess += math.degrees(angle - allowed)
|
|
244
|
+
hit.add(int(prep.owner[s]))
|
|
245
|
+
return {
|
|
246
|
+
"failing": failing,
|
|
247
|
+
"excess_deg": excess,
|
|
248
|
+
"worst_deg": math.degrees(math.atan(worst)),
|
|
249
|
+
"neighbours_hit": len(hit),
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def vsc_directions(hemisphere: Sequence[Any]) -> list[dict[str, Any]]:
|
|
254
|
+
"""Weight hemisphere directions so ``sum(w * vis * max(0, n.d))`` is the VSC.
|
|
255
|
+
|
|
256
|
+
``hemisphere`` holds directions uniform in solid angle (y up), either as
|
|
257
|
+
dicts with a ``dir`` key or as plain 3-vectors.
|
|
258
|
+
"""
|
|
259
|
+
n = len(hemisphere)
|
|
260
|
+
out = []
|
|
261
|
+
for d in hemisphere:
|
|
262
|
+
v = d["dir"] if isinstance(d, dict) else d
|
|
263
|
+
out.append({"dir": v, "weight": (6 / (7 * n)) * (1 + 2 * max(0.0, v[1]))})
|
|
264
|
+
return out
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
_ALLOWED_CACHE: dict[int, float] = {}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def allowed_angle(now_tan: float) -> float:
|
|
271
|
+
"""Highest obstruction angle (radians) a window may have: 25 deg or 0.8 x its VSC now."""
|
|
272
|
+
key = int(math.floor(max(0.0, now_tan) * 1e4 + 0.5))
|
|
273
|
+
a = _ALLOWED_CACHE.get(key)
|
|
274
|
+
if a is not None:
|
|
275
|
+
return a
|
|
276
|
+
target = BRE_FACTOR * vsc_long(math.atan(key / 1e4))
|
|
277
|
+
lo, hi = 0.0, math.pi / 2
|
|
278
|
+
for _ in range(40):
|
|
279
|
+
mid = (lo + hi) / 2
|
|
280
|
+
if vsc_long(mid) > target:
|
|
281
|
+
lo = mid
|
|
282
|
+
else:
|
|
283
|
+
hi = mid
|
|
284
|
+
a = max(math.atan(_TAN25), lo)
|
|
285
|
+
if len(_ALLOWED_CACHE) < 50000:
|
|
286
|
+
_ALLOWED_CACHE[key] = a
|
|
287
|
+
return a
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def reach25(rise: float) -> float:
|
|
291
|
+
"""Distance beyond which a roof ``rise`` metres above a window stays under 25 deg."""
|
|
292
|
+
return max(0.0, rise) / _TAN25
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# --------------------------------------------------------------------------- party walls
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def cover_index(buildings: Sequence[dict[str, Any]], cell: float = 25.0) -> dict[str, Any]:
|
|
299
|
+
"""Grid index of footprint boxes for :func:`cover_at`.
|
|
300
|
+
|
|
301
|
+
``buildings``: ``[{"id", "rings", "bottom", "top"}, ...]`` with outer rings
|
|
302
|
+
in local metres and absolute bottom / roof elevations.
|
|
303
|
+
"""
|
|
304
|
+
cells: dict[tuple[int, int], list[int]] = {}
|
|
305
|
+
for k, b in enumerate(buildings):
|
|
306
|
+
pts = [p for ring in b["rings"] for p in ring]
|
|
307
|
+
if not pts:
|
|
308
|
+
continue
|
|
309
|
+
xs = [p[0] for p in pts]
|
|
310
|
+
ys = [p[1] for p in pts]
|
|
311
|
+
for cx in range(math.floor(min(xs) / cell), math.floor(max(xs) / cell) + 1):
|
|
312
|
+
for cy in range(math.floor(min(ys) / cell), math.floor(max(ys) / cell) + 1):
|
|
313
|
+
cells.setdefault((cx, cy), []).append(k)
|
|
314
|
+
return {"buildings": list(buildings), "cells": cells, "cell": cell}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def cover_at(
|
|
318
|
+
index: dict[str, Any], x: float, y: float, own_id: Optional[Any] = None
|
|
319
|
+
) -> list[list[float]]:
|
|
320
|
+
"""``[bottom, top]`` spans of other buildings whose footprint holds ``(x, y)``."""
|
|
321
|
+
cell = index["cell"]
|
|
322
|
+
lst = index["cells"].get((math.floor(x / cell), math.floor(y / cell)))
|
|
323
|
+
if not lst:
|
|
324
|
+
return []
|
|
325
|
+
out = []
|
|
326
|
+
for k in lst:
|
|
327
|
+
b = index["buildings"][k]
|
|
328
|
+
if b["id"] == own_id:
|
|
329
|
+
continue
|
|
330
|
+
if any(_inside_ring(x, y, ring) for ring in b["rings"]):
|
|
331
|
+
out.append([b["bottom"], b["top"]])
|
|
332
|
+
return out
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def is_open(z: float, spans: Sequence[Sequence[float]]) -> bool:
|
|
336
|
+
"""Whether a wall point at absolute height ``z`` is open to the air."""
|
|
337
|
+
return all(not (bottom <= z < top) for bottom, top in spans)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Street-canyon ventilation, normalised air age and traffic pollutant dispersion.
|
|
2
|
+
|
|
3
|
+
OSPM-inspired screening: a Gaussian line-source plume downwind of road
|
|
4
|
+
samples, a canyon recirculation trap where the sky view factor or the wind
|
|
5
|
+
ratio is low, and deposition on tree foliage.
|
|
6
|
+
|
|
7
|
+
Grids may be flat (row-major from the north-west cell) or ``(rows, cols)``;
|
|
8
|
+
returned grids are ``(rows, cols)``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import math
|
|
14
|
+
from typing import Any, Callable, Optional, Sequence
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from .road_sources import is_solid, sample_road_lines, street_centrelines
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"VENTILATION_CLASSES",
|
|
22
|
+
"ventilation_class",
|
|
23
|
+
"ventilation_efficiency",
|
|
24
|
+
"AIR_QUALITY_CLASSES",
|
|
25
|
+
"air_quality_class",
|
|
26
|
+
"pollutant_dispersion",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
VENTILATION_CLASSES: list[dict[str, Any]] = [
|
|
30
|
+
{"min": 0.8, "max": math.inf, "label": "Well ventilated", "color": "#2b83ba"},
|
|
31
|
+
{"min": 0.5, "max": 0.8, "label": "Moderate exchange", "color": "#abdda4"},
|
|
32
|
+
{"min": 0.3, "max": 0.5, "label": "Low ventilation", "color": "#fdae61"},
|
|
33
|
+
{"min": 0, "max": 0.3, "label": "Stagnation risk", "color": "#d7191c"},
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
AIR_QUALITY_CLASSES: list[dict[str, Any]] = [
|
|
37
|
+
{"min": 0, "max": 20, "label": "Good", "color": "#2b83ba"},
|
|
38
|
+
{"min": 20, "max": 40, "label": "Moderate", "color": "#abdda4"},
|
|
39
|
+
{"min": 40, "max": 80, "label": "Elevated", "color": "#fdae61"},
|
|
40
|
+
{"min": 80, "max": math.inf, "label": "High", "color": "#d7191c"},
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ventilation_class(vei_value: float) -> Optional[dict[str, Any]]:
|
|
45
|
+
"""Class dict for a Ventilation Efficiency Index value (``None`` if not finite)."""
|
|
46
|
+
if vei_value is None or not math.isfinite(vei_value):
|
|
47
|
+
return None
|
|
48
|
+
for c in VENTILATION_CLASSES:
|
|
49
|
+
if vei_value >= c["min"]:
|
|
50
|
+
return c
|
|
51
|
+
return VENTILATION_CLASSES[-1]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def air_quality_class(ugm3: float) -> Optional[dict[str, Any]]:
|
|
55
|
+
"""Class dict for a concentration in ug/m3 (``None`` if not finite)."""
|
|
56
|
+
if ugm3 is None or not math.isfinite(ugm3):
|
|
57
|
+
return None
|
|
58
|
+
for c in AIR_QUALITY_CLASSES:
|
|
59
|
+
if ugm3 < c["max"]:
|
|
60
|
+
return c
|
|
61
|
+
return AIR_QUALITY_CLASSES[-1]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _grid(a: Any, rows: int, cols: int) -> Optional[np.ndarray]:
|
|
65
|
+
return None if a is None else np.asarray(a, dtype=float).reshape(rows, cols)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ventilation_efficiency(
|
|
69
|
+
*,
|
|
70
|
+
heights: Any,
|
|
71
|
+
cols: int,
|
|
72
|
+
rows: int,
|
|
73
|
+
cell: float = 1.0,
|
|
74
|
+
wind_ratio_grid: Any = None,
|
|
75
|
+
porosity: Any = None,
|
|
76
|
+
svf: Any = None,
|
|
77
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
78
|
+
"""Ventilation Efficiency Index and normalised air age per cell.
|
|
79
|
+
|
|
80
|
+
``vei = clip(0.7 * wind_ratio + 0.3 * svf, 0.02, 1.5)`` (missing inputs
|
|
81
|
+
default to 0.7); ``air_age = clip(1 / max(0.08, vei), 0.5, 12)``. Solid
|
|
82
|
+
building cells are NaN. Returns ``(vei, air_age)``.
|
|
83
|
+
"""
|
|
84
|
+
h = _grid(heights, rows, cols)
|
|
85
|
+
assert h is not None
|
|
86
|
+
solid = is_solid(h, _grid(porosity, rows, cols))
|
|
87
|
+
w = _grid(wind_ratio_grid, rows, cols)
|
|
88
|
+
s = _grid(svf, rows, cols)
|
|
89
|
+
w = np.full((rows, cols), 0.7) if w is None else np.where(np.isfinite(w), w, 0.7)
|
|
90
|
+
s = np.full((rows, cols), 0.7) if s is None else np.where(np.isfinite(s), s, 0.7)
|
|
91
|
+
vei = np.clip(0.7 * w + 0.3 * s, 0.02, 1.5)
|
|
92
|
+
age = np.clip(1 / np.maximum(0.08, vei), 0.5, 12)
|
|
93
|
+
vei[solid] = np.nan
|
|
94
|
+
age[solid] = np.nan
|
|
95
|
+
return vei, age
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _emission_sources(
|
|
99
|
+
roads: Any,
|
|
100
|
+
weights: Any,
|
|
101
|
+
cols: int,
|
|
102
|
+
rows: int,
|
|
103
|
+
cell: float,
|
|
104
|
+
heights: np.ndarray,
|
|
105
|
+
porosity: Optional[np.ndarray],
|
|
106
|
+
min_x: float,
|
|
107
|
+
max_y: float,
|
|
108
|
+
) -> list[dict[str, float]]:
|
|
109
|
+
sources, given = sample_road_lines(roads, weights, max(2.0, cell))
|
|
110
|
+
if sources or given:
|
|
111
|
+
return sources
|
|
112
|
+
streets = street_centrelines(heights, porosity, cols, rows, cell, min_x, max_y, 25)
|
|
113
|
+
if streets:
|
|
114
|
+
return streets
|
|
115
|
+
mid_y = max_y - (rows // 2) * cell
|
|
116
|
+
return [{"x": min_x + c * cell, "y": mid_y, "length": cell} for c in range(cols)]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def pollutant_dispersion(
|
|
120
|
+
*,
|
|
121
|
+
heights: Any,
|
|
122
|
+
cols: int,
|
|
123
|
+
rows: int,
|
|
124
|
+
cell: float,
|
|
125
|
+
porosity: Any = None,
|
|
126
|
+
wind_ratio_grid: Any = None,
|
|
127
|
+
svf: Any = None,
|
|
128
|
+
min_x: Optional[float] = None,
|
|
129
|
+
max_y: Optional[float] = None,
|
|
130
|
+
wind_from_deg: float = 270.0,
|
|
131
|
+
wind_speed10: float = 3.0,
|
|
132
|
+
roads: Optional[Sequence[Sequence[Sequence[float]]]] = None,
|
|
133
|
+
road_weights: Optional[Sequence[float]] = None,
|
|
134
|
+
emission_rate_ug_ms: float = 60.0,
|
|
135
|
+
background_ug_m3: float = 18.0,
|
|
136
|
+
tree_deposition_factor: float = 0.25,
|
|
137
|
+
on_progress: Optional[Callable[[float], None]] = None,
|
|
138
|
+
) -> np.ndarray:
|
|
139
|
+
"""Pedestrian-level traffic NO2 / PM2.5 concentration in ug/m3.
|
|
140
|
+
|
|
141
|
+
Solid building cells are NaN; other cells are clamped to
|
|
142
|
+
``background .. 400``. ``wind_from_deg`` is the meteorological direction
|
|
143
|
+
the wind comes from (270 = westerly).
|
|
144
|
+
"""
|
|
145
|
+
if min_x is None:
|
|
146
|
+
min_x = 0.5 * cell
|
|
147
|
+
if max_y is None:
|
|
148
|
+
max_y = (rows - 0.5) * cell
|
|
149
|
+
h = _grid(heights, rows, cols)
|
|
150
|
+
assert h is not None
|
|
151
|
+
por = _grid(porosity, rows, cols)
|
|
152
|
+
solid = is_solid(h, por)
|
|
153
|
+
wr = _grid(wind_ratio_grid, rows, cols)
|
|
154
|
+
sv = _grid(svf, rows, cols)
|
|
155
|
+
sources = _emission_sources(roads, road_weights, cols, rows, cell, h, por, min_x, max_y)
|
|
156
|
+
|
|
157
|
+
bin_size, reach, near = 25.0, 250.0, 30.0
|
|
158
|
+
far = 60.0
|
|
159
|
+
bins: dict[tuple[int, int], list[dict[str, float]]] = {}
|
|
160
|
+
for src in sources:
|
|
161
|
+
key = (math.floor(src["x"] / bin_size), math.floor(src["y"] / bin_size))
|
|
162
|
+
bins.setdefault(key, []).append(src)
|
|
163
|
+
merged: dict[tuple[int, int], list[dict[str, float]]] = {}
|
|
164
|
+
for key, members in bins.items():
|
|
165
|
+
length = sum(m["length"] for m in members)
|
|
166
|
+
if length > 0:
|
|
167
|
+
x = sum(m["x"] * m["length"] for m in members) / length
|
|
168
|
+
y = sum(m["y"] * m["length"] for m in members) / length
|
|
169
|
+
merged[key] = [{"x": x, "y": y, "length": length}]
|
|
170
|
+
else:
|
|
171
|
+
merged[key] = []
|
|
172
|
+
span = int(math.ceil(reach / bin_size)) + 1
|
|
173
|
+
to_rad = math.radians((wind_from_deg + 180) % 360)
|
|
174
|
+
wx, wy = math.sin(to_rad), math.cos(to_rad)
|
|
175
|
+
half_diag = bin_size * math.sqrt(0.5)
|
|
176
|
+
|
|
177
|
+
tree_near = np.zeros((rows, cols), dtype=bool)
|
|
178
|
+
if por is not None:
|
|
179
|
+
pz = por > 0
|
|
180
|
+
for dr in (-1, 0, 1):
|
|
181
|
+
for dc in (-1, 0, 1):
|
|
182
|
+
r0, r1 = max(0, -dr), min(rows, rows - dr)
|
|
183
|
+
c0, c1 = max(0, -dc), min(cols, cols - dc)
|
|
184
|
+
tree_near[r0:r1, c0:c1] |= pz[r0 + dr : r1 + dr, c0 + dc : c1 + dc]
|
|
185
|
+
|
|
186
|
+
exp_z_num = 0.5 * 1.5 * 1.5
|
|
187
|
+
out = np.empty((rows, cols))
|
|
188
|
+
for r in range(rows):
|
|
189
|
+
ry = max_y - r * cell
|
|
190
|
+
for c in range(cols):
|
|
191
|
+
if solid[r, c]:
|
|
192
|
+
out[r, c] = math.nan
|
|
193
|
+
continue
|
|
194
|
+
rx = min_x + c * cell
|
|
195
|
+
lw = wr[r, c] if wr is not None and math.isfinite(wr[r, c]) else 0.7
|
|
196
|
+
ls = sv[r, c] if sv is not None and math.isfinite(sv[r, c]) else 0.7
|
|
197
|
+
u = max(0.6, wind_speed10 * 0.65 * lw)
|
|
198
|
+
plume = 0.0
|
|
199
|
+
min_road = math.inf
|
|
200
|
+
bx0, by0 = math.floor(rx / bin_size), math.floor(ry / bin_size)
|
|
201
|
+
for gy in range(by0 - span, by0 + span + 1):
|
|
202
|
+
for gx in range(bx0 - span, bx0 + span + 1):
|
|
203
|
+
if (gx, gy) not in bins:
|
|
204
|
+
continue
|
|
205
|
+
members = bins[(gx, gy)]
|
|
206
|
+
ex = max(gx * bin_size - rx, 0.0, rx - (gx + 1) * bin_size)
|
|
207
|
+
ey = max(gy * bin_size - ry, 0.0, ry - (gy + 1) * bin_size)
|
|
208
|
+
edge = math.hypot(ex, ey)
|
|
209
|
+
if edge > reach:
|
|
210
|
+
continue
|
|
211
|
+
if edge > far:
|
|
212
|
+
members = merged[(gx, gy)]
|
|
213
|
+
cxb = (gx + 0.5) * bin_size - rx
|
|
214
|
+
cyb = (gy + 0.5) * bin_size - ry
|
|
215
|
+
upwind = -(cxb * wx + cyb * wy) < -half_diag
|
|
216
|
+
if upwind and edge > near:
|
|
217
|
+
continue
|
|
218
|
+
for src in members:
|
|
219
|
+
dx = rx - src["x"]
|
|
220
|
+
dy = ry - src["y"]
|
|
221
|
+
dist = math.hypot(dx, dy)
|
|
222
|
+
if dist < min_road:
|
|
223
|
+
min_road = dist
|
|
224
|
+
if dist > reach or upwind:
|
|
225
|
+
continue
|
|
226
|
+
d_along = dx * wx + dy * wy
|
|
227
|
+
if d_along <= 0:
|
|
228
|
+
continue
|
|
229
|
+
d_cross = abs(dx * wy - dy * wx)
|
|
230
|
+
sig_y = 2.5 + 0.18 * d_along
|
|
231
|
+
sig_z = 1.8 + 0.12 * d_along
|
|
232
|
+
if d_cross > 4 * sig_y:
|
|
233
|
+
continue
|
|
234
|
+
plume += (
|
|
235
|
+
src["length"]
|
|
236
|
+
* (emission_rate_ug_ms / u)
|
|
237
|
+
/ (math.pi * sig_y * sig_z)
|
|
238
|
+
* math.exp(-0.5 * d_cross * d_cross / (sig_y * sig_y))
|
|
239
|
+
* math.exp(-exp_z_num / (sig_z * sig_z))
|
|
240
|
+
)
|
|
241
|
+
inc = plume
|
|
242
|
+
if min_road < 30 and (ls < 0.65 or lw < 0.45):
|
|
243
|
+
inc *= 1 + 1.4 * (1 - ls)
|
|
244
|
+
if tree_near[r, c]:
|
|
245
|
+
inc *= 1 - tree_deposition_factor
|
|
246
|
+
out[r, c] = max(background_ug_m3, min(400.0, background_ug_m3 + inc))
|
|
247
|
+
if on_progress is not None:
|
|
248
|
+
on_progress((r + 1) / rows)
|
|
249
|
+
return out
|