zero2geospatial3d-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. zero2geospatial3d/__init__.py +105 -0
  2. zero2geospatial3d/acoustics.py +284 -0
  3. zero2geospatial3d/classify.py +379 -0
  4. zero2geospatial3d/cli.py +367 -0
  5. zero2geospatial3d/comfort.py +687 -0
  6. zero2geospatial3d/data/epw_stations.json.gz +0 -0
  7. zero2geospatial3d/daylight.py +337 -0
  8. zero2geospatial3d/dispersion.py +249 -0
  9. zero2geospatial3d/epw.py +162 -0
  10. zero2geospatial3d/geo.py +138 -0
  11. zero2geospatial3d/grid.py +119 -0
  12. zero2geospatial3d/microclimate.py +388 -0
  13. zero2geospatial3d/morphology.py +283 -0
  14. zero2geospatial3d/nsga2.py +315 -0
  15. zero2geospatial3d/osm.py +450 -0
  16. zero2geospatial3d/osm_tags.py +180 -0
  17. zero2geospatial3d/py.typed +0 -0
  18. zero2geospatial3d/results.py +231 -0
  19. zero2geospatial3d/road_sources.py +141 -0
  20. zero2geospatial3d/scene.py +555 -0
  21. zero2geospatial3d/server.py +332 -0
  22. zero2geospatial3d/shading.py +443 -0
  23. zero2geospatial3d/solar.py +207 -0
  24. zero2geospatial3d/terrain.py +610 -0
  25. zero2geospatial3d/urban_form.py +326 -0
  26. zero2geospatial3d/walkability.py +380 -0
  27. zero2geospatial3d/weather.py +373 -0
  28. zero2geospatial3d/web/index.html +50 -0
  29. zero2geospatial3d/web/src/analysis/climate.js +278 -0
  30. zero2geospatial3d/web/src/analysis/compliance.js +345 -0
  31. zero2geospatial3d/web/src/analysis/gpu.js +431 -0
  32. zero2geospatial3d/web/src/analysis/optimize.js +208 -0
  33. zero2geospatial3d/web/src/analysis/runners.js +1644 -0
  34. zero2geospatial3d/web/src/analysis/terrain_results.js +145 -0
  35. zero2geospatial3d/web/src/app.js +630 -0
  36. zero2geospatial3d/web/src/core/acoustics.js +341 -0
  37. zero2geospatial3d/web/src/core/classify.js +158 -0
  38. zero2geospatial3d/web/src/core/comfort.js +686 -0
  39. zero2geospatial3d/web/src/core/daylight_rule.js +199 -0
  40. zero2geospatial3d/web/src/core/dispersion.js +264 -0
  41. zero2geospatial3d/web/src/core/microclimate.js +372 -0
  42. zero2geospatial3d/web/src/core/morphology.js +153 -0
  43. zero2geospatial3d/web/src/core/nsga2.js +181 -0
  44. zero2geospatial3d/web/src/core/party_walls.js +60 -0
  45. zero2geospatial3d/web/src/core/road_sources.js +127 -0
  46. zero2geospatial3d/web/src/core/solar.js +112 -0
  47. zero2geospatial3d/web/src/core/terrain_analysis.js +267 -0
  48. zero2geospatial3d/web/src/core/terrain_math.js +279 -0
  49. zero2geospatial3d/web/src/core/urban_form.js +405 -0
  50. zero2geospatial3d/web/src/core/walkability.js +316 -0
  51. zero2geospatial3d/web/src/core/weather.js +225 -0
  52. zero2geospatial3d/web/src/core/wind.js +131 -0
  53. zero2geospatial3d/web/src/export/check_pdf.js +74 -0
  54. zero2geospatial3d/web/src/export/exporters.js +556 -0
  55. zero2geospatial3d/web/src/export/pdf.js +149 -0
  56. zero2geospatial3d/web/src/export/snapshot.js +234 -0
  57. zero2geospatial3d/web/src/export/snapshot_scene.js +150 -0
  58. zero2geospatial3d/web/src/main.js +75 -0
  59. zero2geospatial3d/web/src/scene/objects.js +645 -0
  60. zero2geospatial3d/web/src/scene/overlay.js +145 -0
  61. zero2geospatial3d/web/src/scene/terrain.js +546 -0
  62. zero2geospatial3d/web/src/scene/viewer.js +275 -0
  63. zero2geospatial3d/web/src/scene/wind_streamlines.js +336 -0
  64. zero2geospatial3d/web/src/style.css +225 -0
  65. zero2geospatial3d/web/src/ui/building_report.js +404 -0
  66. zero2geospatial3d/web/src/ui/charts.js +441 -0
  67. zero2geospatial3d/web/src/ui/colorize.js +101 -0
  68. zero2geospatial3d/web/src/ui/compare.js +362 -0
  69. zero2geospatial3d/web/src/ui/dom.js +104 -0
  70. zero2geospatial3d/web/src/ui/panel_analyze.js +742 -0
  71. zero2geospatial3d/web/src/ui/panel_compare.js +294 -0
  72. zero2geospatial3d/web/src/ui/panel_layers.js +172 -0
  73. zero2geospatial3d/web/src/ui/panel_terrain.js +277 -0
  74. zero2geospatial3d/web/src/ui/qgis_link.js +127 -0
  75. zero2geospatial3d/web/src/ui/sunclock.js +45 -0
  76. zero2geospatial3d/web/src/ui/tools.js +197 -0
  77. zero2geospatial3d/web/src/ui/viewtools.js +173 -0
  78. zero2geospatial3d/web/vendor/three/addons/controls/OrbitControls.js +1417 -0
  79. zero2geospatial3d/web/vendor/three/addons/exporters/GLTFExporter.js +3309 -0
  80. zero2geospatial3d/web/vendor/three/addons/utils/TextureUtils.js +98 -0
  81. zero2geospatial3d/web/vendor/three/three.module.js +53044 -0
  82. zero2geospatial3d/wind.py +178 -0
  83. zero2geospatial3d_sdk-0.1.0.dist-info/METADATA +219 -0
  84. zero2geospatial3d_sdk-0.1.0.dist-info/RECORD +89 -0
  85. zero2geospatial3d_sdk-0.1.0.dist-info/WHEEL +5 -0
  86. zero2geospatial3d_sdk-0.1.0.dist-info/entry_points.txt +2 -0
  87. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
  88. zero2geospatial3d_sdk-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +52 -0
  89. zero2geospatial3d_sdk-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ }