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