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,372 @@
1
+ // @ts-check
2
+ // Microclimate physics module: ENVI-met-inspired surface energy balance,
3
+ // foliar canopy shading, evapotranspirative canopy cooling advection,
4
+ // and 24-hour diurnal thermal comfort profiling.
5
+
6
+ import { skyTemperature, meanRadiantTemperature, utci, pet } from './comfort.js';
7
+ import { dayOfYear, solarPosition } from './solar.js';
8
+ import { conditionsAt } from './weather.js';
9
+
10
+ const SIGMA = 5.67e-8;
11
+
12
+ /**
13
+ * Standard urban surface materials with thermal and radiative properties.
14
+ * albedo: shortwave reflectance (0..1)
15
+ * emissivity: thermal longwave emissivity (0..1)
16
+ * bowen: Bowen ratio (sensible heat / latent heat flux)
17
+ * roughness: aerodynamic roughness length z0 (m)
18
+ * color: default hexadecimal color for 3D visualization
19
+ */
20
+ export const SURFACE_MATERIALS = {
21
+ asphalt: { label: 'Asphalt / dark pavement', albedo: 0.12, emissivity: 0.95, bowen: 8.0, roughness: 0.01, color: '#3b3d42' },
22
+ concrete: { label: 'Concrete / stone', albedo: 0.28, emissivity: 0.92, bowen: 5.0, roughness: 0.015, color: '#a6acb5' },
23
+ brick: { label: 'Brick / pavers', albedo: 0.25, emissivity: 0.93, bowen: 4.0, roughness: 0.02, color: '#b86b52' },
24
+ soil: { label: 'Bare soil / gravel', albedo: 0.22, emissivity: 0.94, bowen: 2.0, roughness: 0.03, color: '#b59975' },
25
+ grass: { label: 'Grass / vegetated ground', albedo: 0.22, emissivity: 0.97, bowen: 0.5, roughness: 0.05, color: '#6fbf73' },
26
+ cool_pavement: { label: 'Cool pavement (high albedo)', albedo: 0.55, emissivity: 0.95, bowen: 5.0, roughness: 0.01, color: '#d8dee9' },
27
+ green_roof: { label: 'Green / cool roof', albedo: 0.45, emissivity: 0.96, bowen: 0.6, roughness: 0.06, color: '#7fc97f' },
28
+ water: { label: 'Water surface', albedo: 0.08, emissivity: 0.98, bowen: 0.25, roughness: 0.001, color: '#2b83ba' }
29
+ };
30
+
31
+ /**
32
+ * Beer-Lambert shortwave foliar transmittance through vegetation canopy.
33
+ * @param {number} [lai] leaf area index (m2 leaves / m2 ground, default 3.0)
34
+ * @param {number} [extinction] light extinction coefficient k (default 0.6)
35
+ * @returns {number} transmittance fraction (0..1)
36
+ */
37
+ export function canopyTransmittance(lai = 3.0, extinction = 0.6) {
38
+ return Math.exp(-extinction * Math.max(0, lai));
39
+ }
40
+
41
+ /**
42
+ * Steady-state ground/roof surface temperature Ts (°C) via energy balance.
43
+ * @param {object} p
44
+ * @param {number} p.airTemp air temperature, °C
45
+ * @param {number} p.skyTemp effective sky temperature, °C
46
+ * @param {number} [p.svf] sky view factor (0..1, default 1)
47
+ * @param {number|boolean} [p.sunlit] direct sun fraction (0..1, default 1)
48
+ * @param {number} [p.dni] direct normal irradiance, W/m2
49
+ * @param {number} [p.dhi] diffuse horizontal irradiance, W/m2
50
+ * @param {number} [p.ghi] global horizontal irradiance, W/m2
51
+ * @param {number} [p.elevationDeg] sun elevation angle, degrees (default 45)
52
+ * @param {number} [p.windSpeed] near-surface wind speed, m/s (default 2)
53
+ * @param {string|object} [p.material] material key in SURFACE_MATERIALS or property object
54
+ * @param {number} [p.treeShade] canopy shading fraction (0..1, default 0)
55
+ * @param {number} [p.lai] leaf area index of shading trees (default 3.0)
56
+ * @returns {number} surface temperature, °C
57
+ */
58
+ export function surfaceTemperature({
59
+ airTemp,
60
+ skyTemp,
61
+ svf = 1,
62
+ sunlit = 1,
63
+ dni = 0,
64
+ dhi = 0,
65
+ ghi = 0,
66
+ elevationDeg = 45,
67
+ windSpeed = 2,
68
+ material = 'concrete',
69
+ treeShade = 0,
70
+ lai = 3.0
71
+ }) {
72
+ const mat = (typeof material === 'object' && material) ? material : (SURFACE_MATERIALS[material] || SURFACE_MATERIALS.concrete);
73
+ const albedo = Number.isFinite(mat.albedo) ? mat.albedo : 0.28;
74
+ const emissivity = Number.isFinite(mat.emissivity) ? mat.emissivity : 0.92;
75
+ const bowen = Number.isFinite(mat.bowen) ? mat.bowen : 5.0;
76
+
77
+ // Direct shortwave on horizontal surface
78
+ let swDirect = elevationDeg > 0
79
+ ? dni * Math.sin(elevationDeg * Math.PI / 180) * Math.max(0, Math.min(1, Number(sunlit)))
80
+ : 0;
81
+
82
+ // Attenuate direct beam if tree shade is present
83
+ const shade = Math.max(0, Math.min(1, Number(treeShade)));
84
+ if (shade > 0) {
85
+ const tau = canopyTransmittance(lai);
86
+ swDirect *= (1 - shade * (1 - tau));
87
+ }
88
+
89
+ const svfClamped = Math.max(0, Math.min(1, svf));
90
+ const swDiffuse = svfClamped * dhi;
91
+ const swAbsorbed = (1 - albedo) * (swDirect + swDiffuse);
92
+
93
+ // Net longwave deficit to cold sky
94
+ const tSkyK = skyTemp + 273.15;
95
+ const tAirK = airTemp + 273.15;
96
+ const lwNet = emissivity * svfClamped * SIGMA * (Math.pow(tSkyK, 4) - Math.pow(tAirK, 4));
97
+
98
+ const qRad = swAbsorbed + lwNet;
99
+
100
+ // Convective conductance and linearized radiative conductance
101
+ const ws = Math.max(0.2, Math.min(15, Number.isFinite(windSpeed) ? windSpeed : 2));
102
+ const hc = 5.8 + 4.1 * ws;
103
+ const hr = 4 * emissivity * SIGMA * Math.pow(tAirK, 3);
104
+
105
+ // qRad = H + LE + hr dT with H = hc dT and LE = H / beta (Bowen ratio), so
106
+ // evaporation raises the convective conductance only, not the radiative
107
+ // one. A surface losing heat (qRad < 0) does not evaporate.
108
+ const hTurb = qRad > 0 ? hc * (1 + bowen) / bowen : hc;
109
+ const deltaT = qRad / (hTurb + hr);
110
+ const clampedDelta = Math.max(-12, Math.min(35, deltaT));
111
+
112
+ return airTemp + clampedDelta;
113
+ }
114
+
115
+ /**
116
+ * Evapotranspirative canopy cooling potential and downwind advection grid.
117
+ * @param {object} p
118
+ * @param {Float32Array} p.heights obstacle height above ground per cell (0 = open)
119
+ * @param {Float32Array|null} [p.porosity] optical porosity per cell 0..1 (trees > 0), null = solid
120
+ * @param {Float32Array|null} [p.greenMask] vegetated ground fraction 0..1 (grass/lawn)
121
+ * @param {number} p.cols grid column count
122
+ * @param {number} p.rows grid row count
123
+ * @param {number} p.cell grid cell dimension in metres
124
+ * @param {number} [p.windFromDeg] meteorological direction wind is coming from (degrees, 0 = N, 90 = E, 180 = S, 270 = W)
125
+ * @param {number} [p.windSpeed] ambient wind speed, m/s (default 2)
126
+ * @param {number} [p.airTemp] ambient air temperature, °C (default 30)
127
+ * @param {number} [p.rh] relative humidity, % (default 45)
128
+ * @param {number} [p.lai] tree leaf area index (default 3.0)
129
+ * @param {Float32Array|null} [p.svf] sky view factor grid (0..1)
130
+ * @returns {Float32Array} local air temperature modification dTa (°C, negative = cooling, positive = canyon heating; NaN inside solid buildings)
131
+ */
132
+ export function canopyCoolingGrid({
133
+ heights,
134
+ porosity = null,
135
+ greenMask = null,
136
+ cols,
137
+ rows,
138
+ cell,
139
+ windFromDeg = 270,
140
+ windSpeed = 2,
141
+ airTemp = 30,
142
+ rh = 45,
143
+ lai = 3.0,
144
+ svf = null
145
+ }) {
146
+ const n = cols * rows;
147
+ const dTa = new Float32Array(n);
148
+ const source = new Float32Array(n);
149
+
150
+ // Evapotranspiration driving potential proportional to VPD and temperature above 10°C
151
+ const vpdNorm = Math.max(0.1, Math.min(1.0, 1 - Math.max(0, Math.min(100, rh)) / 100));
152
+ const tempExcess = Math.max(0, Math.min(1.5, (Math.max(15, airTemp) - 10) / 15));
153
+ const vpdFactor = vpdNorm * tempExcess;
154
+
155
+ // 1. Identify local evapotranspirative cooling source potential per cell
156
+ for (let i = 0; i < n; i++) {
157
+ const isSolidBuilding = heights[i] > 0 && !(porosity && porosity[i] > 0);
158
+ if (isSolidBuilding) {
159
+ source[i] = 0;
160
+ continue;
161
+ }
162
+
163
+ let treeCool = 0;
164
+ if (porosity && porosity[i] > 0) {
165
+ // Tree canopy cell: cooling up to -2.5 °C for dense canopy (lai >= 3)
166
+ const treeDensity = Math.min(1.0, Math.max(0, lai / 3.0));
167
+ treeCool = -2.5 * vpdFactor * treeDensity;
168
+ }
169
+
170
+ let grassCool = 0;
171
+ if (greenMask && greenMask[i] > 0) {
172
+ // Vegetated ground cell: cooling up to -1.0 °C
173
+ const grassDensity = Math.min(1.0, Math.max(0, greenMask[i]));
174
+ grassCool = -1.0 * vpdFactor * grassDensity;
175
+ }
176
+
177
+ source[i] = Math.min(treeCool, grassCool);
178
+ }
179
+
180
+ // Unit vector pointing upwind (towards where the wind comes from):
181
+ // +col is east (+x), -row is north (+y)
182
+ const fromRad = windFromDeg * Math.PI / 180;
183
+ const dc = Math.sin(fromRad);
184
+ const dr = -Math.cos(fromRad);
185
+
186
+ // Exponential downwind decay length (~30-50 m, scaled slightly with wind speed)
187
+ const decay = Math.max(25, Math.min(60, 35 + windSpeed * 2.5));
188
+ const maxSearchSteps = Math.ceil(120 / cell);
189
+
190
+ // 2. Advect and diffuse cooling downwind and apply canyon heat increment
191
+ for (let r = 0; r < rows; r++) {
192
+ for (let c = 0; c < cols; c++) {
193
+ const idx = r * cols + c;
194
+ const isSolidBuilding = heights[idx] > 0 && !(porosity && porosity[idx] > 0);
195
+ if (isSolidBuilding) {
196
+ dTa[idx] = NaN;
197
+ continue;
198
+ }
199
+
200
+ // Local source cooling at cell
201
+ let cellCooling = source[idx];
202
+
203
+ // Trace upwind along the wind streamline to accumulate advected cooling
204
+ let maxUpwindCooling = 0;
205
+ for (let s = 1; s <= maxSearchSteps; s++) {
206
+ const uc = Math.round(c + s * dc);
207
+ const ur = Math.round(r + s * dr);
208
+ if (uc < 0 || uc >= cols || ur < 0 || ur >= rows) break;
209
+ const uidx = ur * cols + uc;
210
+ // Solid obstacle blocks advection fetch
211
+ if (heights[uidx] > 0 && !(porosity && porosity[uidx] > 0)) break;
212
+
213
+ const upSource = source[uidx];
214
+ if (upSource < 0) {
215
+ const dist = s * cell;
216
+ const advected = upSource * Math.exp(-dist / decay);
217
+ if (advected < maxUpwindCooling) {
218
+ maxUpwindCooling = advected;
219
+ }
220
+ }
221
+ }
222
+
223
+ // Total cooling modification
224
+ const cooling = Math.min(cellCooling, maxUpwindCooling);
225
+
226
+ // Urban canyon heat trapping increment in deep low-SVF canyons
227
+ let uhiIncrement = 0;
228
+ if (svf && Number.isFinite(svf[idx]) && svf[idx] < 0.5) {
229
+ uhiIncrement = 1.5 * ((0.5 - svf[idx]) / 0.5);
230
+ }
231
+
232
+ dTa[idx] = cooling + uhiIncrement;
233
+ }
234
+ }
235
+
236
+ return dTa;
237
+ }
238
+
239
+ /**
240
+ * Compute 24-hour diurnal thermal comfort and surface temperature profile.
241
+ * @param {object} p
242
+ * @param {number} p.lat latitude in degrees
243
+ * @param {number} p.lon longitude in degrees
244
+ * @param {number} p.utcOffset UTC offset in hours
245
+ * @param {number} p.month calendar month (1..12)
246
+ * @param {number} p.day day of month (1..31)
247
+ * @param {object|null} [p.weather] parsed EPW weather dataset from parseEpw()
248
+ * @param {number} [p.airTempMean] daily mean air temperature if no weather dataset (°C, default 28)
249
+ * @param {number} [p.airTempAmp] daily temperature amplitude if no weather dataset (°C, default 6)
250
+ * @param {number} [p.rhMean] daily mean relative humidity if no weather dataset (%, default 50)
251
+ * @param {number} [p.windSpeed] ambient wind speed (m/s, default 2)
252
+ * @param {number} [p.svf] sky view factor of the spot (0..1, default 0.7)
253
+ * @param {Array<number>|Function|null} [p.sunByHour] array or function returning sunlit fraction (0..1) per hour
254
+ * @param {string|object} [p.material] surface material key or definition (default 'concrete')
255
+ * @param {boolean} [p.treeCrown] whether spot is shaded by tree canopy (default false)
256
+ * @param {number} [p.lai] leaf area index of shading trees (default 3.0)
257
+ * @returns {Array<{ hour: number, airTemp: number, surfaceTemp: number, mrt: number, utci: number, pet: number, sunlit: number }>}
258
+ */
259
+ export function diurnalPointProfile({
260
+ lat,
261
+ lon,
262
+ utcOffset,
263
+ month,
264
+ day,
265
+ weather = null,
266
+ airTempMean = 28,
267
+ airTempAmp = 6,
268
+ rhMean = 50,
269
+ windSpeed = 2,
270
+ svf = 0.7,
271
+ sunByHour = null,
272
+ material = 'concrete',
273
+ treeCrown = false,
274
+ lai = 3.0
275
+ }) {
276
+ const mat = (typeof material === 'object' && material) ? material : (SURFACE_MATERIALS[material] || SURFACE_MATERIALS.concrete);
277
+ const doy = dayOfYear(2025, month, day);
278
+ const profile = [];
279
+
280
+ for (let h = 0; h < 24; h++) {
281
+ const pos = solarPosition(doy, h + 0.5, lat, lon, utcOffset);
282
+ const elevationDeg = pos.elevation * 180 / Math.PI;
283
+
284
+ let airTemp = airTempMean;
285
+ let rh = rhMean;
286
+ let ws = windSpeed;
287
+ let dni = 0;
288
+ let dhi = 0;
289
+ let ghi = 0;
290
+ let ir = null;
291
+
292
+ if (weather) {
293
+ const cond = conditionsAt(weather, month, day, h + 1);
294
+ if (Number.isFinite(cond.airTemp)) airTemp = cond.airTemp;
295
+ if (Number.isFinite(cond.rh)) rh = cond.rh;
296
+ if (Number.isFinite(cond.windSpeed)) ws = cond.windSpeed;
297
+ if (Number.isFinite(cond.dni)) dni = cond.dni;
298
+ if (Number.isFinite(cond.dhi)) dhi = cond.dhi;
299
+ if (Number.isFinite(cond.ghi)) ghi = cond.ghi;
300
+ if (Number.isFinite(cond.ir)) ir = cond.ir;
301
+ } else {
302
+ // Sinusoidal diurnal temperature wave with peak at 15:00
303
+ airTemp = airTempMean + airTempAmp * Math.cos((h - 15) * Math.PI / 12);
304
+ // Inverted diurnal relative humidity wave with minimum at 15:00
305
+ rh = Math.max(20, Math.min(95, rhMean - 15 * Math.cos((h - 15) * Math.PI / 12)));
306
+ ws = windSpeed;
307
+
308
+ if (elevationDeg > 0) {
309
+ const sinElev = Math.sin(pos.elevation);
310
+ dni = 950 * Math.exp(-0.13 / Math.max(0.01, sinElev));
311
+ dhi = 120 * sinElev;
312
+ ghi = dni * sinElev + dhi;
313
+ }
314
+ }
315
+
316
+ // Determine sunlit fraction for this hour
317
+ let isSunlit = elevationDeg > 0 ? 1 : 0;
318
+ if (sunByHour !== null) {
319
+ if (typeof sunByHour === 'function') {
320
+ isSunlit = Number(sunByHour(h));
321
+ } else if (Array.isArray(sunByHour) && Number.isFinite(sunByHour[h])) {
322
+ isSunlit = Number(sunByHour[h]);
323
+ }
324
+ }
325
+
326
+ const skyTemp = skyTemperature(airTemp, ir);
327
+
328
+ const surfaceTemp = surfaceTemperature({
329
+ airTemp,
330
+ skyTemp,
331
+ svf,
332
+ sunlit: isSunlit,
333
+ dni,
334
+ dhi,
335
+ ghi,
336
+ elevationDeg,
337
+ windSpeed: ws,
338
+ material: mat,
339
+ treeShade: treeCrown ? 1 : 0,
340
+ lai
341
+ });
342
+
343
+ const mrt = meanRadiantTemperature({
344
+ airTemp,
345
+ skyTemp,
346
+ svf,
347
+ sunlit: isSunlit,
348
+ dni,
349
+ dhi,
350
+ ghi,
351
+ elevationDeg,
352
+ albedo: mat.albedo,
353
+ groundTemp: surfaceTemp,
354
+ wallTemp: airTemp
355
+ });
356
+
357
+ const utciVal = utci(airTemp, mrt, ws, rh);
358
+ const petVal = pet(airTemp, mrt, ws, rh);
359
+
360
+ profile.push({
361
+ hour: h,
362
+ airTemp,
363
+ surfaceTemp,
364
+ mrt,
365
+ utci: utciVal,
366
+ pet: petVal,
367
+ sunlit: isSunlit
368
+ });
369
+ }
370
+
371
+ return profile;
372
+ }
@@ -0,0 +1,153 @@
1
+ // @ts-check
2
+ // Urban morphology and heat-island indicators from a building-height grid
3
+ // (height above ground per cell, 0 = open).
4
+ //
5
+ // lambda_p plan area fraction lambda_f frontal area index (wind)
6
+ // H area-weighted mean height H/W canyon aspect ratio
7
+ // zd, z0 displacement height and roughness length, Macdonald, Griffiths
8
+ // and Hall (1998), Atmos. Environ. 32(11):1857-1864,
9
+ // doi:10.1016/S1352-2310(97)00403-2 (A = 4.43, beta = 1, Cd = 1.2)
10
+ // LCZ approximate Local Climate Zone (built types 1-6, 8, 9) from the
11
+ // property ranges of Stewart and Oke (2012), BAMS 93(12):1879-1900,
12
+ // doi:10.1175/BAMS-D-11-00019.1. Types 7 and 10 depend on materials
13
+ // and use, which geometry cannot show.
14
+
15
+ const KAPPA = 0.4;
16
+
17
+ /** Two-pass chamfer distance (metres) from every cell to the nearest built cell. */
18
+ export function distanceToBuilt(heights, cols, rows, cell) {
19
+ const INF = 1e9;
20
+ const d = new Float32Array(cols * rows);
21
+ for (let i = 0; i < d.length; i++) d[i] = heights[i] > 0 ? 0 : INF;
22
+ const a = cell, b = cell * Math.SQRT2;
23
+ for (let r = 0; r < rows; r++) {
24
+ for (let c = 0; c < cols; c++) {
25
+ const i = r * cols + c;
26
+ let v = d[i];
27
+ if (c > 0) v = Math.min(v, d[i - 1] + a);
28
+ if (r > 0) {
29
+ v = Math.min(v, d[i - cols] + a);
30
+ if (c > 0) v = Math.min(v, d[i - cols - 1] + b);
31
+ if (c < cols - 1) v = Math.min(v, d[i - cols + 1] + b);
32
+ }
33
+ d[i] = v;
34
+ }
35
+ }
36
+ for (let r = rows - 1; r >= 0; r--) {
37
+ for (let c = cols - 1; c >= 0; c--) {
38
+ const i = r * cols + c;
39
+ let v = d[i];
40
+ if (c < cols - 1) v = Math.min(v, d[i + 1] + a);
41
+ if (r < rows - 1) {
42
+ v = Math.min(v, d[i + cols] + a);
43
+ if (c < cols - 1) v = Math.min(v, d[i + cols + 1] + b);
44
+ if (c > 0) v = Math.min(v, d[i + cols - 1] + b);
45
+ }
46
+ d[i] = v;
47
+ }
48
+ }
49
+ return d;
50
+ }
51
+
52
+ /** Macdonald et al. (1998) displacement height and roughness length (metres). */
53
+ export function macdonald(lambdaP, lambdaF, meanHeight, { A = 4.43, beta = 1.0, Cd = 1.2 } = {}) {
54
+ if (!(meanHeight > 0) || !(lambdaP > 0)) return { zd: 0, z0: 0.03 };
55
+ const lp = Math.min(0.95, lambdaP);
56
+ const zdH = 1 + Math.pow(A, -lp) * (lp - 1);
57
+ const lf = Math.max(1e-4, lambdaF);
58
+ const z0H = (1 - zdH) * Math.exp(-Math.pow(0.5 * beta * (Cd / (KAPPA * KAPPA)) * (1 - zdH) * lf, -0.5));
59
+ return { zd: zdH * meanHeight, z0: Math.max(0.01, z0H * meanHeight) };
60
+ }
61
+
62
+ export const LCZ_LABELS = {
63
+ 1: 'LCZ 1 Compact high-rise', 2: 'LCZ 2 Compact midrise', 3: 'LCZ 3 Compact low-rise',
64
+ 4: 'LCZ 4 Open high-rise', 5: 'LCZ 5 Open midrise', 6: 'LCZ 6 Open low-rise',
65
+ 8: 'LCZ 8 Large low-rise', 9: 'LCZ 9 Sparsely built', 0: 'Open land (LCZ A-G)'
66
+ };
67
+ export const LCZ_COLORS = { 1: '#8c0000', 2: '#d10000', 3: '#ff0000', 4: '#bf4d00', 5: '#ff6600', 6: '#ff9955', 8: '#bcbcbc', 9: '#ffccaa', 0: '#6fbf73' };
68
+
69
+ /** Approximate built LCZ from plan fraction, mean height and mean footprint area. */
70
+ export function classifyLcz(lambdaP, meanHeight, meanFootprint = 0) {
71
+ if (!(lambdaP >= 0.1)) return lambdaP > 0.02 ? 9 : 0;
72
+ const tall = meanHeight >= 25 ? 'high' : meanHeight >= 10 ? 'mid' : 'low';
73
+ if (tall === 'low' && meanFootprint >= 1500 && lambdaP >= 0.3) return 8;
74
+ if (lambdaP < 0.2) return tall === 'low' ? 9 : tall === 'mid' ? 5 : 4;
75
+ if (lambdaP >= 0.4) return tall === 'high' ? 1 : tall === 'mid' ? 2 : 3;
76
+ return tall === 'high' ? 4 : tall === 'mid' ? 5 : 6;
77
+ }
78
+
79
+ /**
80
+ * Indicators per block of `blockCells` x `blockCells` grid cells.
81
+ * @param {object} p
82
+ * @param {Float32Array} p.heights building height above ground per cell
83
+ * @param {number} p.cols
84
+ * @param {number} p.rows
85
+ * @param {number} p.cell metres
86
+ * @param {number} p.blockCells cells per block side
87
+ * @param {number} [p.windFromDeg] wind direction (from), for lambda_f
88
+ * @param {Float32Array|null} [p.svf] optional sky view factor per cell
89
+ * @param {Int32Array|null} [p.featureIds] building id per cell (0 = none), for mean footprint
90
+ */
91
+ export function blockIndicators({ heights, cols, rows, cell, blockCells, windFromDeg = 270, svf = null, featureIds = null }) {
92
+ const bc = Math.ceil(cols / blockCells);
93
+ const br = Math.ceil(rows / blockCells);
94
+ const nb = bc * br;
95
+ const f32 = () => new Float32Array(nb);
96
+ const out = { cols: bc, rows: br, cell: cell * blockCells,
97
+ lambdaP: f32(), lambdaF: f32(), meanHeight: f32(), maxHeight: f32(), heightStd: f32(),
98
+ hw: f32(), zd: f32(), z0: f32(), svf: f32(), lcz: new Uint8Array(nb), volumeDensity: f32() };
99
+ // Wind blows towards (sin, cos) of the "to" bearing; windward faces of a
100
+ // cell point upwind.
101
+ const toRad = ((windFromDeg + 180) % 360) * Math.PI / 180;
102
+ const ux = Math.sin(toRad);
103
+ const uy = Math.cos(toRad); // +y north
104
+ const dist = distanceToBuilt(heights, cols, rows, cell);
105
+ const footprintCells = new Map();
106
+ if (featureIds) for (const id of featureIds) if (id > 0) footprintCells.set(id, (footprintCells.get(id) || 0) + 1);
107
+ const acc = Array.from({ length: nb }, () => ({ n: 0, built: 0, hSum: 0, h2Sum: 0, hMax: 0, frontal: 0, openD: 0, openN: 0, svfSum: 0, svfN: 0, ids: new Set() }));
108
+ for (let r = 0; r < rows; r++) {
109
+ for (let c = 0; c < cols; c++) {
110
+ const i = r * cols + c;
111
+ const a = acc[Math.floor(r / blockCells) * bc + Math.floor(c / blockCells)];
112
+ const h = heights[i];
113
+ a.n++;
114
+ if (h > 0) {
115
+ a.built++; a.hSum += h; a.h2Sum += h * h; if (h > a.hMax) a.hMax = h;
116
+ if (featureIds && featureIds[i] > 0) a.ids.add(featureIds[i]);
117
+ // Windward faces: neighbour on the upwind side lower than this cell.
118
+ // x faces (east/west neighbours) project with |ux|, y faces with |uy|.
119
+ const upX = ux > 0 ? c - 1 : c + 1;
120
+ if (upX >= 0 && upX < cols) a.frontal += Math.max(0, h - heights[r * cols + upX]) * cell * Math.abs(ux);
121
+ const upY = uy > 0 ? r + 1 : r - 1; // rows run north->south: upwind of a northward wind is south (r+1)
122
+ if (upY >= 0 && upY < rows) a.frontal += Math.max(0, h - heights[upY * cols + c]) * cell * Math.abs(uy);
123
+ } else {
124
+ a.openD += dist[i]; a.openN++;
125
+ }
126
+ if (svf && Number.isFinite(svf[i])) { a.svfSum += svf[i]; a.svfN++; }
127
+ }
128
+ }
129
+ for (let k = 0; k < nb; k++) {
130
+ const a = acc[k];
131
+ const plan = a.n * cell * cell;
132
+ const lp = a.n ? a.built / a.n : 0;
133
+ const H = a.built ? a.hSum / a.built : 0;
134
+ out.lambdaP[k] = lp;
135
+ out.meanHeight[k] = H;
136
+ out.maxHeight[k] = a.hMax;
137
+ out.heightStd[k] = a.built ? Math.sqrt(Math.max(0, a.h2Sum / a.built - H * H)) : 0;
138
+ out.lambdaF[k] = plan ? a.frontal / plan : 0;
139
+ out.volumeDensity[k] = plan ? (a.hSum * cell * cell) / plan : 0;
140
+ // Mean distance from open ground to the nearest building is half a
141
+ // typical gap, so the canyon width W ~ 2 x that mean.
142
+ const W = a.openN ? 2 * (a.openD / a.openN) : 0;
143
+ out.hw[k] = W > 0 && H > 0 ? H / W : (H > 0 ? 3 : 0);
144
+ const { zd, z0 } = macdonald(lp, out.lambdaF[k], H);
145
+ out.zd[k] = zd;
146
+ out.z0[k] = z0;
147
+ out.svf[k] = a.svfN ? a.svfSum / a.svfN : NaN;
148
+ let fp = 0;
149
+ if (a.ids.size) { let s = 0; for (const id of a.ids) s += (footprintCells.get(id) || 0) * cell * cell; fp = s / a.ids.size; }
150
+ out.lcz[k] = classifyLcz(lp, H, fp);
151
+ }
152
+ return out;
153
+ }