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