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,326 @@
|
|
|
1
|
+
"""Urban-design indicators: pedestrian Green View Index and solar envelope.
|
|
2
|
+
|
|
3
|
+
* :func:`green_view_index` - panoramic pedestrian-level green view percentage
|
|
4
|
+
on a 2.5-D raster (tree crowns plus vegetated ground within 30 m), occluded
|
|
5
|
+
by buildings.
|
|
6
|
+
* :func:`solar_envelope` - Knowles (1981) / BRE right-to-light maximum
|
|
7
|
+
buildable heights and floor counts that keep sun on existing facades.
|
|
8
|
+
|
|
9
|
+
Grids may be flat (row-major from the north-west cell) or ``(rows, cols)``;
|
|
10
|
+
returned grids are ``(rows, cols)``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from typing import Any, Callable, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
__all__ = ["GVI_CLASSES", "gvi_class", "green_view_index", "solar_envelope"]
|
|
21
|
+
|
|
22
|
+
GVI_CLASSES: list[dict[str, Any]] = [
|
|
23
|
+
{"min": 0, "max": 10, "key": "bare", "label": "Bare / low green", "color": "#d73027"},
|
|
24
|
+
{"min": 10, "max": 20, "key": "moderate", "label": "Moderate green", "color": "#fee08b"},
|
|
25
|
+
{"min": 20, "max": 35, "key": "street", "label": "Green street", "color": "#a6d96a"},
|
|
26
|
+
{"min": 35, "max": 100, "key": "high", "label": "High biophilic canopy", "color": "#1a9850"},
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _jsround(x: float) -> int:
|
|
31
|
+
"""JavaScript ``Math.round`` (halves round up, not to even)."""
|
|
32
|
+
return int(math.floor(x + 0.5))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def gvi_class(pct: float) -> Optional[dict[str, Any]]:
|
|
36
|
+
"""Qualitative tier of a Green View Index percentage (``None`` if not finite)."""
|
|
37
|
+
if pct is None or not math.isfinite(pct):
|
|
38
|
+
return None
|
|
39
|
+
if pct < 10:
|
|
40
|
+
return GVI_CLASSES[0]
|
|
41
|
+
if pct < 20:
|
|
42
|
+
return GVI_CLASSES[1]
|
|
43
|
+
if pct < 35:
|
|
44
|
+
return GVI_CLASSES[2]
|
|
45
|
+
return GVI_CLASSES[3]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _add_interval(intervals: list[list[float]], start: float, end: float) -> list[list[float]]:
|
|
49
|
+
"""Insert ``[start, end]`` into a sorted list of disjoint intervals."""
|
|
50
|
+
if end <= start:
|
|
51
|
+
return intervals
|
|
52
|
+
s, e = start, end
|
|
53
|
+
out: list[list[float]] = []
|
|
54
|
+
inserted = False
|
|
55
|
+
for a, b in intervals:
|
|
56
|
+
if b < s:
|
|
57
|
+
out.append([a, b])
|
|
58
|
+
elif a > e:
|
|
59
|
+
if not inserted:
|
|
60
|
+
out.append([s, e])
|
|
61
|
+
inserted = True
|
|
62
|
+
out.append([a, b])
|
|
63
|
+
else:
|
|
64
|
+
s, e = min(s, a), max(e, b)
|
|
65
|
+
if not inserted:
|
|
66
|
+
out.append([s, e])
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def green_view_index(
|
|
71
|
+
*,
|
|
72
|
+
building_heights: Any,
|
|
73
|
+
cols: int,
|
|
74
|
+
rows: int,
|
|
75
|
+
cell: float,
|
|
76
|
+
tree_obstacles: Optional[Sequence[Sequence[float]]] = None,
|
|
77
|
+
green_mask: Any = None,
|
|
78
|
+
min_x: float = 0.0,
|
|
79
|
+
max_y: float = 0.0,
|
|
80
|
+
eye_height: float = 1.6,
|
|
81
|
+
max_radius: float = 80.0,
|
|
82
|
+
azimuth_steps: int = 24,
|
|
83
|
+
on_progress: Optional[Callable[[float], None]] = None,
|
|
84
|
+
) -> np.ndarray:
|
|
85
|
+
"""Pedestrian Green View Index in percent (0..100); NaN inside buildings.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
building_heights: obstacle height above ground per cell (0 = open).
|
|
89
|
+
tree_obstacles: ``[(x, y, radius, height), ...]`` tree crowns.
|
|
90
|
+
green_mask: 1 where the ground is vegetated.
|
|
91
|
+
min_x, max_y: west edge and north edge of the grid (tree rasterisation
|
|
92
|
+
uses cell centres at ``min_x + (c + 0.5) * cell``).
|
|
93
|
+
eye_height: pedestrian eye level in metres.
|
|
94
|
+
max_radius: sight distance in metres.
|
|
95
|
+
azimuth_steps: horizontal rays across 360 degrees.
|
|
96
|
+
|
|
97
|
+
Only the +/-45 degree vertical field of view counts.
|
|
98
|
+
"""
|
|
99
|
+
n = cols * rows
|
|
100
|
+
bh = (
|
|
101
|
+
np.zeros((rows, cols))
|
|
102
|
+
if building_heights is None
|
|
103
|
+
else np.nan_to_num(np.asarray(building_heights, dtype=float).reshape(rows, cols))
|
|
104
|
+
)
|
|
105
|
+
gm = None if green_mask is None else np.asarray(green_mask, dtype=float).reshape(rows, cols)
|
|
106
|
+
tree_top = np.zeros((rows, cols))
|
|
107
|
+
tree_base = np.zeros((rows, cols))
|
|
108
|
+
for tx, ty, trad, th in tree_obstacles or []:
|
|
109
|
+
if not th > 0:
|
|
110
|
+
continue
|
|
111
|
+
rad = max(trad, cell * 0.5)
|
|
112
|
+
c0 = max(0, math.floor((tx - rad - min_x) / cell))
|
|
113
|
+
c1 = min(cols - 1, math.ceil((tx + rad - min_x) / cell))
|
|
114
|
+
r0 = max(0, math.floor((max_y - ty - rad) / cell))
|
|
115
|
+
r1 = min(rows - 1, math.ceil((max_y - ty + rad) / cell))
|
|
116
|
+
tb = max(1.2, 0.35 * th)
|
|
117
|
+
for r in range(r0, r1 + 1):
|
|
118
|
+
for c in range(c0, c1 + 1):
|
|
119
|
+
cx = min_x + (c + 0.5) * cell
|
|
120
|
+
cy = max_y - (r + 0.5) * cell
|
|
121
|
+
if math.hypot(cx - tx, cy - ty) <= rad and th > tree_top[r, c]:
|
|
122
|
+
tree_top[r, c] = th
|
|
123
|
+
tree_base[r, c] = tb
|
|
124
|
+
|
|
125
|
+
bl, tt, tbl = bh.tolist(), tree_top.tolist(), tree_base.tolist()
|
|
126
|
+
gl = gm.tolist() if gm is not None else None
|
|
127
|
+
step = cell
|
|
128
|
+
max_steps = int(math.ceil(max_radius / step))
|
|
129
|
+
total_vfov = math.pi / 2
|
|
130
|
+
half_vfov = total_vfov / 2
|
|
131
|
+
dirs = [
|
|
132
|
+
(math.cos(a * 2 * math.pi / azimuth_steps), -math.sin(a * 2 * math.pi / azimuth_steps))
|
|
133
|
+
for a in range(azimuth_steps)
|
|
134
|
+
]
|
|
135
|
+
out = np.empty(n).reshape(rows, cols)
|
|
136
|
+
for r in range(rows):
|
|
137
|
+
for c in range(cols):
|
|
138
|
+
if bl[r][c] > 0:
|
|
139
|
+
out[r, c] = math.nan
|
|
140
|
+
continue
|
|
141
|
+
total = 0.0
|
|
142
|
+
for dc, dr in dirs:
|
|
143
|
+
max_tan = 0.0
|
|
144
|
+
intervals: list[list[float]] = []
|
|
145
|
+
ground = 0.0
|
|
146
|
+
for s in range(1, max_steps + 1):
|
|
147
|
+
dist = s * step
|
|
148
|
+
cc = _jsround(c + (dist / cell) * dc)
|
|
149
|
+
rr = _jsround(r + (dist / cell) * dr)
|
|
150
|
+
if cc < 0 or cc >= cols or rr < 0 or rr >= rows:
|
|
151
|
+
break
|
|
152
|
+
b = bl[rr][cc]
|
|
153
|
+
if b > 0:
|
|
154
|
+
bt = (b - eye_height) / dist
|
|
155
|
+
if bt > max_tan:
|
|
156
|
+
max_tan = bt
|
|
157
|
+
if max_tan > 2.5:
|
|
158
|
+
break
|
|
159
|
+
top = tt[rr][cc]
|
|
160
|
+
if top > eye_height:
|
|
161
|
+
top_h = top - eye_height
|
|
162
|
+
bot_h = max(0.0, tbl[rr][cc] - eye_height, dist * max_tan)
|
|
163
|
+
if top_h > bot_h:
|
|
164
|
+
phi_top = min(half_vfov, math.atan2(top_h, dist))
|
|
165
|
+
phi_bot = math.atan2(bot_h, dist)
|
|
166
|
+
if phi_top > phi_bot:
|
|
167
|
+
intervals = _add_interval(intervals, phi_bot, phi_top)
|
|
168
|
+
if gl is not None and gl[rr][cc] > 0 and dist <= 30 and max_tan <= 0:
|
|
169
|
+
d_near = max(cell * 0.5, dist - cell * 0.5)
|
|
170
|
+
d_far = dist + cell * 0.5
|
|
171
|
+
g = min(half_vfov, math.atan2(eye_height, d_near)) - min(
|
|
172
|
+
half_vfov, math.atan2(eye_height, d_far)
|
|
173
|
+
)
|
|
174
|
+
if g > 0:
|
|
175
|
+
ground += g
|
|
176
|
+
green = sum(b - a for a, b in intervals) + ground
|
|
177
|
+
total += min(100.0, max(0.0, green / total_vfov * 100))
|
|
178
|
+
out[r, c] = total / azimuth_steps
|
|
179
|
+
if on_progress is not None:
|
|
180
|
+
on_progress((r + 1) / rows)
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def solar_envelope(
|
|
185
|
+
*,
|
|
186
|
+
building_heights: Any,
|
|
187
|
+
cols: int,
|
|
188
|
+
rows: int,
|
|
189
|
+
cell: float,
|
|
190
|
+
ids: Any = None,
|
|
191
|
+
sun_vectors: Optional[Sequence[dict[str, Any]]] = None,
|
|
192
|
+
min_sun_hours: float = 2.0,
|
|
193
|
+
step_hours: float = 0.5,
|
|
194
|
+
max_buildable_height: float = 80.0,
|
|
195
|
+
floor_height: float = 3.2,
|
|
196
|
+
target_mask: Any = None,
|
|
197
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
198
|
+
"""Maximum buildable height and floor count per cell.
|
|
199
|
+
|
|
200
|
+
Receptors are the perimeter cells of existing buildings and the open cells
|
|
201
|
+
next to them. From each receptor, rays towards the active sun directions
|
|
202
|
+
cap the height of every cell they cross at ``distance * tan(altitude)``,
|
|
203
|
+
unless an existing building already shades that ray.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
sun_vectors: ``[{"dir": (east, up, south), "weight": hours}, ...]`` in
|
|
207
|
+
scene axes (``-z`` north). The highest suns adding up to
|
|
208
|
+
``min_sun_hours`` are used; default is a 45 degree noon sun.
|
|
209
|
+
target_mask: development site cells (neither buildings nor receptors).
|
|
210
|
+
|
|
211
|
+
Returns:
|
|
212
|
+
``(max_height, max_floors)`` arrays of shape ``(rows, cols)``.
|
|
213
|
+
"""
|
|
214
|
+
n = cols * rows
|
|
215
|
+
if cols <= 0 or rows <= 0:
|
|
216
|
+
return np.zeros((0, 0)), np.zeros((0, 0))
|
|
217
|
+
if cell <= 0:
|
|
218
|
+
raise ValueError("cell size must be positive")
|
|
219
|
+
max_h = np.full(n, float(max_buildable_height))
|
|
220
|
+
bh = (
|
|
221
|
+
np.zeros(n)
|
|
222
|
+
if building_heights is None
|
|
223
|
+
else np.asarray(building_heights, dtype=float).ravel()
|
|
224
|
+
)
|
|
225
|
+
idf = None if ids is None else np.asarray(ids).ravel()
|
|
226
|
+
tm = (
|
|
227
|
+
np.zeros(n, dtype=bool)
|
|
228
|
+
if target_mask is None
|
|
229
|
+
else np.asarray(target_mask).ravel().astype(bool)
|
|
230
|
+
)
|
|
231
|
+
is_b = (bh > 0) if idf is None else ((bh > 0) | (idf > 0))
|
|
232
|
+
is_b &= ~tm
|
|
233
|
+
if not is_b.any():
|
|
234
|
+
return max_h.reshape(rows, cols), np.maximum(0, np.floor(max_h / floor_height)).reshape(
|
|
235
|
+
rows, cols
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
receptors: list[tuple[int, int]] = []
|
|
239
|
+
is_rec = np.zeros(n, dtype=bool)
|
|
240
|
+
for r in range(rows):
|
|
241
|
+
for c in range(cols):
|
|
242
|
+
i = r * cols + c
|
|
243
|
+
if tm[i] or not is_b[i]:
|
|
244
|
+
continue
|
|
245
|
+
perim = c == 0 or c == cols - 1 or r == 0 or r == rows - 1
|
|
246
|
+
if not perim and (
|
|
247
|
+
not is_b[i - 1] or not is_b[i + 1] or not is_b[i - cols] or not is_b[i + cols]
|
|
248
|
+
):
|
|
249
|
+
perim = True
|
|
250
|
+
if perim and not is_rec[i]:
|
|
251
|
+
is_rec[i] = True
|
|
252
|
+
receptors.append((c, r))
|
|
253
|
+
for k in range(len(receptors)):
|
|
254
|
+
rc, rr = receptors[k]
|
|
255
|
+
for dr in (-1, 0, 1):
|
|
256
|
+
for dc in (-1, 0, 1):
|
|
257
|
+
nc, nr = rc + dc, rr + dr
|
|
258
|
+
if 0 <= nc < cols and 0 <= nr < rows:
|
|
259
|
+
ni = nr * cols + nc
|
|
260
|
+
if not is_b[ni] and not tm[ni] and not is_rec[ni]:
|
|
261
|
+
is_rec[ni] = True
|
|
262
|
+
receptors.append((nc, nr))
|
|
263
|
+
|
|
264
|
+
active = [
|
|
265
|
+
sv for sv in (sun_vectors or []) if sv and sv.get("dir") is not None and sv["dir"][1] > 0.05
|
|
266
|
+
]
|
|
267
|
+
if not active:
|
|
268
|
+
active = [{"dir": (0.0, math.sqrt(0.5), math.sqrt(0.5)), "weight": min_sun_hours}]
|
|
269
|
+
|
|
270
|
+
def weight(sv: dict[str, Any]) -> float:
|
|
271
|
+
return sv.get("weight") or step_hours
|
|
272
|
+
|
|
273
|
+
if sum(weight(sv) for sv in active) > min_sun_hours:
|
|
274
|
+
active = sorted(active, key=lambda sv: -sv["dir"][1])
|
|
275
|
+
subset = []
|
|
276
|
+
cum = 0.0
|
|
277
|
+
for sv in active:
|
|
278
|
+
subset.append(sv)
|
|
279
|
+
cum += weight(sv)
|
|
280
|
+
if cum >= min_sun_hours:
|
|
281
|
+
break
|
|
282
|
+
active = subset
|
|
283
|
+
|
|
284
|
+
for sv in active:
|
|
285
|
+
dx, dy, dz = (float(v) for v in sv["dir"])
|
|
286
|
+
ux, uy = dx, -dz
|
|
287
|
+
hlen = math.hypot(ux, uy)
|
|
288
|
+
if hlen < 1e-5:
|
|
289
|
+
continue
|
|
290
|
+
hx, hy = ux / hlen, uy / hlen
|
|
291
|
+
tan_alt = dy / hlen
|
|
292
|
+
max_d = max_buildable_height / tan_alt
|
|
293
|
+
step = cell * 0.5
|
|
294
|
+
for rc, rr in receptors:
|
|
295
|
+
rec_idx = rr * cols + rc
|
|
296
|
+
rec_id = idf[rec_idx] if idf is not None else 0
|
|
297
|
+
path: list[tuple[int, float]] = []
|
|
298
|
+
own = bool(is_b[rec_idx])
|
|
299
|
+
shaded = False
|
|
300
|
+
d = step
|
|
301
|
+
while d <= max_d:
|
|
302
|
+
ic = _jsround(rc + hx * d / cell)
|
|
303
|
+
ir = _jsround(rr - hy * d / cell)
|
|
304
|
+
d += step
|
|
305
|
+
if ic < 0 or ic >= cols or ir < 0 or ir >= rows:
|
|
306
|
+
break
|
|
307
|
+
t = ir * cols + ic
|
|
308
|
+
if t == rec_idx:
|
|
309
|
+
continue
|
|
310
|
+
if own:
|
|
311
|
+
if is_b[t] and (idf is None or not rec_id or idf[t] == rec_id):
|
|
312
|
+
continue
|
|
313
|
+
own = False
|
|
314
|
+
allowed = math.hypot((ic - rc) * cell, (ir - rr) * cell) * tan_alt
|
|
315
|
+
if is_b[t] and bh[t] > allowed:
|
|
316
|
+
shaded = True
|
|
317
|
+
break
|
|
318
|
+
path.append((t, allowed))
|
|
319
|
+
if shaded:
|
|
320
|
+
continue
|
|
321
|
+
for t, allowed in path:
|
|
322
|
+
if allowed < max_h[t]:
|
|
323
|
+
max_h[t] = allowed
|
|
324
|
+
max_h = np.clip(max_h, 0, max_buildable_height)
|
|
325
|
+
floors = np.maximum(0, np.floor(max_h / floor_height))
|
|
326
|
+
return max_h.reshape(rows, cols), floors.reshape(rows, cols)
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Pedestrian Cool Walkability Index (PCWI) and A* cool-route finder.
|
|
2
|
+
|
|
3
|
+
Combines thermal comfort (UTCI), solar shade, Green View Index (3-30-300 rule),
|
|
4
|
+
road traffic noise (WHO 53/65 dB thresholds) and terrain slope into a 0-100
|
|
5
|
+
pedestrian quality index, and compares the shortest obstacle-free route
|
|
6
|
+
against the coolest / highest-comfort route across an open-ground grid.
|
|
7
|
+
|
|
8
|
+
Grids may be passed flat (row-major from the north-west cell) or as
|
|
9
|
+
``(rows, cols)`` arrays; returned grids are ``(rows, cols)``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import heapq
|
|
15
|
+
import math
|
|
16
|
+
from typing import Any, Callable, Optional, Sequence
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"WALKABILITY_CLASSES",
|
|
22
|
+
"walkability_class",
|
|
23
|
+
"thermal_subscore",
|
|
24
|
+
"shade_subscore",
|
|
25
|
+
"green_subscore",
|
|
26
|
+
"quiet_subscore",
|
|
27
|
+
"slope_subscore",
|
|
28
|
+
"pedestrian_walkability_index",
|
|
29
|
+
"find_cool_route",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
WALKABILITY_CLASSES: list[dict[str, Any]] = [
|
|
33
|
+
{"min": 75, "max": 101, "label": "Excellent (cool, shaded & quiet)", "color": "#1d8f5f"},
|
|
34
|
+
{"min": 55, "max": 75, "label": "Good pedestrian comfort", "color": "#6dbf67"},
|
|
35
|
+
{"min": 35, "max": 55, "label": "Moderate (exposed or noisy)", "color": "#f0b429"},
|
|
36
|
+
{"min": 0, "max": 35, "label": "Poor (heat stress / high noise / steep)", "color": "#d64545"},
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _finite(v: Any) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
return math.isfinite(float(v))
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def walkability_class(score: float) -> Optional[dict[str, Any]]:
|
|
48
|
+
"""Class dict for a 0-100 score, or ``None`` for a non-finite value."""
|
|
49
|
+
if not _finite(score):
|
|
50
|
+
return None
|
|
51
|
+
s = max(0.0, min(100.0, float(score)))
|
|
52
|
+
for c in WALKABILITY_CLASSES:
|
|
53
|
+
if c["min"] <= s < c["max"]:
|
|
54
|
+
return c
|
|
55
|
+
return WALKABILITY_CLASSES[-1]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def thermal_subscore(utci_c: float) -> float:
|
|
59
|
+
"""100 inside the UTCI no-stress band (9..26 C), falling to 0 at 42 C and -10 C."""
|
|
60
|
+
if not _finite(utci_c):
|
|
61
|
+
return math.nan
|
|
62
|
+
u = float(utci_c)
|
|
63
|
+
if 9 <= u <= 26:
|
|
64
|
+
return 100.0
|
|
65
|
+
if u > 26:
|
|
66
|
+
return max(0.0, min(100.0, 100 * (1 - (u - 26) / 16)))
|
|
67
|
+
return max(0.0, min(100.0, 100 * (1 - (9 - u) / 19)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def shade_subscore(shade_val: float) -> float:
|
|
71
|
+
"""Shade fraction (0..1) or percentage (0..100) as a 0..100 score."""
|
|
72
|
+
if not _finite(shade_val):
|
|
73
|
+
return math.nan
|
|
74
|
+
v = float(shade_val)
|
|
75
|
+
frac = v / 100 if v > 1.01 else v
|
|
76
|
+
return max(0.0, min(100.0, frac * 100))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def green_subscore(gvi_pct: float) -> float:
|
|
80
|
+
"""Green View Index percentage; 30 % (the 3-30-300 rule) scores 100."""
|
|
81
|
+
if not _finite(gvi_pct):
|
|
82
|
+
return math.nan
|
|
83
|
+
return max(0.0, min(100.0, (float(gvi_pct) / 30) * 100))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def quiet_subscore(noise_dba: float) -> float:
|
|
87
|
+
"""100 at or below 50 dB(A), 0 at or above 75 dB(A), linear between."""
|
|
88
|
+
if not _finite(noise_dba):
|
|
89
|
+
return math.nan
|
|
90
|
+
n = float(noise_dba)
|
|
91
|
+
if n <= 50:
|
|
92
|
+
return 100.0
|
|
93
|
+
if n >= 75:
|
|
94
|
+
return 0.0
|
|
95
|
+
return 100 * (1 - (n - 50) / 25)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def slope_subscore(slope_deg: float) -> float:
|
|
99
|
+
"""100 up to 2 degrees, 0 from 12 degrees, linear between."""
|
|
100
|
+
if not _finite(slope_deg):
|
|
101
|
+
return math.nan
|
|
102
|
+
s = float(slope_deg)
|
|
103
|
+
if s <= 2:
|
|
104
|
+
return 100.0
|
|
105
|
+
if s >= 12:
|
|
106
|
+
return 0.0
|
|
107
|
+
return 100 * (1 - (s - 2) / 10)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _flat(a: Any) -> Optional[np.ndarray]:
|
|
111
|
+
if a is None:
|
|
112
|
+
return None
|
|
113
|
+
return np.asarray(a, dtype=float).ravel()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _vec(fn: Callable[[float], float], arr: np.ndarray) -> np.ndarray:
|
|
117
|
+
return np.array([fn(v) for v in arr], dtype=float)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def pedestrian_walkability_index(
|
|
121
|
+
*,
|
|
122
|
+
cols: int,
|
|
123
|
+
rows: int,
|
|
124
|
+
ids: Any = None,
|
|
125
|
+
utci_grid: Any = None,
|
|
126
|
+
shade_grid: Any = None,
|
|
127
|
+
gvi_grid: Any = None,
|
|
128
|
+
noise_grid: Any = None,
|
|
129
|
+
slope_deg_grid: Any = None,
|
|
130
|
+
weights: Optional[dict[str, float]] = None,
|
|
131
|
+
) -> np.ndarray:
|
|
132
|
+
"""0-100 Pedestrian Cool Walkability Index on open ground.
|
|
133
|
+
|
|
134
|
+
Building cells (``ids != 0``) and cells without any input are NaN. Missing
|
|
135
|
+
subscores are left out of the weighted mean. Default weights: thermal 0.35,
|
|
136
|
+
shade 0.25, green 0.15, quiet 0.15, slope 0.10.
|
|
137
|
+
"""
|
|
138
|
+
w = weights or {}
|
|
139
|
+
n = cols * rows
|
|
140
|
+
parts = [
|
|
141
|
+
(utci_grid, thermal_subscore, max(0.0, w.get("thermal", 0.35))),
|
|
142
|
+
(shade_grid, shade_subscore, max(0.0, w.get("shade", 0.25))),
|
|
143
|
+
(gvi_grid, green_subscore, max(0.0, w.get("green", 0.15))),
|
|
144
|
+
(noise_grid, quiet_subscore, max(0.0, w.get("quiet", 0.15))),
|
|
145
|
+
(slope_deg_grid, slope_subscore, max(0.0, w.get("slope", 0.10))),
|
|
146
|
+
]
|
|
147
|
+
total = np.zeros(n)
|
|
148
|
+
wsum = np.zeros(n)
|
|
149
|
+
for grid, fn, wt in parts:
|
|
150
|
+
g = _flat(grid)
|
|
151
|
+
if g is None:
|
|
152
|
+
continue
|
|
153
|
+
s = _vec(fn, g)
|
|
154
|
+
ok = np.isfinite(s)
|
|
155
|
+
total[ok] += wt * s[ok]
|
|
156
|
+
wsum[ok] += wt
|
|
157
|
+
out = np.full(n, np.nan)
|
|
158
|
+
good = wsum > 0
|
|
159
|
+
out[good] = np.clip(total[good] / wsum[good], 0, 100)
|
|
160
|
+
idf = _flat(ids)
|
|
161
|
+
if idf is not None:
|
|
162
|
+
out[idf != 0] = np.nan
|
|
163
|
+
return out.reshape(rows, cols)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _nearest_walkable(
|
|
167
|
+
ok: np.ndarray,
|
|
168
|
+
cols: int,
|
|
169
|
+
rows: int,
|
|
170
|
+
cell: float,
|
|
171
|
+
min_x: float,
|
|
172
|
+
max_y: float,
|
|
173
|
+
xy: Sequence[float],
|
|
174
|
+
) -> int:
|
|
175
|
+
c0 = max(0, min(cols - 1, int(math.floor((xy[0] - min_x) / cell + 0.5))))
|
|
176
|
+
r0 = max(0, min(rows - 1, int(math.floor((max_y - xy[1]) / cell + 0.5))))
|
|
177
|
+
if ok[r0 * cols + c0]:
|
|
178
|
+
return r0 * cols + c0
|
|
179
|
+
for rad in range(1, max(cols, rows) + 1):
|
|
180
|
+
best, best_d = -1, math.inf
|
|
181
|
+
for r in range(max(0, r0 - rad), min(rows - 1, r0 + rad) + 1):
|
|
182
|
+
for c in range(max(0, c0 - rad), min(cols - 1, c0 + rad) + 1):
|
|
183
|
+
if abs(r - r0) != rad and abs(c - c0) != rad:
|
|
184
|
+
continue
|
|
185
|
+
idx = r * cols + c
|
|
186
|
+
if not ok[idx]:
|
|
187
|
+
continue
|
|
188
|
+
d = (r - r0) ** 2 + (c - c0) ** 2
|
|
189
|
+
if d < best_d:
|
|
190
|
+
best_d, best = d, idx
|
|
191
|
+
if best >= 0:
|
|
192
|
+
return best
|
|
193
|
+
return -1
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_DR = (-1, -1, -1, 0, 0, 1, 1, 1)
|
|
197
|
+
_DC = (-1, 0, 1, -1, 1, -1, 0, 1)
|
|
198
|
+
_DL = (math.sqrt(2), 1.0, math.sqrt(2), 1.0, 1.0, math.sqrt(2), 1.0, math.sqrt(2))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _a_star(
|
|
202
|
+
cols: int, rows: int, start: int, end: int, ok: np.ndarray, penalty: np.ndarray
|
|
203
|
+
) -> Optional[list[int]]:
|
|
204
|
+
"""8-connected A* without corner cutting; returns cell indices start..end."""
|
|
205
|
+
n = cols * rows
|
|
206
|
+
g = np.full(n, math.inf)
|
|
207
|
+
prev = np.full(n, -1, dtype=np.int64)
|
|
208
|
+
closed = np.zeros(n, dtype=bool)
|
|
209
|
+
end_r, end_c = divmod(end, cols)
|
|
210
|
+
g[start] = 0.0
|
|
211
|
+
sr, sc = divmod(start, cols)
|
|
212
|
+
heap: list[tuple[float, int, int]] = [(math.hypot(sr - end_r, sc - end_c), 0, start)]
|
|
213
|
+
counter = 1
|
|
214
|
+
while heap:
|
|
215
|
+
_, _, cur = heapq.heappop(heap)
|
|
216
|
+
if cur == end:
|
|
217
|
+
break
|
|
218
|
+
if closed[cur]:
|
|
219
|
+
continue
|
|
220
|
+
closed[cur] = True
|
|
221
|
+
r, c = divmod(cur, cols)
|
|
222
|
+
base = g[cur]
|
|
223
|
+
for k in range(8):
|
|
224
|
+
nr, nc = r + _DR[k], c + _DC[k]
|
|
225
|
+
if nr < 0 or nr >= rows or nc < 0 or nc >= cols:
|
|
226
|
+
continue
|
|
227
|
+
nxt = nr * cols + nc
|
|
228
|
+
if closed[nxt] or not ok[nxt]:
|
|
229
|
+
continue
|
|
230
|
+
if _DR[k] != 0 and _DC[k] != 0 and (not ok[r * cols + nc] or not ok[nr * cols + c]):
|
|
231
|
+
continue
|
|
232
|
+
cand = base + _DL[k] * penalty[nxt]
|
|
233
|
+
if cand < g[nxt]:
|
|
234
|
+
g[nxt] = cand
|
|
235
|
+
prev[nxt] = cur
|
|
236
|
+
heapq.heappush(heap, (cand + math.hypot(nr - end_r, nc - end_c), counter, nxt))
|
|
237
|
+
counter += 1
|
|
238
|
+
if not math.isfinite(g[end]):
|
|
239
|
+
return None
|
|
240
|
+
cells = []
|
|
241
|
+
cur = end
|
|
242
|
+
while cur >= 0:
|
|
243
|
+
cells.append(int(cur))
|
|
244
|
+
if cur == start:
|
|
245
|
+
break
|
|
246
|
+
cur = int(prev[cur])
|
|
247
|
+
cells.reverse()
|
|
248
|
+
return cells
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _summarize(
|
|
252
|
+
cells: list[int],
|
|
253
|
+
route_type: str,
|
|
254
|
+
label: str,
|
|
255
|
+
color: str,
|
|
256
|
+
cols: int,
|
|
257
|
+
cell: float,
|
|
258
|
+
min_x: float,
|
|
259
|
+
max_y: float,
|
|
260
|
+
grids: dict[str, Optional[np.ndarray]],
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
coords = [(min_x + (i % cols) * cell, max_y - (i // cols) * cell) for i in cells]
|
|
263
|
+
length = sum(
|
|
264
|
+
math.hypot(coords[i][0] - coords[i - 1][0], coords[i][1] - coords[i - 1][1])
|
|
265
|
+
for i in range(1, len(coords))
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def mean_of(arr: Optional[np.ndarray]) -> float:
|
|
269
|
+
if arr is None:
|
|
270
|
+
return math.nan
|
|
271
|
+
v = arr[cells]
|
|
272
|
+
v = v[np.isfinite(v)]
|
|
273
|
+
return float(v.mean()) if v.size else math.nan
|
|
274
|
+
|
|
275
|
+
shade_pct = math.nan
|
|
276
|
+
sh = grids["shade"]
|
|
277
|
+
if sh is not None:
|
|
278
|
+
v = sh[cells]
|
|
279
|
+
v = v[np.isfinite(v)]
|
|
280
|
+
if v.size:
|
|
281
|
+
shade_pct = float(np.where(v > 1.01, v, v * 100).mean())
|
|
282
|
+
return {
|
|
283
|
+
"route_type": route_type,
|
|
284
|
+
"label": label,
|
|
285
|
+
"color": color,
|
|
286
|
+
"cells": cells,
|
|
287
|
+
"coords": coords,
|
|
288
|
+
"length_m": length,
|
|
289
|
+
"mean_utci_c": mean_of(grids["utci"]),
|
|
290
|
+
"shade_pct": shade_pct,
|
|
291
|
+
"mean_gvi_pct": mean_of(grids["gvi"]),
|
|
292
|
+
"mean_noise_dba": mean_of(grids["noise"]),
|
|
293
|
+
"walk_score": mean_of(grids["walk"]),
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def find_cool_route(
|
|
298
|
+
*,
|
|
299
|
+
walk_score: Any,
|
|
300
|
+
cols: int,
|
|
301
|
+
rows: int,
|
|
302
|
+
cell: float,
|
|
303
|
+
min_x: float,
|
|
304
|
+
max_y: float,
|
|
305
|
+
start_xy: Sequence[float],
|
|
306
|
+
end_xy: Sequence[float],
|
|
307
|
+
ids: Any = None,
|
|
308
|
+
utci_grid: Any = None,
|
|
309
|
+
shade_grid: Any = None,
|
|
310
|
+
gvi_grid: Any = None,
|
|
311
|
+
noise_grid: Any = None,
|
|
312
|
+
cool_bias: float = 2.8,
|
|
313
|
+
) -> Optional[dict[str, Any]]:
|
|
314
|
+
"""Compare the shortest obstacle-free route with the coolest route.
|
|
315
|
+
|
|
316
|
+
Step costs of the cool route are ``1 + bias * discomfort**1.5`` with
|
|
317
|
+
``discomfort = (100 - walk_score) / 100``. Returns ``None`` when no route
|
|
318
|
+
exists, else a dict with ``shortest``, ``coolest``, ``detour_pct``,
|
|
319
|
+
``utci_delta_c``, ``shade_gain_pct`` and ``walk_score_gain``.
|
|
320
|
+
"""
|
|
321
|
+
n = cols * rows
|
|
322
|
+
ws = _flat(walk_score)
|
|
323
|
+
idf = _flat(ids)
|
|
324
|
+
ok = np.ones(n, dtype=bool)
|
|
325
|
+
if idf is not None:
|
|
326
|
+
ok &= idf == 0
|
|
327
|
+
if ws is not None:
|
|
328
|
+
ok &= np.isfinite(ws)
|
|
329
|
+
start = _nearest_walkable(ok, cols, rows, cell, min_x, max_y, start_xy)
|
|
330
|
+
end = _nearest_walkable(ok, cols, rows, cell, min_x, max_y, end_xy)
|
|
331
|
+
if start < 0 or end < 0:
|
|
332
|
+
return None
|
|
333
|
+
shortest_cells = _a_star(cols, rows, start, end, ok, np.ones(n))
|
|
334
|
+
if shortest_cells is None:
|
|
335
|
+
return None
|
|
336
|
+
bias = max(0.2, cool_bias)
|
|
337
|
+
sc = np.full(n, 50.0) if ws is None else np.where(np.isfinite(ws), ws, 50.0)
|
|
338
|
+
discomfort = np.clip((100 - sc) / 100, 0, 1)
|
|
339
|
+
coolest_cells = _a_star(cols, rows, start, end, ok, 1 + bias * discomfort**1.5)
|
|
340
|
+
if coolest_cells is None:
|
|
341
|
+
coolest_cells = shortest_cells
|
|
342
|
+
grids = {
|
|
343
|
+
"walk": ws,
|
|
344
|
+
"utci": _flat(utci_grid),
|
|
345
|
+
"shade": _flat(shade_grid),
|
|
346
|
+
"gvi": _flat(gvi_grid),
|
|
347
|
+
"noise": _flat(noise_grid),
|
|
348
|
+
}
|
|
349
|
+
shortest = _summarize(
|
|
350
|
+
shortest_cells, "shortest", "Shortest path", "#e4572e", cols, cell, min_x, max_y, grids
|
|
351
|
+
)
|
|
352
|
+
coolest = _summarize(
|
|
353
|
+
coolest_cells,
|
|
354
|
+
"coolest",
|
|
355
|
+
"Coolest & shaded path",
|
|
356
|
+
"#1d8f5f",
|
|
357
|
+
cols,
|
|
358
|
+
cell,
|
|
359
|
+
min_x,
|
|
360
|
+
max_y,
|
|
361
|
+
grids,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def delta(key: str) -> float:
|
|
365
|
+
a, b = coolest[key], shortest[key]
|
|
366
|
+
return a - b if math.isfinite(a) and math.isfinite(b) else math.nan
|
|
367
|
+
|
|
368
|
+
detour = (
|
|
369
|
+
(coolest["length_m"] - shortest["length_m"]) / shortest["length_m"] * 100
|
|
370
|
+
if shortest["length_m"] > 0
|
|
371
|
+
else 0.0
|
|
372
|
+
)
|
|
373
|
+
return {
|
|
374
|
+
"shortest": shortest,
|
|
375
|
+
"coolest": coolest,
|
|
376
|
+
"detour_pct": detour,
|
|
377
|
+
"utci_delta_c": delta("mean_utci_c"),
|
|
378
|
+
"shade_gain_pct": delta("shade_pct"),
|
|
379
|
+
"walk_score_gain": delta("walk_score"),
|
|
380
|
+
}
|