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,388 @@
1
+ """Microclimate screening: surface energy balance, foliar canopy shading,
2
+ evapotranspirative canopy cooling advection and 24-hour diurnal comfort.
3
+
4
+ ENVI-met-inspired screening physics ported from the 02Geo3D QGIS plugin
5
+ (``web/src/core/microclimate.js``). Grids are ``(rows, cols)`` numpy arrays,
6
+ row 0 is north, column 0 is west.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Any, Callable, Mapping, Optional, Sequence, Union
13
+
14
+ import numpy as np
15
+
16
+ from .comfort import mean_radiant_temperature, pet, sky_temperature, utci
17
+ from .solar import day_of_year, solar_position
18
+ from .weather import Weather, conditions_at
19
+
20
+ __all__ = [
21
+ "SURFACE_MATERIALS",
22
+ "canopy_cooling_grid",
23
+ "canopy_transmittance",
24
+ "diurnal_point_profile",
25
+ "surface_temperature",
26
+ ]
27
+
28
+ SIGMA = 5.67e-8
29
+
30
+ #: Urban surface materials: albedo, longwave emissivity, Bowen ratio,
31
+ #: roughness length z0 (m) and a display colour.
32
+ SURFACE_MATERIALS: dict[str, dict[str, Any]] = {
33
+ "asphalt": {
34
+ "label": "Asphalt / dark pavement",
35
+ "albedo": 0.12,
36
+ "emissivity": 0.95,
37
+ "bowen": 8.0,
38
+ "roughness": 0.01,
39
+ "color": "#3b3d42",
40
+ },
41
+ "concrete": {
42
+ "label": "Concrete / stone",
43
+ "albedo": 0.28,
44
+ "emissivity": 0.92,
45
+ "bowen": 5.0,
46
+ "roughness": 0.015,
47
+ "color": "#a6acb5",
48
+ },
49
+ "brick": {
50
+ "label": "Brick / pavers",
51
+ "albedo": 0.25,
52
+ "emissivity": 0.93,
53
+ "bowen": 4.0,
54
+ "roughness": 0.02,
55
+ "color": "#b86b52",
56
+ },
57
+ "soil": {
58
+ "label": "Bare soil / gravel",
59
+ "albedo": 0.22,
60
+ "emissivity": 0.94,
61
+ "bowen": 2.0,
62
+ "roughness": 0.03,
63
+ "color": "#b59975",
64
+ },
65
+ "grass": {
66
+ "label": "Grass / vegetated ground",
67
+ "albedo": 0.22,
68
+ "emissivity": 0.97,
69
+ "bowen": 0.5,
70
+ "roughness": 0.05,
71
+ "color": "#6fbf73",
72
+ },
73
+ "cool_pavement": {
74
+ "label": "Cool pavement (high albedo)",
75
+ "albedo": 0.55,
76
+ "emissivity": 0.95,
77
+ "bowen": 5.0,
78
+ "roughness": 0.01,
79
+ "color": "#d8dee9",
80
+ },
81
+ "green_roof": {
82
+ "label": "Green / cool roof",
83
+ "albedo": 0.45,
84
+ "emissivity": 0.96,
85
+ "bowen": 0.6,
86
+ "roughness": 0.06,
87
+ "color": "#7fc97f",
88
+ },
89
+ "water": {
90
+ "label": "Water surface",
91
+ "albedo": 0.08,
92
+ "emissivity": 0.98,
93
+ "bowen": 0.25,
94
+ "roughness": 0.001,
95
+ "color": "#2b83ba",
96
+ },
97
+ }
98
+
99
+ Material = Union[str, Mapping[str, Any]]
100
+
101
+
102
+ def _material(material: Material) -> Mapping[str, Any]:
103
+ if isinstance(material, Mapping):
104
+ return material
105
+ return SURFACE_MATERIALS.get(str(material), SURFACE_MATERIALS["concrete"])
106
+
107
+
108
+ def _finite(x: Any) -> bool:
109
+ try:
110
+ return x is not None and math.isfinite(float(x))
111
+ except (TypeError, ValueError):
112
+ return False
113
+
114
+
115
+ def _prop(mat: Mapping[str, Any], key: str, default: float) -> float:
116
+ v = mat.get(key)
117
+ return float(v) if v is not None and _finite(v) else default
118
+
119
+
120
+ def canopy_transmittance(lai: float = 3.0, extinction: float = 0.6) -> float:
121
+ """Beer-Lambert shortwave transmittance through a canopy of leaf area index ``lai``."""
122
+ return math.exp(-extinction * max(0.0, lai))
123
+
124
+
125
+ def surface_temperature(
126
+ air_temp: float,
127
+ sky_temp: float,
128
+ svf: float = 1.0,
129
+ sunlit: float = 1.0,
130
+ dni: float = 0.0,
131
+ dhi: float = 0.0,
132
+ ghi: float = 0.0,
133
+ elevation_deg: float = 45.0,
134
+ wind_speed: Optional[float] = 2.0,
135
+ material: Material = "concrete",
136
+ tree_shade: float = 0.0,
137
+ lai: float = 3.0,
138
+ ) -> float:
139
+ """Steady-state ground/roof surface temperature (C) from an energy balance.
140
+
141
+ Absorbed short-wave plus net long-wave to the sky is split into
142
+ convective (Bowen-ratio partitioned) and linearised radiative exchange.
143
+ The excess over air temperature is clamped to -12..+35 K.
144
+
145
+ Args:
146
+ air_temp: air temperature, C.
147
+ sky_temp: effective sky temperature, C.
148
+ svf: sky view factor (0..1).
149
+ sunlit: direct-sun fraction (0..1).
150
+ dni: direct normal irradiance, W/m2.
151
+ dhi: diffuse horizontal irradiance, W/m2.
152
+ ghi: global horizontal irradiance, W/m2 (unused; kept for API parity).
153
+ elevation_deg: sun elevation, degrees.
154
+ wind_speed: near-surface wind speed, m/s (clamped 0.2..15; None -> 2).
155
+ material: key of :data:`SURFACE_MATERIALS` or a property mapping.
156
+ tree_shade: canopy shading fraction (0..1).
157
+ lai: leaf area index of shading trees.
158
+ """
159
+ del ghi
160
+ mat = _material(material)
161
+ albedo = _prop(mat, "albedo", 0.28)
162
+ emissivity = _prop(mat, "emissivity", 0.92)
163
+ bowen = _prop(mat, "bowen", 5.0)
164
+
165
+ sw_direct = (
166
+ dni * math.sin(math.radians(elevation_deg)) * max(0.0, min(1.0, float(sunlit)))
167
+ if elevation_deg > 0
168
+ else 0.0
169
+ )
170
+ shade = max(0.0, min(1.0, float(tree_shade)))
171
+ if shade > 0:
172
+ tau = canopy_transmittance(lai)
173
+ sw_direct *= 1 - shade * (1 - tau)
174
+
175
+ s = max(0.0, min(1.0, float(svf)))
176
+ sw_absorbed = (1 - albedo) * (sw_direct + s * dhi)
177
+
178
+ t_sky_k = sky_temp + 273.15
179
+ t_air_k = air_temp + 273.15
180
+ lw_net = emissivity * s * SIGMA * (t_sky_k**4 - t_air_k**4)
181
+ q_rad = sw_absorbed + lw_net
182
+
183
+ ws = max(0.2, min(15.0, float(wind_speed) if _finite(wind_speed) else 2.0)) # type: ignore[arg-type]
184
+ hc = 5.8 + 4.1 * ws
185
+ hr = 4 * emissivity * SIGMA * t_air_k**3
186
+ # q_rad = H + LE + hr dT with H = hc dT and LE = H / Bowen: evaporation
187
+ # raises the convective conductance only. A surface losing heat does not evaporate.
188
+ h_turb = hc * (1 + bowen) / bowen if q_rad > 0 else hc
189
+ delta_t = q_rad / (h_turb + hr)
190
+ return float(air_temp + max(-12.0, min(35.0, delta_t)))
191
+
192
+
193
+ def canopy_cooling_grid(
194
+ heights: np.ndarray,
195
+ cell: float,
196
+ porosity: Optional[np.ndarray] = None,
197
+ green_mask: Optional[np.ndarray] = None,
198
+ wind_from_deg: float = 270.0,
199
+ wind_speed: float = 2.0,
200
+ air_temp: float = 30.0,
201
+ rh: float = 45.0,
202
+ lai: float = 3.0,
203
+ svf: Optional[np.ndarray] = None,
204
+ ) -> np.ndarray:
205
+ """Evapotranspirative cooling potential advected downwind, plus canyon heating.
206
+
207
+ Args:
208
+ heights: ``(rows, cols)`` obstacle height above ground (0 = open).
209
+ cell: cell size, metres.
210
+ porosity: optical porosity per cell (trees > 0); None = all solid.
211
+ green_mask: vegetated ground fraction 0..1 (grass/lawn).
212
+ wind_from_deg: meteorological direction the wind comes from (0 = N, 90 = E).
213
+ wind_speed: ambient wind speed, m/s.
214
+ air_temp: ambient air temperature, C.
215
+ rh: relative humidity, %.
216
+ lai: tree leaf area index.
217
+ svf: sky view factor grid; cells below 0.5 get up to +1.5 K canyon heating.
218
+
219
+ Returns:
220
+ ``(rows, cols)`` air temperature change dTa (K; negative = cooling,
221
+ NaN inside solid buildings).
222
+ """
223
+ h = np.asarray(heights, dtype=float)
224
+ rows, cols = h.shape
225
+ por = None if porosity is None else np.asarray(porosity, dtype=float).reshape(rows, cols)
226
+ tree = por > 0 if por is not None else np.zeros_like(h, dtype=bool)
227
+ solid = (h > 0) & ~tree
228
+
229
+ vpd_norm = max(0.1, min(1.0, 1 - max(0.0, min(100.0, rh)) / 100))
230
+ temp_excess = max(0.0, min(1.5, (max(15.0, air_temp) - 10) / 15))
231
+ vpd_factor = vpd_norm * temp_excess
232
+
233
+ tree_cool = np.where(tree, -2.5 * vpd_factor * min(1.0, max(0.0, lai / 3.0)), 0.0)
234
+ if green_mask is not None:
235
+ g = np.asarray(green_mask, dtype=float).reshape(rows, cols)
236
+ grass_cool = np.where(g > 0, -1.0 * vpd_factor * np.clip(g, 0, 1), 0.0)
237
+ else:
238
+ grass_cool = np.zeros_like(h)
239
+ source = np.where(solid, 0.0, np.minimum(tree_cool, grass_cool))
240
+
241
+ # Unit step pointing upwind: +col is east, -row is north.
242
+ from_rad = math.radians(wind_from_deg)
243
+ dc = math.sin(from_rad)
244
+ dr = -math.cos(from_rad)
245
+ decay = max(25.0, min(60.0, 35 + wind_speed * 2.5))
246
+ max_steps = int(math.ceil(120 / cell))
247
+
248
+ rr, cc = np.mgrid[0:rows, 0:cols]
249
+ best = np.zeros_like(h)
250
+ alive = ~solid
251
+ for s in range(1, max_steps + 1):
252
+ # JS Math.round rounds halves up: floor(x + 0.5).
253
+ uc = np.floor(cc + s * dc + 0.5).astype(int)
254
+ ur = np.floor(rr + s * dr + 0.5).astype(int)
255
+ inside = (uc >= 0) & (uc < cols) & (ur >= 0) & (ur < rows)
256
+ alive &= inside
257
+ if not alive.any():
258
+ break
259
+ ucc = np.clip(uc, 0, cols - 1)
260
+ urc = np.clip(ur, 0, rows - 1)
261
+ alive &= ~solid[urc, ucc]
262
+ up = source[urc, ucc]
263
+ adv = np.where(alive & (up < 0), up * math.exp(-s * cell / decay), 0.0)
264
+ best = np.minimum(best, adv)
265
+
266
+ cooling = np.minimum(source, best)
267
+ uhi = np.zeros_like(h)
268
+ if svf is not None:
269
+ sv = np.asarray(svf, dtype=float).reshape(rows, cols)
270
+ with np.errstate(invalid="ignore"):
271
+ low = np.isfinite(sv) & (sv < 0.5)
272
+ uhi = np.where(low, 1.5 * ((0.5 - np.where(low, sv, 0.5)) / 0.5), 0.0)
273
+ out = cooling + uhi
274
+ out[solid] = np.nan
275
+ return out
276
+
277
+
278
+ SunByHour = Union[Sequence[float], Callable[[int], float], None]
279
+
280
+
281
+ def diurnal_point_profile(
282
+ lat: float,
283
+ lon: float,
284
+ utc_offset: float,
285
+ month: int,
286
+ day: int,
287
+ weather: Optional[Weather] = None,
288
+ air_temp_mean: float = 28.0,
289
+ air_temp_amp: float = 6.0,
290
+ rh_mean: float = 50.0,
291
+ wind_speed: float = 2.0,
292
+ svf: float = 0.7,
293
+ sun_by_hour: SunByHour = None,
294
+ material: Material = "concrete",
295
+ tree_crown: bool = False,
296
+ lai: float = 3.0,
297
+ ) -> list[dict[str, float]]:
298
+ """24-hour air, surface, mean radiant temperature, UTCI and PET at one spot.
299
+
300
+ Uses EPW conditions when ``weather`` is given, otherwise a sinusoidal
301
+ temperature wave peaking at 15:00 with a synthetic clear sky.
302
+
303
+ Args:
304
+ sun_by_hour: sunlit fraction per hour (24 values or ``f(hour)``);
305
+ default: sunlit whenever the sun is up.
306
+
307
+ Returns:
308
+ 24 dicts with keys ``hour, air_temp, surface_temp, mrt, utci, pet, sunlit``.
309
+ """
310
+ mat = _material(material)
311
+ doy = day_of_year(2025, month, day)
312
+ profile: list[dict[str, float]] = []
313
+ for h in range(24):
314
+ pos = solar_position(doy, h + 0.5, lat, lon, utc_offset)
315
+ elevation_deg = math.degrees(pos.elevation)
316
+ air_temp, rh, ws = air_temp_mean, rh_mean, wind_speed
317
+ dni = dhi = ghi = 0.0
318
+ ir: Optional[float] = None
319
+ if weather is not None:
320
+ cond = conditions_at(weather, month, day, h + 1)
321
+ if _finite(cond["air_temp"]):
322
+ air_temp = float(cond["air_temp"]) # type: ignore[arg-type]
323
+ if _finite(cond["rh"]):
324
+ rh = float(cond["rh"]) # type: ignore[arg-type]
325
+ if _finite(cond["wind_speed"]):
326
+ ws = float(cond["wind_speed"]) # type: ignore[arg-type]
327
+ dni = float(cond["dni"] or 0.0)
328
+ dhi = float(cond["dhi"] or 0.0)
329
+ ghi = float(cond["ghi"] or 0.0)
330
+ if _finite(cond["ir"]):
331
+ ir = float(cond["ir"]) # type: ignore[arg-type]
332
+ else:
333
+ wave = math.cos((h - 15) * math.pi / 12)
334
+ air_temp = air_temp_mean + air_temp_amp * wave
335
+ rh = max(20.0, min(95.0, rh_mean - 15 * wave))
336
+ if elevation_deg > 0:
337
+ sin_el = math.sin(pos.elevation)
338
+ dni = 950 * math.exp(-0.13 / max(0.01, sin_el))
339
+ dhi = 120 * sin_el
340
+ ghi = dni * sin_el + dhi
341
+
342
+ sunlit = 1.0 if elevation_deg > 0 else 0.0
343
+ if sun_by_hour is not None:
344
+ if callable(sun_by_hour):
345
+ sunlit = float(sun_by_hour(h))
346
+ elif h < len(sun_by_hour) and _finite(sun_by_hour[h]):
347
+ sunlit = float(sun_by_hour[h])
348
+
349
+ sky = sky_temperature(air_temp, ir)
350
+ surf = surface_temperature(
351
+ air_temp,
352
+ sky,
353
+ svf=svf,
354
+ sunlit=sunlit,
355
+ dni=dni,
356
+ dhi=dhi,
357
+ ghi=ghi,
358
+ elevation_deg=elevation_deg,
359
+ wind_speed=ws,
360
+ material=mat,
361
+ tree_shade=1.0 if tree_crown else 0.0,
362
+ lai=lai,
363
+ )
364
+ mrt = mean_radiant_temperature(
365
+ air_temp,
366
+ sky,
367
+ svf,
368
+ sunlit,
369
+ dni,
370
+ dhi,
371
+ ghi,
372
+ elevation_deg,
373
+ albedo=_prop(mat, "albedo", 0.2),
374
+ ground_temp=surf,
375
+ wall_temp=air_temp,
376
+ )
377
+ profile.append(
378
+ {
379
+ "hour": h,
380
+ "air_temp": air_temp,
381
+ "surface_temp": surf,
382
+ "mrt": mrt,
383
+ "utci": float(utci(air_temp, mrt, ws, rh)),
384
+ "pet": pet(air_temp, mrt, ws, rh),
385
+ "sunlit": sunlit,
386
+ }
387
+ )
388
+ return profile
@@ -0,0 +1,283 @@
1
+ """Urban morphology and heat-island indicators from a building-height grid.
2
+
3
+ Heights are metres above ground per cell (0 = open), as a ``(rows, cols)``
4
+ array with row 0 at the north.
5
+
6
+ * ``lambda_p`` plan area fraction, ``lambda_f`` frontal area index (wind)
7
+ * ``H`` area-weighted mean height, ``H/W`` canyon aspect ratio
8
+ * ``zd``, ``z0`` displacement height and roughness length, Macdonald,
9
+ Griffiths and Hall (1998), Atmos. Environ. 32(11):1857-1864,
10
+ doi:10.1016/S1352-2310(97)00403-2 (A = 4.43, beta = 1, Cd = 1.2)
11
+ * LCZ: approximate Local Climate Zone (built types 1-6, 8, 9) from the
12
+ property ranges of Stewart and Oke (2012), BAMS 93(12):1879-1900,
13
+ doi:10.1175/BAMS-D-11-00019.1.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ from dataclasses import dataclass
20
+ from typing import Optional
21
+
22
+ import numpy as np
23
+
24
+ __all__ = [
25
+ "LCZ_COLORS",
26
+ "LCZ_LABELS",
27
+ "BlockIndicators",
28
+ "block_indicators",
29
+ "classify_lcz",
30
+ "distance_to_built",
31
+ "macdonald",
32
+ ]
33
+
34
+ KAPPA = 0.4
35
+
36
+ LCZ_LABELS: dict[int, str] = {
37
+ 1: "LCZ 1 Compact high-rise",
38
+ 2: "LCZ 2 Compact midrise",
39
+ 3: "LCZ 3 Compact low-rise",
40
+ 4: "LCZ 4 Open high-rise",
41
+ 5: "LCZ 5 Open midrise",
42
+ 6: "LCZ 6 Open low-rise",
43
+ 8: "LCZ 8 Large low-rise",
44
+ 9: "LCZ 9 Sparsely built",
45
+ 0: "Open land (LCZ A-G)",
46
+ }
47
+ LCZ_COLORS: dict[int, str] = {
48
+ 1: "#8c0000",
49
+ 2: "#d10000",
50
+ 3: "#ff0000",
51
+ 4: "#bf4d00",
52
+ 5: "#ff6600",
53
+ 6: "#ff9955",
54
+ 8: "#bcbcbc",
55
+ 9: "#ffccaa",
56
+ 0: "#6fbf73",
57
+ }
58
+
59
+
60
+ def _as2d(heights: np.ndarray, cols: Optional[int], rows: Optional[int]) -> np.ndarray:
61
+ h = np.asarray(heights, dtype=float)
62
+ if h.ndim == 1:
63
+ if cols is None or rows is None:
64
+ raise ValueError("cols and rows are required for a flat heights array")
65
+ h = h.reshape(rows, cols)
66
+ return h
67
+
68
+
69
+ def distance_to_built(
70
+ heights: np.ndarray,
71
+ cols: Optional[int] = None,
72
+ rows: Optional[int] = None,
73
+ cell: float = 1.0,
74
+ ) -> np.ndarray:
75
+ """Two-pass chamfer distance (metres) from every cell to the nearest built cell.
76
+
77
+ ``heights`` may be ``(rows, cols)`` or flat with ``cols``/``rows`` given.
78
+ Returns a ``(rows, cols)`` array; 1e9 when nothing is built.
79
+ """
80
+ h = _as2d(heights, cols, rows)
81
+ nr, nc = h.shape
82
+ d = np.where(h > 0, 0.0, 1e9)
83
+ a = cell
84
+ b = cell * math.sqrt(2)
85
+ idx = np.arange(nc) * a
86
+ for r in range(nr):
87
+ w = d[r].copy()
88
+ if r > 0:
89
+ up = d[r - 1]
90
+ w = np.minimum(w, up + a)
91
+ w[1:] = np.minimum(w[1:], up[:-1] + b)
92
+ w[:-1] = np.minimum(w[:-1], up[1:] + b)
93
+ # v[c] = min(w[c], v[c-1] + a) == min_j<=c (w[j] + a (c - j))
94
+ d[r] = np.minimum.accumulate(w - idx) + idx
95
+ for r in range(nr - 1, -1, -1):
96
+ w = d[r].copy()
97
+ if r < nr - 1:
98
+ dn = d[r + 1]
99
+ w = np.minimum(w, dn + a)
100
+ w[:-1] = np.minimum(w[:-1], dn[1:] + b)
101
+ w[1:] = np.minimum(w[1:], dn[:-1] + b)
102
+ rev = w[::-1]
103
+ d[r] = (np.minimum.accumulate(rev - idx) + idx)[::-1]
104
+ return d
105
+
106
+
107
+ def macdonald(
108
+ lambda_p: float,
109
+ lambda_f: float,
110
+ mean_height: float,
111
+ A: float = 4.43,
112
+ beta: float = 1.0,
113
+ Cd: float = 1.2,
114
+ ) -> tuple[float, float]:
115
+ """Macdonald et al. (1998) displacement height and roughness length.
116
+
117
+ Returns:
118
+ ``(zd, z0)`` in metres.
119
+ """
120
+ if not (mean_height > 0) or not (lambda_p > 0):
121
+ return 0.0, 0.03
122
+ lp = min(0.95, lambda_p)
123
+ zd_h = 1 + A ** (-lp) * (lp - 1)
124
+ lf = max(1e-4, lambda_f)
125
+ z0_h = (1 - zd_h) * math.exp(-((0.5 * beta * (Cd / (KAPPA * KAPPA)) * (1 - zd_h) * lf) ** -0.5))
126
+ return zd_h * mean_height, max(0.01, z0_h * mean_height)
127
+
128
+
129
+ def classify_lcz(lambda_p: float, mean_height: float, mean_footprint: float = 0.0) -> int:
130
+ """Approximate built LCZ from plan fraction, mean height and mean footprint (m2)."""
131
+ if not (lambda_p >= 0.1):
132
+ return 9 if lambda_p > 0.02 else 0
133
+ tall = "high" if mean_height >= 25 else "mid" if mean_height >= 10 else "low"
134
+ if tall == "low" and mean_footprint >= 1500 and lambda_p >= 0.3:
135
+ return 8
136
+ if lambda_p < 0.2:
137
+ return 9 if tall == "low" else 5 if tall == "mid" else 4
138
+ if lambda_p >= 0.4:
139
+ return 1 if tall == "high" else 2 if tall == "mid" else 3
140
+ return 4 if tall == "high" else 5 if tall == "mid" else 6
141
+
142
+
143
+ @dataclass
144
+ class BlockIndicators:
145
+ """Morphology per block; every array is ``(block_rows, block_cols)``."""
146
+
147
+ cols: int
148
+ rows: int
149
+ cell: float
150
+ lambda_p: np.ndarray
151
+ lambda_f: np.ndarray
152
+ mean_height: np.ndarray
153
+ max_height: np.ndarray
154
+ height_std: np.ndarray
155
+ hw: np.ndarray
156
+ zd: np.ndarray
157
+ z0: np.ndarray
158
+ svf: np.ndarray
159
+ lcz: np.ndarray
160
+ volume_density: np.ndarray
161
+
162
+
163
+ def block_indicators(
164
+ heights: np.ndarray,
165
+ cell: float,
166
+ block_cells: int,
167
+ wind_from_deg: float = 270.0,
168
+ svf: Optional[np.ndarray] = None,
169
+ feature_ids: Optional[np.ndarray] = None,
170
+ cols: Optional[int] = None,
171
+ rows: Optional[int] = None,
172
+ ) -> BlockIndicators:
173
+ """Indicators per block of ``block_cells`` x ``block_cells`` grid cells.
174
+
175
+ Args:
176
+ heights: building height above ground per cell.
177
+ cell: cell size in metres.
178
+ block_cells: cells per block side.
179
+ wind_from_deg: wind direction (from), for ``lambda_f``.
180
+ svf: optional sky view factor per cell (block mean reported).
181
+ feature_ids: building id per cell (0 = none), for the mean footprint.
182
+ """
183
+ h = _as2d(heights, cols, rows)
184
+ nr, nc = h.shape
185
+ bc = math.ceil(nc / block_cells)
186
+ br = math.ceil(nr / block_cells)
187
+ nb = bc * br
188
+ to_rad = math.radians((wind_from_deg + 180) % 360)
189
+ ux = math.sin(to_rad)
190
+ uy = math.cos(to_rad)
191
+ dist = distance_to_built(h, cell=cell)
192
+ rr, cc = np.mgrid[0:nr, 0:nc]
193
+ block = ((rr // block_cells) * bc + (cc // block_cells)).ravel()
194
+ built = (h > 0).ravel()
195
+ hv = h.ravel()
196
+
197
+ # Windward faces: upwind neighbour lower than this cell.
198
+ frontal = np.zeros_like(h)
199
+ if ux > 0:
200
+ nb_x = np.pad(h, ((0, 0), (1, 0)), constant_values=np.nan)[:, :-1]
201
+ else:
202
+ nb_x = np.pad(h, ((0, 0), (0, 1)), constant_values=np.nan)[:, 1:]
203
+ if uy > 0:
204
+ nb_y = np.pad(h, ((0, 1), (0, 0)), constant_values=np.nan)[1:, :]
205
+ else:
206
+ nb_y = np.pad(h, ((1, 0), (0, 0)), constant_values=np.nan)[:-1, :]
207
+ with np.errstate(invalid="ignore"):
208
+ fx = np.where(np.isfinite(nb_x), np.maximum(0, h - nb_x), 0.0) * cell * abs(ux)
209
+ fy = np.where(np.isfinite(nb_y), np.maximum(0, h - nb_y), 0.0) * cell * abs(uy)
210
+ frontal = np.where(h > 0, fx + fy, 0.0).ravel()
211
+
212
+ def bsum(w: np.ndarray) -> np.ndarray:
213
+ return np.bincount(block, weights=w, minlength=nb)
214
+
215
+ n = np.bincount(block, minlength=nb).astype(float)
216
+ n_built = bsum(built.astype(float))
217
+ h_sum = bsum(np.where(built, hv, 0.0))
218
+ h2_sum = bsum(np.where(built, hv * hv, 0.0))
219
+ h_max = np.zeros(nb)
220
+ np.maximum.at(h_max, block[built], hv[built])
221
+ fr = bsum(frontal)
222
+ open_ = ~built
223
+ open_d = bsum(np.where(open_, dist.ravel(), 0.0))
224
+ open_n = bsum(open_.astype(float))
225
+ if svf is not None:
226
+ sv = np.asarray(svf, dtype=float).ravel()
227
+ fin = np.isfinite(sv)
228
+ svf_sum = bsum(np.where(fin, sv, 0.0))
229
+ svf_n = bsum(fin.astype(float))
230
+ else:
231
+ svf_sum = np.zeros(nb)
232
+ svf_n = np.zeros(nb)
233
+
234
+ mean_fp = np.zeros(nb)
235
+ if feature_ids is not None:
236
+ ids = np.asarray(feature_ids).ravel().astype(np.int64)
237
+ pos = ids > 0
238
+ uniq, counts = np.unique(ids[pos], return_counts=True)
239
+ fp_of = dict(zip(uniq.tolist(), (counts * cell * cell).tolist()))
240
+ pairs = np.unique(np.stack([block[built & pos], ids[built & pos]], 1), axis=0)
241
+ for k in range(nb):
242
+ sel = pairs[pairs[:, 0] == k, 1] if pairs.size else np.zeros(0, dtype=np.int64)
243
+ if sel.size:
244
+ mean_fp[k] = sum(fp_of.get(int(i), 0.0) for i in sel) / sel.size
245
+
246
+ plan = n * cell * cell
247
+ with np.errstate(invalid="ignore", divide="ignore"):
248
+ lp = np.where(n > 0, n_built / np.maximum(n, 1), 0.0)
249
+ H = np.where(n_built > 0, h_sum / np.maximum(n_built, 1), 0.0)
250
+ hstd = np.where(
251
+ n_built > 0, np.sqrt(np.maximum(0, h2_sum / np.maximum(n_built, 1) - H * H)), 0.0
252
+ )
253
+ lf = np.where(plan > 0, fr / np.maximum(plan, 1e-300), 0.0)
254
+ vd = np.where(plan > 0, h_sum * cell * cell / np.maximum(plan, 1e-300), 0.0)
255
+ W = np.where(open_n > 0, 2 * open_d / np.maximum(open_n, 1), 0.0)
256
+ hw = np.where((W > 0) & (H > 0), H / np.where(W > 0, W, 1), np.where(H > 0, 3.0, 0.0))
257
+ svf_b = np.where(svf_n > 0, svf_sum / np.maximum(svf_n, 1), np.nan)
258
+ zd = np.zeros(nb)
259
+ z0 = np.zeros(nb)
260
+ lcz = np.zeros(nb, dtype=np.uint8)
261
+ for k in range(nb):
262
+ zd[k], z0[k] = macdonald(float(lp[k]), float(lf[k]), float(H[k]))
263
+ lcz[k] = classify_lcz(float(lp[k]), float(H[k]), float(mean_fp[k]))
264
+
265
+ def shp(a: np.ndarray) -> np.ndarray:
266
+ return a.reshape(br, bc)
267
+
268
+ return BlockIndicators(
269
+ cols=bc,
270
+ rows=br,
271
+ cell=cell * block_cells,
272
+ lambda_p=shp(lp),
273
+ lambda_f=shp(lf),
274
+ mean_height=shp(H),
275
+ max_height=shp(h_max),
276
+ height_std=shp(hstd),
277
+ hw=shp(hw),
278
+ zd=shp(zd),
279
+ z0=shp(z0),
280
+ svf=shp(svf_b),
281
+ lcz=shp(lcz),
282
+ volume_density=shp(vd),
283
+ )