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,316 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Pedestrian Cool Walkability Index (PCWI) and A* thermally-optimal route
|
|
3
|
+
// finder across the open-ground analysis grid.
|
|
4
|
+
//
|
|
5
|
+
// Combines thermal comfort (UTCI), solar shade, Green View Index (Konijnendijk
|
|
6
|
+
// 3-30-300 rule), road traffic noise (WHO 53/65 dB thresholds) and terrain
|
|
7
|
+
// slope into a 0-100 pedestrian quality index, and compares the shortest
|
|
8
|
+
// obstacle-free route against the coolest / highest-comfort route.
|
|
9
|
+
|
|
10
|
+
export const WALKABILITY_CLASSES = [
|
|
11
|
+
{ min: 75, max: 101, label: 'Excellent (cool, shaded & quiet)', color: '#1d8f5f' },
|
|
12
|
+
{ min: 55, max: 75, label: 'Good pedestrian comfort', color: '#6dbf67' },
|
|
13
|
+
{ min: 35, max: 55, label: 'Moderate (exposed or noisy)', color: '#f0b429' },
|
|
14
|
+
{ min: 0, max: 35, label: 'Poor (heat stress / high noise / steep)', color: '#d64545' }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export function walkabilityClass(score) {
|
|
18
|
+
if (!Number.isFinite(score)) return null;
|
|
19
|
+
const s = Math.max(0, Math.min(100, score));
|
|
20
|
+
return WALKABILITY_CLASSES.find((c) => s >= c.min && s < c.max) || WALKABILITY_CLASSES[WALKABILITY_CLASSES.length - 1];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function thermalSubscore(utciC) {
|
|
24
|
+
if (!Number.isFinite(utciC)) return NaN;
|
|
25
|
+
if (utciC >= 9 && utciC <= 26) return 100;
|
|
26
|
+
if (utciC > 26) return Math.max(0, Math.min(100, 100 * (1 - (utciC - 26) / 16)));
|
|
27
|
+
return Math.max(0, Math.min(100, 100 * (1 - (9 - utciC) / 19)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function shadeSubscore(shadeVal) {
|
|
31
|
+
if (!Number.isFinite(shadeVal)) return NaN;
|
|
32
|
+
const frac = shadeVal > 1.01 ? shadeVal / 100 : shadeVal;
|
|
33
|
+
return Math.max(0, Math.min(100, frac * 100));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function greenSubscore(gviPct) {
|
|
37
|
+
if (!Number.isFinite(gviPct)) return NaN;
|
|
38
|
+
return Math.max(0, Math.min(100, (gviPct / 30) * 100));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function quietSubscore(noiseDba) {
|
|
42
|
+
if (!Number.isFinite(noiseDba)) return NaN;
|
|
43
|
+
if (noiseDba <= 50) return 100;
|
|
44
|
+
if (noiseDba >= 75) return 0;
|
|
45
|
+
return 100 * (1 - (noiseDba - 50) / 25);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function slopeSubscore(slopeDeg) {
|
|
49
|
+
if (!Number.isFinite(slopeDeg)) return NaN;
|
|
50
|
+
if (slopeDeg <= 2) return 100;
|
|
51
|
+
if (slopeDeg >= 12) return 0;
|
|
52
|
+
return 100 * (1 - (slopeDeg - 2) / 10);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compute a 0-100 Pedestrian Cool Walkability Index on open ground.
|
|
57
|
+
*/
|
|
58
|
+
export function pedestrianWalkabilityIndex({
|
|
59
|
+
ids = null,
|
|
60
|
+
utciGrid = null,
|
|
61
|
+
shadeGrid = null,
|
|
62
|
+
gviGrid = null,
|
|
63
|
+
noiseGrid = null,
|
|
64
|
+
slopeDegGrid = null,
|
|
65
|
+
cols,
|
|
66
|
+
rows,
|
|
67
|
+
weights = {}
|
|
68
|
+
}) {
|
|
69
|
+
const n = cols * rows;
|
|
70
|
+
const out = new Float32Array(n).fill(NaN);
|
|
71
|
+
const wThermal = Math.max(0, weights.thermal ?? 0.35);
|
|
72
|
+
const wShade = Math.max(0, weights.shade ?? 0.25);
|
|
73
|
+
const wGreen = Math.max(0, weights.green ?? 0.15);
|
|
74
|
+
const wQuiet = Math.max(0, weights.quiet ?? 0.15);
|
|
75
|
+
const wSlope = Math.max(0, weights.slope ?? 0.10);
|
|
76
|
+
|
|
77
|
+
for (let i = 0; i < n; i++) {
|
|
78
|
+
if (ids && ids[i] !== 0) continue;
|
|
79
|
+
let sum = 0;
|
|
80
|
+
let wSum = 0;
|
|
81
|
+
if (utciGrid) {
|
|
82
|
+
const s = thermalSubscore(utciGrid[i]);
|
|
83
|
+
if (Number.isFinite(s)) { sum += wThermal * s; wSum += wThermal; }
|
|
84
|
+
}
|
|
85
|
+
if (shadeGrid) {
|
|
86
|
+
const s = shadeSubscore(shadeGrid[i]);
|
|
87
|
+
if (Number.isFinite(s)) { sum += wShade * s; wSum += wShade; }
|
|
88
|
+
}
|
|
89
|
+
if (gviGrid) {
|
|
90
|
+
const s = greenSubscore(gviGrid[i]);
|
|
91
|
+
if (Number.isFinite(s)) { sum += wGreen * s; wSum += wGreen; }
|
|
92
|
+
}
|
|
93
|
+
if (noiseGrid) {
|
|
94
|
+
const s = quietSubscore(noiseGrid[i]);
|
|
95
|
+
if (Number.isFinite(s)) { sum += wQuiet * s; wSum += wQuiet; }
|
|
96
|
+
}
|
|
97
|
+
if (slopeDegGrid) {
|
|
98
|
+
const s = slopeSubscore(slopeDegGrid[i]);
|
|
99
|
+
if (Number.isFinite(s)) { sum += wSlope * s; wSum += wSlope; }
|
|
100
|
+
}
|
|
101
|
+
if (wSum > 0) out[i] = Math.max(0, Math.min(100, sum / wSum));
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function nearestWalkableCell(ids, walkScore, cols, rows, cell, minX, maxY, xy) {
|
|
107
|
+
const c0 = Math.max(0, Math.min(cols - 1, Math.round((xy[0] - minX) / cell)));
|
|
108
|
+
const r0 = Math.max(0, Math.min(rows - 1, Math.round((maxY - xy[1]) / cell)));
|
|
109
|
+
const isOk = (idx) => (!ids || ids[idx] === 0) && (!walkScore || Number.isFinite(walkScore[idx]));
|
|
110
|
+
if (isOk(r0 * cols + c0)) return r0 * cols + c0;
|
|
111
|
+
const maxRad = Math.max(cols, rows);
|
|
112
|
+
for (let rad = 1; rad <= maxRad; rad++) {
|
|
113
|
+
let best = -1;
|
|
114
|
+
let bestDist = Infinity;
|
|
115
|
+
const rMin = Math.max(0, r0 - rad), rMax = Math.min(rows - 1, r0 + rad);
|
|
116
|
+
const cMin = Math.max(0, c0 - rad), cMax = Math.min(cols - 1, c0 + rad);
|
|
117
|
+
for (let r = rMin; r <= rMax; r++) {
|
|
118
|
+
for (let c = cMin; c <= cMax; c++) {
|
|
119
|
+
if (Math.abs(r - r0) !== rad && Math.abs(c - c0) !== rad) continue;
|
|
120
|
+
const idx = r * cols + c;
|
|
121
|
+
if (!isOk(idx)) continue;
|
|
122
|
+
const d = (r - r0) * (r - r0) + (c - c0) * (c - c0);
|
|
123
|
+
if (d < bestDist) { bestDist = d; best = idx; }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (best >= 0) return best;
|
|
127
|
+
}
|
|
128
|
+
return -1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function aStarPath(cols, rows, startIdx, endIdx, passableFn, stepPenaltyFn) {
|
|
132
|
+
const n = cols * rows;
|
|
133
|
+
const gScore = new Float64Array(n).fill(Infinity);
|
|
134
|
+
const prev = new Int32Array(n).fill(-1);
|
|
135
|
+
const closed = new Uint8Array(n);
|
|
136
|
+
const endR = Math.floor(endIdx / cols), endC = endIdx % cols;
|
|
137
|
+
|
|
138
|
+
// Binary min-heap of (fScore, cellIndex)
|
|
139
|
+
const heapF = [];
|
|
140
|
+
const heapIdx = [];
|
|
141
|
+
const push = (f, idx) => {
|
|
142
|
+
let i = heapF.length;
|
|
143
|
+
heapF.push(f);
|
|
144
|
+
heapIdx.push(idx);
|
|
145
|
+
while (i > 0) {
|
|
146
|
+
const p = (i - 1) >> 1;
|
|
147
|
+
if (heapF[p] <= f) break;
|
|
148
|
+
heapF[i] = heapF[p]; heapIdx[i] = heapIdx[p];
|
|
149
|
+
i = p;
|
|
150
|
+
}
|
|
151
|
+
heapF[i] = f; heapIdx[i] = idx;
|
|
152
|
+
};
|
|
153
|
+
const pop = () => {
|
|
154
|
+
const topIdx = heapIdx[0];
|
|
155
|
+
const lastF = heapF.pop();
|
|
156
|
+
const lastIdx = heapIdx.pop();
|
|
157
|
+
if (heapF.length > 0 && lastF !== undefined && lastIdx !== undefined) {
|
|
158
|
+
let i = 0;
|
|
159
|
+
const half = heapF.length >> 1;
|
|
160
|
+
while (i < half) {
|
|
161
|
+
let l = (i << 1) + 1;
|
|
162
|
+
const r = l + 1;
|
|
163
|
+
if (r < heapF.length && heapF[r] < heapF[l]) l = r;
|
|
164
|
+
if (lastF <= heapF[l]) break;
|
|
165
|
+
heapF[i] = heapF[l]; heapIdx[i] = heapIdx[l];
|
|
166
|
+
i = l;
|
|
167
|
+
}
|
|
168
|
+
heapF[i] = lastF; heapIdx[i] = lastIdx;
|
|
169
|
+
}
|
|
170
|
+
return topIdx;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
gScore[startIdx] = 0;
|
|
174
|
+
push(Math.hypot(Math.floor(startIdx / cols) - endR, (startIdx % cols) - endC), startIdx);
|
|
175
|
+
|
|
176
|
+
const dr = [-1, -1, -1, 0, 0, 1, 1, 1];
|
|
177
|
+
const dc = [-1, 0, 1, -1, 1, -1, 0, 1];
|
|
178
|
+
const dLen = [Math.SQRT2, 1, Math.SQRT2, 1, 1, Math.SQRT2, 1, Math.SQRT2];
|
|
179
|
+
|
|
180
|
+
while (heapF.length > 0) {
|
|
181
|
+
const curr = pop();
|
|
182
|
+
if (curr === endIdx) break;
|
|
183
|
+
if (closed[curr]) continue;
|
|
184
|
+
closed[curr] = 1;
|
|
185
|
+
const r = Math.floor(curr / cols), c = curr % cols;
|
|
186
|
+
const baseG = gScore[curr];
|
|
187
|
+
for (let k = 0; k < 8; k++) {
|
|
188
|
+
const nr = r + dr[k], nc = c + dc[k];
|
|
189
|
+
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
|
|
190
|
+
const next = nr * cols + nc;
|
|
191
|
+
if (closed[next] || !passableFn(next)) continue;
|
|
192
|
+
// Prevent corner-cutting through diagonal building cells
|
|
193
|
+
if (dr[k] !== 0 && dc[k] !== 0) {
|
|
194
|
+
if (!passableFn(r * cols + nc) || !passableFn(nr * cols + c)) continue;
|
|
195
|
+
}
|
|
196
|
+
const stepCost = dLen[k] * stepPenaltyFn(next);
|
|
197
|
+
const candG = baseG + stepCost;
|
|
198
|
+
if (candG < gScore[next]) {
|
|
199
|
+
gScore[next] = candG;
|
|
200
|
+
prev[next] = curr;
|
|
201
|
+
const h = Math.hypot(nr - endR, nc - endC);
|
|
202
|
+
push(candG + h, next);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!Number.isFinite(gScore[endIdx])) return null;
|
|
208
|
+
const cells = [];
|
|
209
|
+
let cur = endIdx;
|
|
210
|
+
while (cur >= 0) {
|
|
211
|
+
cells.push(cur);
|
|
212
|
+
if (cur === startIdx) break;
|
|
213
|
+
cur = prev[cur];
|
|
214
|
+
}
|
|
215
|
+
cells.reverse();
|
|
216
|
+
return cells;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function summarizeRouteCells(cells, { routeType, label, color, cols, cell, minX, maxY, walkScore, utciGrid, shadeGrid, gviGrid, noiseGrid }) {
|
|
220
|
+
const coords = cells.map((idx) => {
|
|
221
|
+
const r = Math.floor(idx / cols), c = idx % cols;
|
|
222
|
+
return [minX + c * cell, maxY - r * cell];
|
|
223
|
+
});
|
|
224
|
+
let lengthM = 0;
|
|
225
|
+
for (let i = 1; i < coords.length; i++) {
|
|
226
|
+
lengthM += Math.hypot(coords[i][0] - coords[i - 1][0], coords[i][1] - coords[i - 1][1]);
|
|
227
|
+
}
|
|
228
|
+
const meanOf = (arr) => {
|
|
229
|
+
if (!arr) return NaN;
|
|
230
|
+
let s = 0, cnt = 0;
|
|
231
|
+
for (const idx of cells) {
|
|
232
|
+
const v = arr[idx];
|
|
233
|
+
if (Number.isFinite(v)) { s += v; cnt++; }
|
|
234
|
+
}
|
|
235
|
+
return cnt > 0 ? s / cnt : NaN;
|
|
236
|
+
};
|
|
237
|
+
let shadePct = NaN;
|
|
238
|
+
if (shadeGrid) {
|
|
239
|
+
let s = 0, cnt = 0;
|
|
240
|
+
for (const idx of cells) {
|
|
241
|
+
const v = shadeGrid[idx];
|
|
242
|
+
if (Number.isFinite(v)) {
|
|
243
|
+
s += v > 1.01 ? v : v * 100;
|
|
244
|
+
cnt++;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (cnt > 0) shadePct = s / cnt;
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
route_type: routeType,
|
|
251
|
+
label,
|
|
252
|
+
color,
|
|
253
|
+
cells,
|
|
254
|
+
coords,
|
|
255
|
+
length_m: lengthM,
|
|
256
|
+
mean_utci_c: meanOf(utciGrid),
|
|
257
|
+
shade_pct: shadePct,
|
|
258
|
+
mean_gvi_pct: meanOf(gviGrid),
|
|
259
|
+
mean_noise_dba: meanOf(noiseGrid),
|
|
260
|
+
walk_score: meanOf(walkScore)
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Find and compare the shortest obstacle-free pedestrian route vs. the
|
|
266
|
+
* thermally/environmentally optimal (coolest & shaded) route.
|
|
267
|
+
*/
|
|
268
|
+
export function findCoolRoute({
|
|
269
|
+
ids = null,
|
|
270
|
+
walkScore,
|
|
271
|
+
utciGrid = null,
|
|
272
|
+
shadeGrid = null,
|
|
273
|
+
gviGrid = null,
|
|
274
|
+
noiseGrid = null,
|
|
275
|
+
cols,
|
|
276
|
+
rows,
|
|
277
|
+
cell,
|
|
278
|
+
minX,
|
|
279
|
+
maxY,
|
|
280
|
+
startXY,
|
|
281
|
+
endXY,
|
|
282
|
+
coolBias = 2.8
|
|
283
|
+
}) {
|
|
284
|
+
const startIdx = nearestWalkableCell(ids, walkScore, cols, rows, cell, minX, maxY, startXY);
|
|
285
|
+
const endIdx = nearestWalkableCell(ids, walkScore, cols, rows, cell, minX, maxY, endXY);
|
|
286
|
+
if (startIdx < 0 || endIdx < 0) return null;
|
|
287
|
+
const passable = (idx) => (!ids || ids[idx] === 0) && (!walkScore || Number.isFinite(walkScore[idx]));
|
|
288
|
+
|
|
289
|
+
const shortestCells = aStarPath(cols, rows, startIdx, endIdx, passable, () => 1.0);
|
|
290
|
+
if (!shortestCells) return null;
|
|
291
|
+
|
|
292
|
+
const bias = Math.max(0.2, coolBias);
|
|
293
|
+
const coolestCells = aStarPath(cols, rows, startIdx, endIdx, passable, (idx) => {
|
|
294
|
+
const sc = walkScore && Number.isFinite(walkScore[idx]) ? walkScore[idx] : 50;
|
|
295
|
+
const discomfort = Math.max(0, Math.min(1, (100 - sc) / 100));
|
|
296
|
+
return 1 + bias * Math.pow(discomfort, 1.5);
|
|
297
|
+
}) || shortestCells;
|
|
298
|
+
|
|
299
|
+
const meta = { cols, cell, minX, maxY, walkScore, utciGrid, shadeGrid, gviGrid, noiseGrid };
|
|
300
|
+
const shortest = summarizeRouteCells(shortestCells, {
|
|
301
|
+
routeType: 'shortest', label: 'Shortest path', color: '#e4572e', ...meta
|
|
302
|
+
});
|
|
303
|
+
const coolest = summarizeRouteCells(coolestCells, {
|
|
304
|
+
routeType: 'coolest', label: 'Coolest & shaded path', color: '#1d8f5f', ...meta
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
const detourPct = shortest.length_m > 0 ? ((coolest.length_m - shortest.length_m) / shortest.length_m) * 100 : 0;
|
|
308
|
+
const utciDeltaC = Number.isFinite(coolest.mean_utci_c) && Number.isFinite(shortest.mean_utci_c)
|
|
309
|
+
? coolest.mean_utci_c - shortest.mean_utci_c : NaN;
|
|
310
|
+
const shadeGainPct = Number.isFinite(coolest.shade_pct) && Number.isFinite(shortest.shade_pct)
|
|
311
|
+
? coolest.shade_pct - shortest.shade_pct : NaN;
|
|
312
|
+
const walkScoreGain = Number.isFinite(coolest.walk_score) && Number.isFinite(shortest.walk_score)
|
|
313
|
+
? coolest.walk_score - shortest.walk_score : NaN;
|
|
314
|
+
|
|
315
|
+
return { shortest, coolest, detourPct, utciDeltaC, shadeGainPct, walkScoreGain };
|
|
316
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Weather: EnergyPlus EPW parsing, monthly summaries, wind rose, annual
|
|
3
|
+
// sun-direction bins for radiation, and a clear-sky year when no EPW exists.
|
|
4
|
+
import { solarPosition, sunVector } from './solar.js';
|
|
5
|
+
|
|
6
|
+
const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
7
|
+
const MONTH_START = MONTH_DAYS.reduce((acc, d, i) => { acc.push(i ? acc[i - 1] + MONTH_DAYS[i - 1] : 0); return acc; }, []);
|
|
8
|
+
export const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
9
|
+
|
|
10
|
+
/** Day of year in a 365-day typical year. */
|
|
11
|
+
export function typicalDoy(month, day) {
|
|
12
|
+
return MONTH_START[month - 1] + day;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Parse EPW text.
|
|
17
|
+
* @param {string} text
|
|
18
|
+
* @returns {{ location: object, hours: number,
|
|
19
|
+
* month: Uint8Array, day: Uint8Array, hour: Uint8Array,
|
|
20
|
+
* dryBulb: Float32Array, dewPoint: Float32Array, rh: Float32Array, pressure: Float32Array,
|
|
21
|
+
* ir: Float32Array, ghi: Float32Array, dni: Float32Array, dhi: Float32Array,
|
|
22
|
+
* windDir: Float32Array, windSpeed: Float32Array, skyCover: Float32Array }}
|
|
23
|
+
*/
|
|
24
|
+
export function parseEpw(text) {
|
|
25
|
+
const lines = text.split(/\r?\n/);
|
|
26
|
+
const loc = lines[0].split(',').map((s) => s.trim());
|
|
27
|
+
if (loc[0]?.toUpperCase() !== 'LOCATION' || loc.length < 10) throw new Error('Not an EPW file (no LOCATION line).');
|
|
28
|
+
const location = {
|
|
29
|
+
city: loc[1], region: loc[2], country: loc[3], source: loc[4], wmo: loc[5],
|
|
30
|
+
lat: Number(loc[6]), lon: Number(loc[7]), utcOffset: Number(loc[8]), elevation: Number(loc[9])
|
|
31
|
+
};
|
|
32
|
+
const rows = [];
|
|
33
|
+
for (let i = 8; i < lines.length; i++) {
|
|
34
|
+
const line = lines[i];
|
|
35
|
+
if (!line || !/^\s*-?\d/.test(line)) continue;
|
|
36
|
+
rows.push(line.split(','));
|
|
37
|
+
}
|
|
38
|
+
if (rows.length < 8760) throw new Error(`EPW has ${rows.length} hourly records; 8760 are required.`);
|
|
39
|
+
const n = 8760;
|
|
40
|
+
const f32 = () => new Float32Array(n);
|
|
41
|
+
const data = {
|
|
42
|
+
location, hours: n,
|
|
43
|
+
month: new Uint8Array(n), day: new Uint8Array(n), hour: new Uint8Array(n),
|
|
44
|
+
dryBulb: f32(), dewPoint: f32(), rh: f32(), pressure: f32(), ir: f32(),
|
|
45
|
+
ghi: f32(), dni: f32(), dhi: f32(), windDir: f32(), windSpeed: f32(), skyCover: f32()
|
|
46
|
+
};
|
|
47
|
+
// Leap-year files carry Feb 29: drop it so the year has 8760 hours.
|
|
48
|
+
let k = 0;
|
|
49
|
+
for (const r of rows) {
|
|
50
|
+
if (k >= n) break;
|
|
51
|
+
const m = Number(r[1]);
|
|
52
|
+
const d = Number(r[2]);
|
|
53
|
+
if (m === 2 && d === 29) continue;
|
|
54
|
+
data.month[k] = m;
|
|
55
|
+
data.day[k] = d;
|
|
56
|
+
data.hour[k] = Number(r[3]);
|
|
57
|
+
data.dryBulb[k] = num(r[6], 99.9, 20);
|
|
58
|
+
data.dewPoint[k] = num(r[7], 99.9, 10);
|
|
59
|
+
data.rh[k] = num(r[8], 999, 50);
|
|
60
|
+
data.pressure[k] = num(r[9], 999999, 101325);
|
|
61
|
+
data.ir[k] = num(r[12], 9999, NaN);
|
|
62
|
+
data.ghi[k] = Math.max(0, num(r[13], 9999, 0));
|
|
63
|
+
data.dni[k] = Math.max(0, num(r[14], 9999, 0));
|
|
64
|
+
data.dhi[k] = Math.max(0, num(r[15], 9999, 0));
|
|
65
|
+
data.windDir[k] = num(r[20], 999, 0);
|
|
66
|
+
data.windSpeed[k] = Math.max(0, num(r[21], 999, 0));
|
|
67
|
+
data.skyCover[k] = num(r[22], 99, 5);
|
|
68
|
+
k++;
|
|
69
|
+
}
|
|
70
|
+
if (k < n) throw new Error('EPW ended before 8760 hours.');
|
|
71
|
+
return data;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function num(text, missing, fallback) {
|
|
75
|
+
const v = Number(text);
|
|
76
|
+
return Number.isFinite(v) && v < missing ? v : fallback;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Index of the EPW record for a month, day and clock hour (1..24 = hour ending). */
|
|
80
|
+
export function hourIndex(month, day, hourEnding) {
|
|
81
|
+
return (typicalDoy(month, day) - 1) * 24 + (hourEnding - 1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Monthly means and sums for charts. */
|
|
85
|
+
export function monthlySummary(w) {
|
|
86
|
+
const out = MONTH_NAMES.map((name) => ({ name, tMean: 0, tMin: Infinity, tMax: -Infinity, rh: 0, ghiKwh: 0, wind: 0, n: 0 }));
|
|
87
|
+
for (let i = 0; i < w.hours; i++) {
|
|
88
|
+
const m = out[w.month[i] - 1];
|
|
89
|
+
const t = w.dryBulb[i];
|
|
90
|
+
m.tMean += t; m.rh += w.rh[i]; m.wind += w.windSpeed[i]; m.ghiKwh += w.ghi[i] / 1000; m.n++;
|
|
91
|
+
if (t < m.tMin) m.tMin = t;
|
|
92
|
+
if (t > m.tMax) m.tMax = t;
|
|
93
|
+
}
|
|
94
|
+
for (const m of out) {
|
|
95
|
+
if (!m.n) continue;
|
|
96
|
+
m.tMean /= m.n; m.rh /= m.n; m.wind /= m.n;
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Wind rose: frequency (fraction of hours) per direction sector and speed
|
|
103
|
+
* class. Calm hours (< 0.5 m/s) are counted separately.
|
|
104
|
+
*/
|
|
105
|
+
export function windRose(w, { sectors = 16, classes = [0.5, 2, 4, 6, 8, 11], months = null, hours = null } = {}) {
|
|
106
|
+
const table = Array.from({ length: sectors }, () => new Array(classes.length).fill(0));
|
|
107
|
+
let calm = 0;
|
|
108
|
+
let total = 0;
|
|
109
|
+
let speedSum = 0;
|
|
110
|
+
for (let i = 0; i < w.hours; i++) {
|
|
111
|
+
if (months && !months.includes(w.month[i])) continue;
|
|
112
|
+
if (hours && !hours.includes(w.hour[i])) continue;
|
|
113
|
+
const v = w.windSpeed[i];
|
|
114
|
+
total++;
|
|
115
|
+
speedSum += v;
|
|
116
|
+
if (v < classes[0]) { calm++; continue; }
|
|
117
|
+
const s = Math.floor((((w.windDir[i] % 360) + 360) % 360) / (360 / sectors) + 0.5) % sectors;
|
|
118
|
+
let c = classes.length - 1;
|
|
119
|
+
for (let j = 1; j < classes.length; j++) if (v < classes[j]) { c = j - 1; break; }
|
|
120
|
+
table[s][c]++;
|
|
121
|
+
}
|
|
122
|
+
const freq = table.map((row) => row.map((x) => (total ? x / total : 0)));
|
|
123
|
+
const sectorFreq = freq.map((row) => row.reduce((a, b) => a + b, 0));
|
|
124
|
+
let prevailing = 0;
|
|
125
|
+
sectorFreq.forEach((f, i) => { if (f > sectorFreq[prevailing]) prevailing = i; });
|
|
126
|
+
// Mean speed of the prevailing sector (non-calm hours).
|
|
127
|
+
let pSum = 0;
|
|
128
|
+
let pN = 0;
|
|
129
|
+
for (let i = 0; i < w.hours; i++) {
|
|
130
|
+
if (months && !months.includes(w.month[i])) continue;
|
|
131
|
+
if (hours && !hours.includes(w.hour[i])) continue;
|
|
132
|
+
const v = w.windSpeed[i];
|
|
133
|
+
if (v < classes[0]) continue;
|
|
134
|
+
const s = Math.floor((((w.windDir[i] % 360) + 360) % 360) / (360 / sectors) + 0.5) % sectors;
|
|
135
|
+
if (s === prevailing) { pSum += v; pN++; }
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
sectors, classes, freq, sectorFreq, calm: total ? calm / total : 0,
|
|
139
|
+
meanSpeed: total ? speedSum / total : 0,
|
|
140
|
+
prevailingDirection: prevailing * (360 / sectors),
|
|
141
|
+
prevailingSpeed: pN ? pSum / pN : 0
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ASHRAE clear-sky model constants per month (A in W/m2, B, C; ASHRAE
|
|
146
|
+
// Handbook of Fundamentals, 1985): DNI = A exp(-B / sin(altitude)), DHI = C DNI.
|
|
147
|
+
const ASHRAE_CLEAR = [
|
|
148
|
+
[1230, 0.142, 0.058], [1215, 0.144, 0.060], [1186, 0.156, 0.071], [1136, 0.180, 0.097],
|
|
149
|
+
[1104, 0.196, 0.121], [1088, 0.205, 0.134], [1085, 0.207, 0.136], [1107, 0.201, 0.122],
|
|
150
|
+
[1151, 0.177, 0.092], [1192, 0.160, 0.073], [1221, 0.149, 0.063], [1233, 0.142, 0.057]
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
/** Hourly clear-sky DNI/DHI/GHI (Wh/m2 per hour) for a site: an upper bound with no clouds. */
|
|
154
|
+
export function clearSkyYear(lat, lon, utcOffset) {
|
|
155
|
+
const n = 8760;
|
|
156
|
+
const w = { location: { lat, lon, utcOffset, city: 'Clear sky', clearSky: true }, hours: n,
|
|
157
|
+
month: new Uint8Array(n), day: new Uint8Array(n), hour: new Uint8Array(n),
|
|
158
|
+
ghi: new Float32Array(n), dni: new Float32Array(n), dhi: new Float32Array(n) };
|
|
159
|
+
let k = 0;
|
|
160
|
+
for (let m = 1; m <= 12; m++) {
|
|
161
|
+
for (let d = 1; d <= MONTH_DAYS[m - 1]; d++) {
|
|
162
|
+
for (let h = 1; h <= 24; h++) {
|
|
163
|
+
w.month[k] = m; w.day[k] = d; w.hour[k] = h;
|
|
164
|
+
const p = solarPosition(typicalDoy(m, d), h - 0.5, lat, lon, utcOffset);
|
|
165
|
+
if (p.elevation > 0.0175) {
|
|
166
|
+
const [A, B, C] = ASHRAE_CLEAR[m - 1];
|
|
167
|
+
const s = Math.sin(p.elevation);
|
|
168
|
+
const dni = A * Math.exp(-B / s);
|
|
169
|
+
w.dni[k] = dni;
|
|
170
|
+
w.dhi[k] = C * dni;
|
|
171
|
+
w.ghi[k] = dni * s + C * dni;
|
|
172
|
+
}
|
|
173
|
+
k++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return w;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Bin the year's direct-beam hours into sky patches. Each patch becomes one
|
|
182
|
+
* direction (the DNI-weighted mean sun vector of its hours) with the patch's
|
|
183
|
+
* DNI sum in Wh/m2 as weight. Also returns DHI and GHI sums for the diffuse
|
|
184
|
+
* and ground-reflected terms.
|
|
185
|
+
* @param {any} w weather (EPW or clear sky)
|
|
186
|
+
* @param {{ lat: number, lon: number, utcOffset: number, months?: number[]|null, altStepDeg?: number, azStepDeg?: number }} site
|
|
187
|
+
*/
|
|
188
|
+
export function annualSunBins(w, { lat, lon, utcOffset, months = null, altStepDeg = 5, azStepDeg = 7.5 }) {
|
|
189
|
+
const bins = new Map();
|
|
190
|
+
let dhiSum = 0;
|
|
191
|
+
let ghiSum = 0;
|
|
192
|
+
let dniSum = 0;
|
|
193
|
+
for (let i = 0; i < w.hours; i++) {
|
|
194
|
+
if (months && !months.includes(w.month[i])) continue;
|
|
195
|
+
dhiSum += w.dhi[i];
|
|
196
|
+
ghiSum += w.ghi[i];
|
|
197
|
+
const dni = w.dni[i];
|
|
198
|
+
if (dni <= 0) continue;
|
|
199
|
+
const p = solarPosition(typicalDoy(w.month[i], w.day[i]), w.hour[i] - 0.5, lat, lon, utcOffset);
|
|
200
|
+
if (p.elevation <= 0.0087) continue; // below 0.5 degrees
|
|
201
|
+
dniSum += dni;
|
|
202
|
+
const altBin = Math.floor(p.elevation / (altStepDeg * Math.PI / 180));
|
|
203
|
+
const azBin = Math.floor(p.azimuth / (azStepDeg * Math.PI / 180));
|
|
204
|
+
const key = altBin * 1000 + azBin;
|
|
205
|
+
const v = sunVector(p.elevation, p.azimuth);
|
|
206
|
+
let b = bins.get(key);
|
|
207
|
+
if (!b) { b = { x: 0, y: 0, z: 0, weight: 0 }; bins.set(key, b); }
|
|
208
|
+
b.x += v[0] * dni; b.y += v[1] * dni; b.z += v[2] * dni; b.weight += dni;
|
|
209
|
+
}
|
|
210
|
+
const directions = [];
|
|
211
|
+
for (const b of bins.values()) {
|
|
212
|
+
const len = Math.hypot(b.x, b.y, b.z) || 1;
|
|
213
|
+
directions.push({ dir: [b.x / len, b.y / len, b.z / len], weight: b.weight });
|
|
214
|
+
}
|
|
215
|
+
return { directions, dhiSum, ghiSum, dniSum };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Hourly conditions for comfort analysis. */
|
|
219
|
+
export function conditionsAt(w, month, day, hourEnding) {
|
|
220
|
+
const i = hourIndex(month, day, hourEnding);
|
|
221
|
+
return {
|
|
222
|
+
airTemp: w.dryBulb?.[i], rh: w.rh?.[i], windSpeed: w.windSpeed?.[i], windDir: w.windDir?.[i],
|
|
223
|
+
ghi: w.ghi[i], dni: w.dni[i], dhi: w.dhi[i], ir: w.ir?.[i]
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Pedestrian-level wind screening on a building-height grid. NOT CFD: a fast
|
|
3
|
+
// geometric estimate meant to find sheltered and exposed places, to be
|
|
4
|
+
// confirmed with a flow simulation or wind-tunnel test where it matters.
|
|
5
|
+
//
|
|
6
|
+
// For each open cell the ratio to the open-terrain pedestrian wind is
|
|
7
|
+
// ratio = (1 - wake deficit) x channelling x corner factor
|
|
8
|
+
// wake deficit strongest upwind wake: A exp(-x / (k h)) for an obstacle of
|
|
9
|
+
// height h at distance x (recovery over about 10-15 h, the
|
|
10
|
+
// usual near-wake length behind a building)
|
|
11
|
+
// channelling open fetch along the wind with buildings close on both
|
|
12
|
+
// sides (a street aligned with the wind) speeds the flow up
|
|
13
|
+
// corner factor cells beside a building much taller than its surroundings
|
|
14
|
+
// (downwash and corner streams)
|
|
15
|
+
// The open-terrain pedestrian wind is the 10 m reference speed scaled by a
|
|
16
|
+
// log profile over open terrain: ln(1.5/z0) / ln(10/z0) with z0 = 0.03 m.
|
|
17
|
+
|
|
18
|
+
export const OPEN_PEDESTRIAN_FACTOR = Math.log(1.5 / 0.03) / Math.log(10 / 0.03);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} p
|
|
22
|
+
* @param {Float32Array} p.heights obstacle height above ground per cell (0 = open)
|
|
23
|
+
* @param {Float32Array|null} [p.porosity] optical porosity per cell 0..1 (trees ~0.5), null = solid
|
|
24
|
+
* @param {number} p.cols
|
|
25
|
+
* @param {number} p.rows
|
|
26
|
+
* @param {number} p.cell metres
|
|
27
|
+
* @param {number} p.windFromDeg meteorological direction the wind comes from
|
|
28
|
+
* @param {number} [p.wakeAmplitude]
|
|
29
|
+
* @param {number} [p.wakeLength] recovery length in obstacle heights
|
|
30
|
+
* @param {(f: number) => void} [p.onProgress]
|
|
31
|
+
* @returns {Promise<Float32Array>} ratio per cell, NaN inside buildings
|
|
32
|
+
*/
|
|
33
|
+
export async function windRatio({ heights, porosity = null, cols, rows, cell, windFromDeg, wakeAmplitude = 0.75, wakeLength = 3.5, onProgress }) {
|
|
34
|
+
const out = new Float32Array(cols * rows);
|
|
35
|
+
// Unit vector pointing upwind (towards where the wind comes from), in
|
|
36
|
+
// grid steps: +x east = +col, +y north = -row.
|
|
37
|
+
const from = windFromDeg * Math.PI / 180;
|
|
38
|
+
const ux = Math.sin(from);
|
|
39
|
+
const uy = Math.cos(from);
|
|
40
|
+
const dc = ux;
|
|
41
|
+
const dr = -uy;
|
|
42
|
+
// Perpendicular (for channelling).
|
|
43
|
+
const pc = -dr;
|
|
44
|
+
const pr = dc;
|
|
45
|
+
let hMax = 0, hSum = 0, hN = 0;
|
|
46
|
+
for (const h of heights) { if (h > hMax) hMax = h; if (h > 0) { hSum += h; hN++; } }
|
|
47
|
+
const hMean = hN ? hSum / hN : 0;
|
|
48
|
+
let hasTall = false;
|
|
49
|
+
for (const h of heights) if (h > 2 * hMean && h > 20) { hasTall = true; break; }
|
|
50
|
+
const maxSteps = Math.ceil(Math.min(600, Math.max(60, wakeLength * 5 * hMax)) / cell);
|
|
51
|
+
const sideSteps = Math.ceil(40 / cell);
|
|
52
|
+
// Round, then check the bounds: c = cols - 0.3 would round into the next row.
|
|
53
|
+
const at = (c, r) => {
|
|
54
|
+
const ic = Math.round(c), ir = Math.round(r);
|
|
55
|
+
return ic < 0 || ir < 0 || ic >= cols || ir >= rows ? 0 : heights[ir * cols + ic];
|
|
56
|
+
};
|
|
57
|
+
const poro = (c, r) => {
|
|
58
|
+
const ic = Math.round(c), ir = Math.round(r);
|
|
59
|
+
return porosity && ic >= 0 && ir >= 0 && ic < cols && ir < rows ? porosity[ir * cols + ic] || 0 : 0;
|
|
60
|
+
};
|
|
61
|
+
for (let r = 0; r < rows; r++) {
|
|
62
|
+
for (let c = 0; c < cols; c++) {
|
|
63
|
+
const i = r * cols + c;
|
|
64
|
+
if (heights[i] > 0 && !(porosity && porosity[i] > 0)) { out[i] = NaN; continue; }
|
|
65
|
+
// 1. Wake deficit from upwind obstacles.
|
|
66
|
+
let deficit = 0;
|
|
67
|
+
let fetch = maxSteps * cell;
|
|
68
|
+
for (let s = 1; s <= maxSteps; s++) {
|
|
69
|
+
const cc = c + dc * s;
|
|
70
|
+
const rr = r + dr * s;
|
|
71
|
+
if (cc < 0 || rr < 0 || cc > cols - 1 || rr > rows - 1) break;
|
|
72
|
+
const h = at(cc, rr);
|
|
73
|
+
if (h <= 0) continue;
|
|
74
|
+
const x = s * cell;
|
|
75
|
+
if (x < fetch) fetch = x;
|
|
76
|
+
const d = wakeAmplitude * (1 - poro(cc, rr)) * Math.exp(-x / (wakeLength * h));
|
|
77
|
+
if (d > deficit) deficit = d;
|
|
78
|
+
if (x > wakeLength * 5 * hMax) break;
|
|
79
|
+
}
|
|
80
|
+
// 2. Channelling: buildings close on both sides, open along the wind.
|
|
81
|
+
let left = 0, right = 0, hl = 0, hr = 0;
|
|
82
|
+
for (let s = 1; s <= sideSteps && !left; s++) { const h = at(c + pc * s, r + pr * s); if (h > 0) { left = s * cell; hl = h; } }
|
|
83
|
+
for (let s = 1; s <= sideSteps && !right; s++) { const h = at(c - pc * s, r - pr * s); if (h > 0) { right = s * cell; hr = h; } }
|
|
84
|
+
let channel = 1;
|
|
85
|
+
if (left && right) {
|
|
86
|
+
const width = left + right;
|
|
87
|
+
const hSide = Math.min(hl, hr);
|
|
88
|
+
const along = Math.min(1, fetch / (3 * width));
|
|
89
|
+
channel = 1 + 0.25 * along * Math.min(1, hSide / width);
|
|
90
|
+
}
|
|
91
|
+
// 3. Corner/downwash: a much taller building close by.
|
|
92
|
+
let corner = 1;
|
|
93
|
+
if (hasTall) {
|
|
94
|
+
const rad = Math.ceil(30 / cell);
|
|
95
|
+
let best = 0;
|
|
96
|
+
for (let dy = -rad; dy <= rad; dy += 2) {
|
|
97
|
+
for (let dx = -rad; dx <= rad; dx += 2) {
|
|
98
|
+
const h = at(c + dx, r + dy);
|
|
99
|
+
if (h > 2 * hMean && h > 20) {
|
|
100
|
+
const dist = Math.hypot(dx, dy) * cell;
|
|
101
|
+
if (dist < 0.6 * h) best = Math.max(best, (1 - dist / (0.6 * h)) * Math.min(1, h / (4 * hMean)));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
corner = 1 + 0.35 * best;
|
|
106
|
+
}
|
|
107
|
+
out[i] = Math.max(0.05, Math.min(1.6, (1 - deficit) * channel * corner));
|
|
108
|
+
}
|
|
109
|
+
if (onProgress && r % 32 === 0) {
|
|
110
|
+
onProgress(r / rows);
|
|
111
|
+
await new Promise((res) => setTimeout(res, 0));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Indicative pedestrian comfort class for a wind speed (m/s) at pedestrian
|
|
119
|
+
* height, with the thresholds of the LDDC (Lawson) criteria: sitting < 4,
|
|
120
|
+
* standing < 6, strolling < 8, business walking < 10. Lawson applies them to
|
|
121
|
+
* the speed exceeded 5 percent of the time, so feed it the 95th-percentile
|
|
122
|
+
* reference speed, not the mean.
|
|
123
|
+
*/
|
|
124
|
+
export function lawsonClass(speed) {
|
|
125
|
+
if (!Number.isFinite(speed)) return null;
|
|
126
|
+
if (speed < 4) return { key: 'sitting', label: 'Sitting', color: '#2c7bb6' };
|
|
127
|
+
if (speed < 6) return { key: 'standing', label: 'Standing', color: '#abd9e9' };
|
|
128
|
+
if (speed < 8) return { key: 'strolling', label: 'Strolling', color: '#ffffbf' };
|
|
129
|
+
if (speed < 10) return { key: 'walking', label: 'Business walking', color: '#fdae61' };
|
|
130
|
+
return { key: 'uncomfortable', label: 'Uncomfortable', color: '#d7191c' };
|
|
131
|
+
}
|