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