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,199 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Daylight to neighbouring windows after BRE 209 (Site layout planning for
|
|
3
|
+
// daylight and sunlight). Seen from the centre of a neighbour's lowest window,
|
|
4
|
+
// in a section at right angles to the window wall, new building rising more
|
|
5
|
+
// than 25° above the horizontal may take noticeable daylight; it then passes
|
|
6
|
+
// if the window keeps at least 0.8 times its former vertical sky component.
|
|
7
|
+
// Here the VSC is that of a long obstruction at the angle the new building
|
|
8
|
+
// subtends (closed form for the CIE overcast sky below), which overstates the
|
|
9
|
+
// loss for a short building: a screening on the safe side.
|
|
10
|
+
// Prepared once for a set of neighbour walls and buildings whose heights
|
|
11
|
+
// change; checking a design is then a few operations per window, so it can
|
|
12
|
+
// sit inside an optimiser.
|
|
13
|
+
|
|
14
|
+
const TAN25 = Math.tan((25 * Math.PI) / 180);
|
|
15
|
+
const DEG = 180 / Math.PI;
|
|
16
|
+
/** BRE 209: a loss to less than 0.8 times the former value is noticeable. */
|
|
17
|
+
export const BRE_FACTOR = 0.8;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Vertical sky component (0..1) of a window facing a long obstruction
|
|
21
|
+
* parallel to it whose top is `angle` radians above the horizontal in the
|
|
22
|
+
* section at right angles to the wall, under the CIE standard overcast sky
|
|
23
|
+
* (luminance ∝ 1 + 2 sin γ). With ψ the elevation in that section and β the
|
|
24
|
+
* angle out of it, a direction is (cos β cos ψ, cos β sin ψ, sin β); the sky
|
|
25
|
+
* above ψ = θ, weighted by the cosine to the wall normal and divided by the
|
|
26
|
+
* unobstructed horizontal illuminance 7π/3, integrates to
|
|
27
|
+
* VSC(θ) = [ (π/2)(1 − sin θ) + (4/3) cos²θ ] / (7π/3):
|
|
28
|
+
* 39.6 % for an open window and 27.3 % at 25°, the pair BRE 209 relates
|
|
29
|
+
* (checked against direct numerical integration in the tests).
|
|
30
|
+
*/
|
|
31
|
+
export function vscLong(angle) {
|
|
32
|
+
const t = Math.max(0, Math.min(Math.PI / 2, angle));
|
|
33
|
+
return ((Math.PI / 2) * (1 - Math.sin(t)) + (4 / 3) * Math.cos(t) ** 2) / ((7 * Math.PI) / 3);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function signedArea(ring) {
|
|
37
|
+
let a = 0;
|
|
38
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1];
|
|
39
|
+
return a / 2;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Distance along a ray (p + t d, t > 0) to the nearest edge of the rings, or Infinity. */
|
|
43
|
+
export function rayToRings(px, py, dx, dy, rings) {
|
|
44
|
+
let best = Infinity;
|
|
45
|
+
for (const ring of rings) {
|
|
46
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
47
|
+
const ax = ring[j][0], ay = ring[j][1];
|
|
48
|
+
const ex = ring[i][0] - ax, ey = ring[i][1] - ay;
|
|
49
|
+
const den = dx * ey - dy * ex;
|
|
50
|
+
if (Math.abs(den) < 1e-12) continue;
|
|
51
|
+
const wx = ax - px, wy = ay - py;
|
|
52
|
+
const t = (wx * ey - wy * ex) / den;
|
|
53
|
+
const u = (wx * dy - wy * dx) / den;
|
|
54
|
+
if (t > 1e-9 && u >= 0 && u <= 1 && t < best) best = t;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return best;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function inside(x, y, rings) {
|
|
61
|
+
let odd = false;
|
|
62
|
+
for (const ring of rings) {
|
|
63
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
64
|
+
const [xi, yi] = ring[i], [xj, yj] = ring[j];
|
|
65
|
+
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) odd = !odd;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return odd;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Window samples on the outer walls of a building: every `step` metres, with the outward normal. */
|
|
72
|
+
export function wallSamples(ring, step = 3) {
|
|
73
|
+
const out = [];
|
|
74
|
+
const ccw = signedArea(ring) > 0;
|
|
75
|
+
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
|
76
|
+
const ax = ring[j][0], ay = ring[j][1], ex = ring[i][0] - ax, ey = ring[i][1] - ay;
|
|
77
|
+
const len = Math.hypot(ex, ey);
|
|
78
|
+
if (len < 1) continue;
|
|
79
|
+
const nx = (ccw ? ey : -ey) / len, ny = (ccw ? -ex : ex) / len;
|
|
80
|
+
const n = Math.max(1, Math.round(len / step));
|
|
81
|
+
for (let k = 0; k < n; k++) {
|
|
82
|
+
const s = (k + 0.5) / n;
|
|
83
|
+
out.push({ x: ax + ex * s, y: ay + ey * s, nx, ny });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {{ ring: number[][], windowZ: number }[]} neighbours outer ring and window-centre height of each neighbour
|
|
91
|
+
* @param {{ rings: number[][][] }[]} targets footprints of the buildings whose heights change
|
|
92
|
+
* @param {number[]} topsNow roof heights of the targets now (absolute)
|
|
93
|
+
* @param {{ step?: number, maxDist?: number, partyWall?: number }} [o]
|
|
94
|
+
* maxDist: no target farther than this can reach 25° (from the tallest allowed roof);
|
|
95
|
+
* partyWall: hits closer than this are a shared wall, not a window opposite.
|
|
96
|
+
*/
|
|
97
|
+
export function prepareRule(neighbours, targets, topsNow, { step = 3, maxDist = 200, partyWall = 1 } = {}) {
|
|
98
|
+
const sampleZ = [], sampleNowTan = [], pairStart = [0], pairTarget = [], pairDist = [], owner = [];
|
|
99
|
+
neighbours.forEach((nb, k) => {
|
|
100
|
+
for (const s of wallSamples(nb.ring, step)) {
|
|
101
|
+
const before = pairTarget.length;
|
|
102
|
+
let shared = false;
|
|
103
|
+
targets.forEach((t, ti) => {
|
|
104
|
+
if (shared) return;
|
|
105
|
+
// A wall the target stands against (the point just in front of it is
|
|
106
|
+
// inside the target) or one a hair away is a shared wall, not a window.
|
|
107
|
+
if (inside(s.x + s.nx * partyWall * 0.5, s.y + s.ny * partyWall * 0.5, t.rings)) { shared = true; return; }
|
|
108
|
+
const d = rayToRings(s.x, s.y, s.nx, s.ny, t.rings);
|
|
109
|
+
if (d < partyWall) { shared = true; return; }
|
|
110
|
+
if (d <= maxDist) { pairTarget.push(ti); pairDist.push(d); }
|
|
111
|
+
});
|
|
112
|
+
if (shared || pairTarget.length === before) { pairTarget.length = before; pairDist.length = before; continue; }
|
|
113
|
+
let now = 0;
|
|
114
|
+
for (let p = before; p < pairTarget.length; p++) now = Math.max(now, (topsNow[pairTarget[p]] - nb.windowZ) / pairDist[p]);
|
|
115
|
+
sampleZ.push(nb.windowZ);
|
|
116
|
+
sampleNowTan.push(now);
|
|
117
|
+
owner.push(k);
|
|
118
|
+
pairStart.push(pairTarget.length);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
windows: sampleZ.length,
|
|
123
|
+
sampleZ: Float64Array.from(sampleZ), nowTan: Float64Array.from(sampleNowTan), owner: Int32Array.from(owner),
|
|
124
|
+
pairStart: Int32Array.from(pairStart), pairTarget: Int32Array.from(pairTarget), pairDist: Float64Array.from(pairDist)
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check a design. A window passes when the targets stay under 25° from it,
|
|
130
|
+
* or when its VSC stays at least 0.8 times what it is now.
|
|
131
|
+
* excessDeg: how far, in degrees, the failing windows' obstruction rises
|
|
132
|
+
* above the highest angle they would still pass at (for an optimiser).
|
|
133
|
+
* @param {ReturnType<typeof prepareRule>} prep
|
|
134
|
+
* @param {ArrayLike<number>} tops roof heights of the targets in this design (absolute)
|
|
135
|
+
* @returns {{ failing: number, excessDeg: number, worstDeg: number, neighboursHit: number }}
|
|
136
|
+
*/
|
|
137
|
+
export function checkRule(prep, tops) {
|
|
138
|
+
let failing = 0, excess = 0, worst = 0;
|
|
139
|
+
const hit = new Set();
|
|
140
|
+
for (let s = 0; s < prep.windows; s++) {
|
|
141
|
+
const z = prep.sampleZ[s];
|
|
142
|
+
let tan = 0;
|
|
143
|
+
for (let p = prep.pairStart[s]; p < prep.pairStart[s + 1]; p++) {
|
|
144
|
+
const v = (tops[prep.pairTarget[p]] - z) / prep.pairDist[p];
|
|
145
|
+
if (v > tan) tan = v;
|
|
146
|
+
}
|
|
147
|
+
if (tan > worst) worst = tan;
|
|
148
|
+
if (tan <= TAN25 + 1e-12) continue;
|
|
149
|
+
const allowed = allowedAngle(prep.nowTan[s]);
|
|
150
|
+
const angle = Math.atan(tan);
|
|
151
|
+
if (angle > allowed + 1e-9) {
|
|
152
|
+
failing++;
|
|
153
|
+
excess += (angle - allowed) * DEG;
|
|
154
|
+
hit.add(prep.owner[s]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return { failing, excessDeg: excess, worstDeg: Math.atan(worst) * DEG, neighboursHit: hit.size };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** VSC of an unobstructed vertical window (0.396). */
|
|
161
|
+
export const VSC_OPEN = vscLong(0);
|
|
162
|
+
/** BRE 209: a window with a VSC of 27 % or more keeps enough daylight. */
|
|
163
|
+
export const VSC_TARGET = 0.27;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Sky directions weighted so that the GPU sum of weight x visibility x
|
|
167
|
+
* max(0, n.d) on a vertical window is its VSC: each direction stands for an
|
|
168
|
+
* equal solid angle 2π/n of the hemisphere, the CIE overcast sky luminance
|
|
169
|
+
* goes as 1 + 2 sin γ, and the unobstructed horizontal illuminance is 7π/3
|
|
170
|
+
* in the same units, so weight = (2π/n)(1 + 2 sin γ) / (7π/3).
|
|
171
|
+
* @param {{ dir: number[] }[]} hemisphere directions uniform in solid angle (y up)
|
|
172
|
+
*/
|
|
173
|
+
export function vscDirections(hemisphere) {
|
|
174
|
+
const n = hemisphere.length;
|
|
175
|
+
return hemisphere.map((d) => ({ dir: d.dir, weight: (6 / (7 * n)) * (1 + 2 * Math.max(0, d.dir[1])) }));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const allowedCache = new Map();
|
|
179
|
+
/** The highest obstruction angle (radians) a window may have: 25°, or what keeps 0.8 of its VSC now. */
|
|
180
|
+
export function allowedAngle(nowTan) {
|
|
181
|
+
const key = Math.round(Math.max(0, nowTan) * 1e4);
|
|
182
|
+
let a = allowedCache.get(key);
|
|
183
|
+
if (a !== undefined) return a;
|
|
184
|
+
const target = BRE_FACTOR * vscLong(Math.atan(key / 1e4));
|
|
185
|
+
// VSC falls with the angle: bisect for VSC(a) = target.
|
|
186
|
+
let lo = 0, hi = Math.PI / 2;
|
|
187
|
+
for (let i = 0; i < 40; i++) {
|
|
188
|
+
const mid = (lo + hi) / 2;
|
|
189
|
+
if (vscLong(mid) > target) lo = mid; else hi = mid;
|
|
190
|
+
}
|
|
191
|
+
a = Math.max(Math.atan(TAN25), lo);
|
|
192
|
+
if (allowedCache.size < 50000) allowedCache.set(key, a);
|
|
193
|
+
return a;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Distance beyond which a roof `rise` metres above a window stays under 25°. */
|
|
197
|
+
export function reach25(rise) {
|
|
198
|
+
return Math.max(0, rise) / TAN25;
|
|
199
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// Street-canyon ventilation, normalized air age, and traffic pollutant
|
|
3
|
+
// dispersion modeling (OSPM-inspired Gaussian plume + canyon recirculation).
|
|
4
|
+
|
|
5
|
+
import { sampleRoadLines, streetCentrelines } from './road_sources.js';
|
|
6
|
+
|
|
7
|
+
export const VENTILATION_CLASSES = [
|
|
8
|
+
{ min: 0.8, max: Infinity, label: 'Well ventilated', color: '#2b83ba' },
|
|
9
|
+
{ min: 0.5, max: 0.8, label: 'Moderate exchange', color: '#abdda4' },
|
|
10
|
+
{ min: 0.3, max: 0.5, label: 'Low ventilation', color: '#fdae61' },
|
|
11
|
+
{ min: 0, max: 0.3, label: 'Stagnation risk', color: '#d7191c' }
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Categorize a Ventilation Efficiency Index (VEI) value.
|
|
16
|
+
* @param {number} veiValue
|
|
17
|
+
* @returns {{ min: number, max: number, label: string, color: string } | null}
|
|
18
|
+
*/
|
|
19
|
+
export function ventilationClass(veiValue) {
|
|
20
|
+
if (!Number.isFinite(veiValue)) return null;
|
|
21
|
+
for (const c of VENTILATION_CLASSES) {
|
|
22
|
+
if (veiValue >= c.min) return c;
|
|
23
|
+
}
|
|
24
|
+
return VENTILATION_CLASSES[VENTILATION_CLASSES.length - 1];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compute pedestrian-level ventilation efficiency index (VEI) and normalized air age.
|
|
29
|
+
* @param {object} p
|
|
30
|
+
* @param {Float32Array|null} [p.windRatioGrid] ratio to open wind (0.05..1.6)
|
|
31
|
+
* @param {Float32Array} p.heights obstacle height above ground per cell (0 = open)
|
|
32
|
+
* @param {Float32Array|null} [p.porosity] optical porosity per cell (null = solid)
|
|
33
|
+
* @param {Float32Array|null} [p.svf] sky view factor per cell (0..1)
|
|
34
|
+
* @param {number} p.cols
|
|
35
|
+
* @param {number} p.rows
|
|
36
|
+
* @param {number} p.cell metres per cell
|
|
37
|
+
* @returns {{ vei: Float32Array, airAge: Float32Array }}
|
|
38
|
+
*/
|
|
39
|
+
export function ventilationEfficiency({ windRatioGrid = null, heights, porosity = null, svf = null, cols, rows, cell }) {
|
|
40
|
+
const n = cols * rows;
|
|
41
|
+
const vei = new Float32Array(n);
|
|
42
|
+
const airAge = new Float32Array(n);
|
|
43
|
+
|
|
44
|
+
for (let i = 0; i < n; i++) {
|
|
45
|
+
if (heights[i] > 0 && !(porosity && porosity[i] > 0)) {
|
|
46
|
+
vei[i] = NaN;
|
|
47
|
+
airAge[i] = NaN;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const w = windRatioGrid && Number.isFinite(windRatioGrid[i]) ? windRatioGrid[i] : 0.7;
|
|
51
|
+
const s = svf && Number.isFinite(svf[i]) ? svf[i] : 0.7;
|
|
52
|
+
const v = Math.max(0.02, Math.min(1.5, 0.7 * w + 0.3 * s));
|
|
53
|
+
vei[i] = v;
|
|
54
|
+
airAge[i] = Math.max(0.5, Math.min(12, 1 / Math.max(0.08, v)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { vei, airAge };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const AIR_QUALITY_CLASSES = [
|
|
61
|
+
{ min: 0, max: 20, label: 'Good', color: '#2b83ba' },
|
|
62
|
+
{ min: 20, max: 40, label: 'Moderate', color: '#abdda4' },
|
|
63
|
+
{ min: 40, max: 80, label: 'Elevated', color: '#fdae61' },
|
|
64
|
+
{ min: 80, max: Infinity, label: 'High', color: '#d7191c' }
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Categorize a pollutant concentration value (ug/m3).
|
|
69
|
+
* @param {number} ugm3
|
|
70
|
+
* @returns {{ min: number, max: number, label: string, color: string } | null}
|
|
71
|
+
*/
|
|
72
|
+
export function airQualityClass(ugm3) {
|
|
73
|
+
if (!Number.isFinite(ugm3)) return null;
|
|
74
|
+
return AIR_QUALITY_CLASSES.find((c) => ugm3 < c.max) || AIR_QUALITY_CLASSES[AIR_QUALITY_CLASSES.length - 1];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Traffic emission sources: the given roads sampled every max(2 m, 1 cell),
|
|
79
|
+
* or street centrelines between buildings when there are none, or a line
|
|
80
|
+
* across the middle of a scene without buildings.
|
|
81
|
+
* @returns {Array<{ x: number, y: number, length: number }>}
|
|
82
|
+
*/
|
|
83
|
+
function emissionSources(roads, weights, cols, rows, cell, heights, porosity, minX, maxY) {
|
|
84
|
+
const { sources, given } = sampleRoadLines(roads, weights, Math.max(2, cell));
|
|
85
|
+
if (sources.length || given) return sources;
|
|
86
|
+
const streets = streetCentrelines(heights, porosity, cols, rows, cell, minX, maxY, 25);
|
|
87
|
+
if (streets.length) return streets;
|
|
88
|
+
const midY = maxY - Math.floor(rows / 2) * cell;
|
|
89
|
+
return Array.from({ length: cols }, (_, c) => ({ x: minX + c * cell, y: midY, length: cell }));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Model pedestrian-level traffic pollutant dispersion (NO2 or PM2.5 in ug/m3).
|
|
94
|
+
* @param {object} p
|
|
95
|
+
* @param {Float32Array} p.heights obstacle height above ground per cell
|
|
96
|
+
* @param {Float32Array|null} [p.porosity] optical porosity per cell (null = solid)
|
|
97
|
+
* @param {Float32Array|null} [p.windRatioGrid] wind speed ratio per cell
|
|
98
|
+
* @param {Float32Array|null} [p.svf] sky view factor per cell
|
|
99
|
+
* @param {number} p.cols
|
|
100
|
+
* @param {number} p.rows
|
|
101
|
+
* @param {number} p.cell metres per cell
|
|
102
|
+
* @param {number} [p.minX]
|
|
103
|
+
* @param {number} [p.maxY]
|
|
104
|
+
* @param {number} [p.windFromDeg] meteorological direction wind comes from (0=N, 90=E, 180=S, 270=W)
|
|
105
|
+
* @param {number} [p.windSpeed10] reference wind speed at 10m (m/s)
|
|
106
|
+
* @param {Array<Array<[number, number]>>} [p.roads] polylines of road centerlines
|
|
107
|
+
* @param {ArrayLike<number>|null} [p.roadWeights] relative traffic per road polyline (1 = the stated level; 2 doubles it)
|
|
108
|
+
* @param {number} [p.emissionRateUgMS] line source emission rate (ug / (m * s))
|
|
109
|
+
* @param {number} [p.backgroundUgM3] ambient background concentration (ug / m3)
|
|
110
|
+
* @param {number} [p.treeDepositionFactor] fraction of pollutant filtered by trees (0..1)
|
|
111
|
+
* @param {((f: number) => void)|null} [p.onProgress] progress 0..1; the page stays responsive
|
|
112
|
+
* @returns {Promise<Float32Array>} concentration in ug/m3, NaN inside solid buildings
|
|
113
|
+
*/
|
|
114
|
+
export async function pollutantDispersion({
|
|
115
|
+
heights,
|
|
116
|
+
porosity = null,
|
|
117
|
+
windRatioGrid = null,
|
|
118
|
+
svf = null,
|
|
119
|
+
cols,
|
|
120
|
+
rows,
|
|
121
|
+
cell,
|
|
122
|
+
minX = 0.5 * cell,
|
|
123
|
+
maxY = (rows - 0.5) * cell,
|
|
124
|
+
windFromDeg = 270,
|
|
125
|
+
windSpeed10 = 3,
|
|
126
|
+
roads = [],
|
|
127
|
+
roadWeights = null,
|
|
128
|
+
emissionRateUgMS = 60,
|
|
129
|
+
backgroundUgM3 = 18,
|
|
130
|
+
treeDepositionFactor = 0.25,
|
|
131
|
+
onProgress = null
|
|
132
|
+
}) {
|
|
133
|
+
const sources = emissionSources(roads, roadWeights, cols, rows, cell, heights, porosity, minX, maxY);
|
|
134
|
+
// Sources in 25 m bins: a cell visits only the bins within the 250 m
|
|
135
|
+
// plume reach (and within 30 m, for the canyon trap), not every source.
|
|
136
|
+
const BIN = 25, REACH = 250, NEAR = 30;
|
|
137
|
+
/** @type {Map<number, Array<{ x: number, y: number, length: number }>>} */
|
|
138
|
+
const bins = new Map();
|
|
139
|
+
const binKey = (bx, by) => (bx + 32768) * 65536 + (by + 32768);
|
|
140
|
+
for (const src of sources) {
|
|
141
|
+
const k = binKey(Math.floor(src.x / BIN), Math.floor(src.y / BIN));
|
|
142
|
+
let b = bins.get(k);
|
|
143
|
+
if (!b) { b = []; bins.set(k, b); }
|
|
144
|
+
b.push(src);
|
|
145
|
+
}
|
|
146
|
+
const span = Math.ceil(REACH / BIN) + 1;
|
|
147
|
+
// Beyond FAR the plume (sigma_y >= 13 m) is wider than half a bin: a bin
|
|
148
|
+
// counts as one source at its length-weighted centre.
|
|
149
|
+
const FAR = 60;
|
|
150
|
+
/** @type {Map<number, Array<{ x: number, y: number, length: number }>>} */
|
|
151
|
+
const merged = new Map();
|
|
152
|
+
for (const [k, members] of bins) {
|
|
153
|
+
let x = 0, y = 0, length = 0;
|
|
154
|
+
for (const m of members) { x += m.x * m.length; y += m.y * m.length; length += m.length; }
|
|
155
|
+
merged.set(k, length > 0 ? [{ x: x / length, y: y / length, length }] : []);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Wind vector blows towards (windFromDeg + 180) degrees
|
|
159
|
+
const toRad = (((windFromDeg + 180) % 360) * Math.PI) / 180;
|
|
160
|
+
const wx = Math.sin(toRad);
|
|
161
|
+
const wy = Math.cos(toRad);
|
|
162
|
+
|
|
163
|
+
const out = new Float32Array(cols * rows);
|
|
164
|
+
let lastYield = Date.now();
|
|
165
|
+
|
|
166
|
+
for (let r = 0; r < rows; r++) {
|
|
167
|
+
for (let c = 0; c < cols; c++) {
|
|
168
|
+
const i = r * cols + c;
|
|
169
|
+
if (heights[i] > 0 && !(porosity && porosity[i] > 0)) {
|
|
170
|
+
out[i] = NaN;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const rx = minX + c * cell;
|
|
175
|
+
const ry = maxY - r * cell;
|
|
176
|
+
|
|
177
|
+
const localWindRatio = windRatioGrid && Number.isFinite(windRatioGrid[i]) ? windRatioGrid[i] : 0.7;
|
|
178
|
+
const localSvf = svf && Number.isFinite(svf[i]) ? svf[i] : 0.7;
|
|
179
|
+
const u = Math.max(0.6, windSpeed10 * 0.65 * localWindRatio);
|
|
180
|
+
|
|
181
|
+
let directPlume = 0;
|
|
182
|
+
let minRoadDist = Infinity;
|
|
183
|
+
|
|
184
|
+
const bx0 = Math.floor(rx / BIN), by0 = Math.floor(ry / BIN);
|
|
185
|
+
for (let gy = by0 - span; gy <= by0 + span; gy++) {
|
|
186
|
+
for (let gx = bx0 - span; gx <= bx0 + span; gx++) {
|
|
187
|
+
const key = binKey(gx, gy);
|
|
188
|
+
if (!bins.has(key)) continue;
|
|
189
|
+
// Nearest edge of the bin: farther than the reach, nothing to add.
|
|
190
|
+
const ex = Math.max(gx * BIN - rx, 0, rx - (gx + 1) * BIN);
|
|
191
|
+
const ey = Math.max(gy * BIN - ry, 0, ry - (gy + 1) * BIN);
|
|
192
|
+
const edge = Math.hypot(ex, ey);
|
|
193
|
+
if (edge > REACH) continue;
|
|
194
|
+
const members = edge > FAR ? merged.get(key) : bins.get(key);
|
|
195
|
+
// A bin wholly upwind adds no plume; it still counts for the canyon trap when near.
|
|
196
|
+
const cxb = (gx + 0.5) * BIN - rx, cyb = (gy + 0.5) * BIN - ry;
|
|
197
|
+
const upwind = -(cxb * wx + cyb * wy) < -BIN * Math.SQRT1_2;
|
|
198
|
+
if (upwind && edge > NEAR) continue;
|
|
199
|
+
for (let s = 0; s < members.length; s++) {
|
|
200
|
+
const src = members[s];
|
|
201
|
+
const dx = rx - src.x;
|
|
202
|
+
const dy = ry - src.y;
|
|
203
|
+
const dist = Math.hypot(dx, dy);
|
|
204
|
+
if (dist < minRoadDist) minRoadDist = dist;
|
|
205
|
+
|
|
206
|
+
if (dist > REACH || upwind) continue;
|
|
207
|
+
|
|
208
|
+
// Along-wind and cross-wind distance
|
|
209
|
+
const dAlong = dx * wx + dy * wy;
|
|
210
|
+
if (dAlong <= 0) continue; // Upwind of this source
|
|
211
|
+
|
|
212
|
+
const dCross = Math.abs(dx * wy - dy * wx);
|
|
213
|
+
const sigmaY = 2.5 + 0.18 * dAlong;
|
|
214
|
+
const sigmaZ = 1.8 + 0.12 * dAlong;
|
|
215
|
+
|
|
216
|
+
if (dCross > 4 * sigmaY) continue;
|
|
217
|
+
|
|
218
|
+
const expY = Math.exp(-0.5 * (dCross * dCross) / (sigmaY * sigmaY));
|
|
219
|
+
const expZ = Math.exp(-0.5 * (1.5 * 1.5) / (sigmaZ * sigmaZ));
|
|
220
|
+
const kernel = (src.length * (emissionRateUgMS / u) / (Math.PI * sigmaY * sigmaZ)) * expY * expZ;
|
|
221
|
+
directPlume += kernel;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let trafficIncrement = directPlume;
|
|
227
|
+
|
|
228
|
+
// Canyon vortex recirculation trap: when svf < 0.65 or windRatioGrid < 0.45 near road sources (< 30 m)
|
|
229
|
+
if (minRoadDist < 30 && (localSvf < 0.65 || localWindRatio < 0.45)) {
|
|
230
|
+
const trapFactor = 1 + 1.4 * (1 - localSvf);
|
|
231
|
+
trafficIncrement *= trapFactor;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Vegetation / Tree canopy filtration: if local cell or nearby has tree porosity
|
|
235
|
+
let hasTree = Boolean(porosity && porosity[i] > 0);
|
|
236
|
+
if (!hasTree && porosity) {
|
|
237
|
+
for (let dr = -1; dr <= 1 && !hasTree; dr++) {
|
|
238
|
+
for (let dc = -1; dc <= 1; dc++) {
|
|
239
|
+
const nr = r + dr, nc = c + dc;
|
|
240
|
+
if (nr >= 0 && nr < rows && nc >= 0 && nc < cols) {
|
|
241
|
+
if (porosity[nr * cols + nc] > 0) {
|
|
242
|
+
hasTree = true;
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (hasTree) {
|
|
250
|
+
trafficIncrement *= (1 - treeDepositionFactor);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const totalConc = backgroundUgM3 + trafficIncrement;
|
|
254
|
+
out[i] = Math.max(backgroundUgM3, Math.min(400, totalConc));
|
|
255
|
+
}
|
|
256
|
+
if (onProgress && Date.now() - lastYield > 50) {
|
|
257
|
+
onProgress((r + 1) / rows);
|
|
258
|
+
await new Promise((res) => setTimeout(res, 0));
|
|
259
|
+
lastYield = Date.now();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return out;
|
|
264
|
+
}
|