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