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,278 @@
1
+ // @ts-check
2
+ // A year of outdoor climate around each building, from an EPW file.
3
+ //
4
+ // The open ground within `ringMetres` of a building is that building's
5
+ // surroundings (each open cell goes to its nearest building). For it we take
6
+ // the sky view factor, the sheltering wind ratio per wind sector, and the
7
+ // sunlit share per sun position of the year (grouped into coarse sky bins).
8
+ // Then every hour of the chosen daily window is evaluated with the EPW
9
+ // weather: mean radiant temperature (SolarCal) and UTCI, pedestrian wind.
10
+ import { skyHemisphere, solarPosition, sunVector } from '../core/solar.js';
11
+ import { typicalDoy } from '../core/weather.js';
12
+ import { windRatio, OPEN_PEDESTRIAN_FACTOR } from '../core/wind.js';
13
+ import { meanRadiantTemperature, skyTemperature, utci } from '../core/comfort.js';
14
+ import { splitKey, ExtrudedLayer } from '../scene/objects.js';
15
+
16
+ const DEG = Math.PI / 180;
17
+
18
+ /**
19
+ * Meteorological seasons as month groups (Dec-Feb, Mar-May, Jun-Aug,
20
+ * Sep-Nov). Field codes name the months; the season names follow the
21
+ * hemisphere: Dec-Feb is winter in the north and summer in the south.
22
+ */
23
+ export const SEASONS = [['djf', 'Winter'], ['mam', 'Spring'], ['jja', 'Summer'], ['son', 'Autumn']];
24
+ export function seasonNames(lat) {
25
+ return lat < 0 ? ['Summer', 'Autumn', 'Winter', 'Spring'] : SEASONS.map(([, name]) => name);
26
+ }
27
+ /** Month-group codes of summer and winter at a latitude. */
28
+ export function summerCode(lat) { return lat < 0 ? 'djf' : 'jja'; }
29
+ export function winterCode(lat) { return lat < 0 ? 'jja' : 'djf'; }
30
+ export function seasonOf(month) {
31
+ return month === 12 || month <= 2 ? 0 : month <= 5 ? 1 : month <= 8 ? 2 : 3;
32
+ }
33
+
34
+ /** Open cells within `rings` steps of a building, labelled with its key (multi-source BFS). */
35
+ export function surroundings(ids, heights, cols, rows, rings, isBuilding) {
36
+ const label = new Int32Array(cols * rows);
37
+ const dist = new Uint16Array(cols * rows);
38
+ let queue = [];
39
+ for (let i = 0; i < ids.length; i++) {
40
+ if (ids[i] && isBuilding(ids[i])) { label[i] = ids[i]; queue.push(i); }
41
+ }
42
+ for (let step = 1; step <= rings && queue.length; step++) {
43
+ const next = [];
44
+ for (const i of queue) {
45
+ const c = i % cols, r = (i - c) / cols;
46
+ const nb = [c > 0 ? i - 1 : -1, c < cols - 1 ? i + 1 : -1, r > 0 ? i - cols : -1, r < rows - 1 ? i + cols : -1];
47
+ for (const j of nb) {
48
+ if (j < 0 || label[j] || ids[j] || heights[j] > 0) continue;
49
+ label[j] = label[i];
50
+ dist[j] = step;
51
+ next.push(j);
52
+ }
53
+ }
54
+ queue = next;
55
+ }
56
+ // Building cells themselves are not "surroundings".
57
+ for (let i = 0; i < ids.length; i++) if (ids[i]) label[i] = 0;
58
+ return label;
59
+ }
60
+
61
+ /** A colour range that spreads the actual values (rounded to 5 %). */
62
+ function rangeOf(values) {
63
+ let lo = Infinity, hi = -Infinity;
64
+ for (const v of values) { if (v < lo) lo = v; if (v > hi) hi = v; }
65
+ if (!Number.isFinite(lo)) return { min: 0, max: 100 };
66
+ return { min: Math.max(0, Math.floor(lo / 5) * 5), max: Math.min(100, Math.max(Math.floor(lo / 5) * 5 + 5, Math.ceil(hi / 5) * 5)) };
67
+ }
68
+
69
+ /** Mean of a per-cell array over each building's surroundings. */
70
+ function meansByKey(label, values) {
71
+ const acc = new Map();
72
+ for (let i = 0; i < label.length; i++) {
73
+ const k = label[i];
74
+ if (!k || !Number.isFinite(values[i])) continue;
75
+ let a = acc.get(k);
76
+ if (!a) { a = [0, 0]; acc.set(k, a); }
77
+ a[0] += values[i];
78
+ a[1]++;
79
+ }
80
+ const out = new Map();
81
+ for (const [k, [s, n]] of acc) out.set(k, s / n);
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * @param {any} analyst the Analyst (runners.js)
87
+ * @param {object} o
88
+ */
89
+ export async function climateAroundBuildings(analyst, { area, cell, sectors = 8, hourFrom = 7, hourTo = 22, ringMetres = 15, onProgress }) {
90
+ const app = analyst.app;
91
+ const w = app.weather;
92
+ if (!w?.dryBulb) throw new Error('This analysis needs an EPW weather file (Layers > Scene > Load EPW, or pick one in QGIS).');
93
+ const s = app.site;
94
+ const prog = (a, b) => (f) => onProgress?.(a + (b - a) * f);
95
+ // 1. Geometry, surroundings.
96
+ const og = await analyst.obstacleGrid({ area, cell, onProgress: prog(0, 0.08) });
97
+ const { grid } = og;
98
+ const n = grid.cols * grid.rows;
99
+ const buildingLayers = new Set(app.layers.filter((l) => l instanceof ExtrudedLayer && !l.isWall).map((l) => l.index));
100
+ const label = surroundings(og.ids, og.heights, grid.cols, grid.rows, Math.max(1, Math.round(ringMetres / cell)),
101
+ (key) => buildingLayers.has(splitKey(key).layerIndex));
102
+ // 2. Sky view at pedestrian height.
103
+ const sky = await analyst.gridRun({ area, cell, directions: skyHemisphere(96), cosMode: 1, lift: 1.1, onProgress: prog(0.08, 0.2) });
104
+ const svfB = meansByKey(label, sky.values);
105
+ // 3. Wind per sector from the EPW, sheltering ratio per sector.
106
+ const sectorFreq = new Float64Array(sectors), sectorSpeed = new Float64Array(sectors);
107
+ const seasonFreq = SEASONS.map(() => new Float64Array(sectors));
108
+ const seasonSpeed = SEASONS.map(() => new Float64Array(sectors));
109
+ const seasonHours = new Float64Array(4), seasonCalm = new Float64Array(4);
110
+ let calmHours = 0, windowHours = 0;
111
+ const inWindow = (i) => w.hour[i] - 1 >= hourFrom && w.hour[i] - 1 < hourTo;
112
+ for (let i = 0; i < w.hours; i++) {
113
+ if (!inWindow(i)) continue;
114
+ windowHours++;
115
+ const sea = seasonOf(w.month[i]);
116
+ seasonHours[sea]++;
117
+ if (w.windSpeed[i] < 0.5) { calmHours++; seasonCalm[sea]++; continue; }
118
+ const k = Math.floor((((w.windDir[i] % 360) + 360) % 360) / (360 / sectors) + 0.5) % sectors;
119
+ sectorFreq[k]++;
120
+ sectorSpeed[k] += w.windSpeed[i];
121
+ seasonFreq[sea][k]++;
122
+ seasonSpeed[sea][k] += w.windSpeed[i];
123
+ }
124
+ const ratioB = new Map(); // key -> Float32Array(sectors)
125
+ const annualWind = new Float32Array(n);
126
+ for (let k = 0; k < sectors; k++) {
127
+ if (!sectorFreq[k]) continue;
128
+ sectorSpeed[k] /= sectorFreq[k];
129
+ const ratio = await windRatio({ heights: og.heights, porosity: og.porosity, cols: grid.cols, rows: grid.rows, cell,
130
+ windFromDeg: (k * 360) / sectors, onProgress: (f) => prog(0.2 + (0.25 * k) / sectors, 0.2 + (0.25 * (k + 1)) / sectors)(f) });
131
+ const share = sectorFreq[k] / Math.max(1, windowHours);
132
+ for (let i = 0; i < n; i++) if (Number.isFinite(ratio[i])) annualWind[i] += share * sectorSpeed[k] * ratio[i] * OPEN_PEDESTRIAN_FACTOR;
133
+ for (const [key, m] of meansByKey(label, ratio)) {
134
+ if (!ratioB.has(key)) ratioB.set(key, new Float32Array(sectors).fill(1));
135
+ ratioB.get(key)[k] = m;
136
+ }
137
+ }
138
+ for (let i = 0; i < n; i++) if (og.ids[i] || !Number.isFinite(sky.values[i])) annualWind[i] = NaN;
139
+ // 4. Sun: coarse sky bins of the window's sun positions; sunlit share per bin.
140
+ const binOfHour = new Int16Array(w.hours).fill(-1);
141
+ const bins = new Map();
142
+ const elevOfHour = new Float32Array(w.hours);
143
+ for (let i = 0; i < w.hours; i++) {
144
+ if (!inWindow(i)) continue;
145
+ const p = solarPosition(typicalDoy(w.month[i], w.day[i]), w.hour[i] - 0.5, s.lat, s.lon, s.utcOffset);
146
+ elevOfHour[i] = p.elevation / DEG;
147
+ if (p.elevation <= 1 * DEG) continue;
148
+ const key = Math.floor(p.elevation / (10 * DEG)) * 100 + Math.floor(p.azimuth / (15 * DEG));
149
+ let b = bins.get(key);
150
+ if (!b) { b = { id: bins.size, v: [0, 0, 0] }; bins.set(key, b); }
151
+ const v = sunVector(p.elevation, p.azimuth);
152
+ b.v[0] += v[0]; b.v[1] += v[1]; b.v[2] += v[2];
153
+ binOfHour[i] = b.id;
154
+ }
155
+ const binList = [...bins.values()];
156
+ const sunlitB = new Map(); // key -> Float32Array(bins)
157
+ for (let j = 0; j < binList.length; j++) {
158
+ const v = binList[j].v;
159
+ const len = Math.hypot(v[0], v[1], v[2]) || 1;
160
+ const run = await analyst.gridRun({ area, cell, directions: [{ dir: [v[0] / len, v[1] / len, v[2] / len], weight: 1 }], lift: 1.1,
161
+ onProgress: prog(0.45 + (0.4 * j) / binList.length, 0.45 + (0.4 * (j + 1)) / binList.length) });
162
+ for (const [key, m] of meansByKey(label, run.values)) {
163
+ if (!sunlitB.has(key)) sunlitB.set(key, new Float32Array(binList.length));
164
+ sunlitB.get(key)[j] = m;
165
+ }
166
+ }
167
+ // 5. Every hour of the window, for every building.
168
+ const keys = [...svfB.keys()];
169
+ const blank = () => ({ comfort: 0, heat: 0, cold: 0, windSum: 0, windy: 0, hours: 0 });
170
+ const stats = new Map(keys.map((k) => [k, { ...blank(), seasons: SEASONS.map(blank) }]));
171
+ for (let i = 0; i < w.hours; i++) {
172
+ if (!inWindow(i)) continue;
173
+ const ta = w.dryBulb[i], rh = w.rh[i], ws = w.windSpeed[i];
174
+ const k = ws < 0.5 ? -1 : Math.floor((((w.windDir[i] % 360) + 360) % 360) / (360 / sectors) + 0.5) % sectors;
175
+ const tSky = skyTemperature(ta, w.ir[i]);
176
+ const bin = binOfHour[i];
177
+ const sea = seasonOf(w.month[i]);
178
+ for (const key of keys) {
179
+ const svf = svfB.get(key);
180
+ const r = k < 0 ? 1 : (ratioB.get(key)?.[k] ?? 1);
181
+ const sunlit = bin >= 0 ? (sunlitB.get(key)?.[bin] ?? 0) : 0;
182
+ const tr = meanRadiantTemperature({ airTemp: ta, skyTemp: tSky, svf, sunlit, dni: w.dni[i], dhi: w.dhi[i], ghi: w.ghi[i], elevationDeg: elevOfHour[i] });
183
+ const v10 = Math.max(0.5, Math.min(17, ws * r));
184
+ const u = utci(ta, tr, v10, rh);
185
+ const ped = ws * r * OPEN_PEDESTRIAN_FACTOR;
186
+ const all = stats.get(key);
187
+ for (const st of [all, all.seasons[sea]]) {
188
+ st.hours++;
189
+ if (u >= 9 && u < 26) st.comfort++;
190
+ else if (u >= 32) st.heat++;
191
+ else if (u < 0) st.cold++;
192
+ st.windSum += ped;
193
+ if (ped > 5) st.windy++;
194
+ }
195
+ }
196
+ if (i % 400 === 0) { onProgress?.(0.85 + (0.15 * i) / w.hours); await new Promise((res) => setTimeout(res, 0)); }
197
+ }
198
+ // 6. Fields per building, a comfort halo grid, the wind map.
199
+ const perLayer = new Map();
200
+ const field = (layer, name) => {
201
+ let m = perLayer.get(layer);
202
+ if (!m) { m = new Map(); perLayer.set(layer, m); }
203
+ if (!m.has(name)) m.set(name, new Float32Array(layer.count).fill(NaN));
204
+ return m.get(name);
205
+ };
206
+ const comfortOfKey = new Map();
207
+ const seasonComfortOfKey = SEASONS.map(() => new Map());
208
+ const seasonal = new Map(); // key -> per-season summary for the report
209
+ for (const [key, st] of stats) {
210
+ const { layerIndex, id } = splitKey(key);
211
+ const layer = app.layers.find((l) => l.index === layerIndex);
212
+ if (!layer || !st.hours) continue;
213
+ const f = id - 1;
214
+ const pct = (100 * st.comfort) / st.hours;
215
+ comfortOfKey.set(key, pct);
216
+ field(layer, 'comfort_pct')[f] = pct;
217
+ field(layer, 'heat_stress_h')[f] = st.heat;
218
+ field(layer, 'cold_stress_h')[f] = st.cold;
219
+ field(layer, 'ped_wind_ms')[f] = st.windSum / st.hours;
220
+ field(layer, 'windy_pct')[f] = (100 * st.windy) / st.hours;
221
+ field(layer, 'svf_ground')[f] = svfB.get(key);
222
+ const rows = st.seasons.map((x) => (x.hours ? { comfort: (100 * x.comfort) / x.hours, heat: x.heat, cold: x.cold,
223
+ wind: x.windSum / x.hours, windy: (100 * x.windy) / x.hours, hours: x.hours } : null));
224
+ seasonal.set(key, rows);
225
+ SEASONS.forEach(([code], s) => {
226
+ if (!rows[s]) return;
227
+ seasonComfortOfKey[s].set(key, rows[s].comfort);
228
+ field(layer, `comfort_pct_${code}`)[f] = rows[s].comfort;
229
+ });
230
+ const summer = rows[SEASONS.findIndex(([code]) => code === summerCode(s.lat))];
231
+ const winter = rows[SEASONS.findIndex(([code]) => code === winterCode(s.lat))];
232
+ if (summer) field(layer, `heat_stress_h_${summerCode(s.lat)}`)[f] = summer.heat;
233
+ if (winter) {
234
+ field(layer, `cold_stress_h_${winterCode(s.lat)}`)[f] = winter.cold;
235
+ field(layer, `ped_wind_ms_${winterCode(s.lat)}`)[f] = winter.wind;
236
+ field(layer, `windy_pct_${winterCode(s.lat)}`)[f] = winter.windy;
237
+ }
238
+ }
239
+ const fields = [];
240
+ for (const [layer, m] of perLayer) for (const [name, values] of m) fields.push({ layer, name, values });
241
+ const haloOf = (map) => {
242
+ const out = new Float32Array(n).fill(NaN);
243
+ for (let i = 0; i < n; i++) if (label[i] && map.has(label[i])) out[i] = map.get(label[i]);
244
+ return out;
245
+ };
246
+ const halo = haloOf(comfortOfKey);
247
+ const haloSeasons = { year: halo };
248
+ SEASONS.forEach(([code], s) => { haloSeasons[code] = haloOf(seasonComfortOfKey[s]); });
249
+ const allComfort = [...comfortOfKey.values(), ...seasonComfortOfKey.flatMap((m) => [...m.values()])];
250
+ // Per-building wind roses for the report.
251
+ const freq = Array.from(sectorFreq, (x) => x / Math.max(1, windowHours));
252
+ const names = seasonNames(s.lat);
253
+ const bySeason = SEASONS.map(([code], k) => ({ code, name: names[k],
254
+ freq: Array.from(seasonFreq[k], (x) => x / Math.max(1, seasonHours[k])),
255
+ speed: Array.from(seasonSpeed[k], (x, j) => (seasonFreq[k][j] ? x / seasonFreq[k][j] : 0)),
256
+ calm: seasonCalm[k] / Math.max(1, seasonHours[k]) }));
257
+ app.climateRoses = { sectors, freq, speed: Array.from(sectorSpeed), calm: calmHours / Math.max(1, windowHours), ratios: ratioB,
258
+ window: [hourFrom, hourTo], source: w.location.city || app.weatherSource, bySeason };
259
+ app.climateSeasonal = seasonal;
260
+ const windowLabel = `${String(hourFrom).padStart(2, '0')}:00–${String(hourTo).padStart(2, '0')}:00`;
261
+ let sum = 0, cnt = 0;
262
+ for (const v of comfortOfKey.values()) { sum += v; cnt++; }
263
+ let windMax = 1;
264
+ for (const v of annualWind) if (v > windMax) windMax = v; // no spread: the grid can hold millions of cells
265
+ return [{
266
+ kind: 'climate', title: 'Comfortable hours around buildings (year)', subtitle: `${windowLabel}, ${w.location.city || 'EPW'}; UTCI 9–26 °C`,
267
+ unit: '%', grid, values: halo, ids: og.ids, display: { ...rangeOf(allComfort), ramp: 'rdylbu' }, fields,
268
+ seasonal: haloSeasons, season: 'year',
269
+ stats: [['Buildings', String(cnt)], ['Mean comfortable share', `${cnt ? (sum / cnt).toFixed(0) : 0} %`],
270
+ ['Hours evaluated per building', String(windowHours)], ['Sun positions', String(binList.length)]],
271
+ notes: 'Each building\'s surroundings: open ground within ' + ringMetres + ' m. Hourly UTCI from the EPW weather with the local sky view, sun and sheltered wind (screening model). Fields: comfort_pct, heat_stress_h, cold_stress_h, ped_wind_ms, windy_pct, svf_ground; per month group (djf = Dec–Feb …) comfort_pct_djf/mam/jja/son; summer heat_stress_h and winter cold_stress_h, ped_wind_ms and windy_pct of the hemisphere\'s summer and winter months.'
272
+ }, {
273
+ kind: 'windyear', title: 'Annual pedestrian wind', subtitle: `${windowLabel}, frequency-weighted over ${sectors} directions`,
274
+ unit: 'm/s', grid, values: annualWind, ids: og.ids, display: { min: 0, max: Math.ceil(windMax), ramp: 'spectral-r' },
275
+ fields: [], stats: [['Calm hours', `${((100 * calmHours) / Math.max(1, windowHours)).toFixed(0)} %`]],
276
+ notes: 'Mean of the sheltered pedestrian wind over the year (screening, not CFD).'
277
+ }];
278
+ }
@@ -0,0 +1,345 @@
1
+ // @ts-check
2
+ // Planning check: pass / fail of each building against sun, daylight, wind
3
+ // and heat criteria, with the analyses it needs run on demand.
4
+ //
5
+ // These are screening proxies of the named guidance, not formal assessments:
6
+ // BRE 209 (Site layout planning for daylight and sunlight, 3rd ed. 2022) asks
7
+ // for VSC on window centres with an overcast sky; here the facade sky view
8
+ // factor stands in for it.
9
+ import { sunPath } from '../core/solar.js';
10
+ import { typicalDoy } from '../core/weather.js';
11
+ import { surroundings, summerCode } from './climate.js';
12
+ import { featureKey, splitKey, ExtrudedLayer } from '../scene/objects.js';
13
+
14
+ export const CRITERIA = [
15
+ { id: 'amenity_sun', label: 'Sun on surrounding open ground (21 Mar)', short: 'Amenity sun',
16
+ unit: '%', test: 'min', threshold: 50,
17
+ help: 'Share of the open ground within 15 m that gets at least 2 h of sun on 21 March. BRE 209: at least half of an amenity area.' },
18
+ { id: 'facade_vsc', label: 'Daylight on the walls (VSC)', short: 'Daylight',
19
+ unit: '%', test: 'min', threshold: 27,
20
+ help: 'Mean vertical sky component of the walls under the CIE overcast sky. BRE 209: 27 % or more at a window keeps enough daylight (39.6 % is an open wall).' },
21
+ { id: 'neighbour_daylight', label: 'Daylight kept after scenario B (VSC B / A)', short: 'B / A daylight',
22
+ unit: '×', test: 'min', threshold: 0.8,
23
+ help: 'Wall VSC with scenario B divided by that with A. BRE 209: less than 0.8 times the former value is a noticeable loss. Needs "Run A vs B" with daylight (VSC).' },
24
+ { id: 'wind', label: 'Windy hours around it', short: 'Wind',
25
+ unit: '%', test: 'max', threshold: 5,
26
+ help: 'Share of the hours with pedestrian wind above 5 m/s (needs the climate year and an EPW).' },
27
+ { id: 'heat', label: 'Summer heat-stress hours around it', short: 'Summer heat',
28
+ unit: 'h', test: 'max', threshold: 300,
29
+ help: 'Summer hours with UTCI of 32 °C or more on the surrounding ground (needs the climate year and an EPW). The limit is a project choice.' },
30
+ { id: 'facade_noise', label: 'Max facade noise level (Lden)', short: 'Facade noise',
31
+ unit: 'dB(A)', test: 'max', threshold: 65,
32
+ help: 'Highest road traffic noise level at the building perimeter. 65 dB Lden is a common noise action-plan level; the WHO 2018 guideline for road traffic is 53 dB.' },
33
+ { id: 'green_view', label: 'Green View Index around it (GVI)', short: 'Green view',
34
+ unit: '%', test: 'min', threshold: 15,
35
+ help: 'Mean pedestrian-level Green View Index on the surrounding open ground.' },
36
+ { id: 'air_no2', label: 'Street air quality around it (NO2)', short: 'Air NO2',
37
+ unit: 'µg/m³', test: 'max', threshold: 40,
38
+ help: 'Mean street-canyon NO2 concentration on the surrounding open ground. 40 µg/m³ is the EU annual limit; the WHO 2021 guideline is 10 µg/m³.' },
39
+ { id: 'canyon_vent', label: 'Canyon ventilation efficiency (VEI)', short: 'Ventilation',
40
+ unit: '', test: 'min', threshold: 0.35,
41
+ help: 'Mean street-canyon Ventilation Efficiency Index on the surrounding open ground (>= 0.35 avoids stagnant canyon pockets).' },
42
+ { id: 'solar_roof', label: 'Rooftop solar irradiation', short: 'Solar roof',
43
+ unit: 'kWh/m²', test: 'min', threshold: 800,
44
+ help: 'Mean annual solar irradiation on the building roof (>= 800 kWh/m² is suitable for rooftop PV).' }
45
+ ];
46
+
47
+ export const PRESET_PROFILES = [
48
+ { key: 'defaults', name: 'Defaults (BRE 209 & Urban Environment)',
49
+ criteria: ['amenity_sun', 'facade_vsc', 'neighbour_daylight', 'wind', 'heat', 'facade_noise', 'green_view'] },
50
+ { key: 'bre209', name: 'BRE 209 Daylight & Sunlight',
51
+ criteria: ['amenity_sun', 'facade_vsc', 'neighbour_daylight'] },
52
+ { key: 'who_health', name: 'WHO Urban Health (Noise, Air, Heat & Green)',
53
+ criteria: ['heat', 'facade_noise', 'air_no2', 'canyon_vent', 'green_view'],
54
+ thresholds: { facade_noise: 55, air_no2: 25, green_view: 20 } },
55
+ { key: 'solar_district', name: 'Solar & Energy-Ready District',
56
+ criteria: ['amenity_sun', 'facade_vsc', 'solar_roof'],
57
+ thresholds: { solar_roof: 900 } },
58
+ { key: 'complete_audit', name: 'Complete 10-Point Urban Audit',
59
+ criteria: CRITERIA.map((c) => c.id) }
60
+ ];
61
+
62
+ export function presetProfile(key) {
63
+ const base = defaultProfile();
64
+ const found = PRESET_PROFILES.find((p) => p.key === key);
65
+ if (!found) return base;
66
+ return {
67
+ name: found.name,
68
+ criteria: [...found.criteria],
69
+ thresholds: { ...base.thresholds, ...(found.thresholds || {}) }
70
+ };
71
+ }
72
+
73
+ export const PROFILE_FORMAT = '02geo3d-planning-profile';
74
+ // Criteria renamed since a profile may have been saved: the old id switches
75
+ // the new criterion on, but its threshold is not carried over (other units).
76
+ const RENAMED = { facade_daylight: 'facade_vsc' };
77
+
78
+ /** The built-in profile: every criterion at its default threshold. */
79
+ export function defaultProfile() {
80
+ return { name: 'Defaults (BRE 209 proxies)', criteria: CRITERIA.map((c) => c.id), thresholds: Object.fromEntries(CRITERIA.map((c) => [c.id, c.threshold])) };
81
+ }
82
+
83
+ /** A profile as a JSON file: name, the criteria in use and their thresholds. */
84
+ export function profileJson(p) {
85
+ return JSON.stringify({
86
+ format: PROFILE_FORMAT, version: 1, name: p.name || 'Planning profile',
87
+ criteria: CRITERIA.map((c) => ({ id: c.id, label: c.label, unit: c.unit, test: c.test === 'min' ? 'at least' : 'at most',
88
+ enabled: p.criteria.includes(c.id), threshold: p.thresholds[c.id] ?? c.threshold }))
89
+ }, null, 2);
90
+ }
91
+
92
+ /**
93
+ * Read a profile file. Unknown criteria are ignored, missing ones keep their
94
+ * default and stay off; a threshold must be a finite number.
95
+ * @returns {{ name: string, criteria: string[], thresholds: Record<string, number>, ignored: string[] }}
96
+ */
97
+ export function parseProfile(text) {
98
+ let data;
99
+ try { data = typeof text === 'string' ? JSON.parse(text) : text; } catch { throw new Error('This file is not JSON.'); }
100
+ if (!data || data.format !== PROFILE_FORMAT || !Array.isArray(data.criteria)) throw new Error('This is not a planning-check profile.');
101
+ const known = new Map(CRITERIA.map((c) => [c.id, c]));
102
+ const out = defaultProfile();
103
+ out.name = String(data.name || 'Planning profile').slice(0, 80);
104
+ out.criteria = [];
105
+ const ignored = [];
106
+ for (const item of data.criteria) {
107
+ const renamed = RENAMED[item?.id];
108
+ if (renamed) {
109
+ if (item.enabled !== false && !out.criteria.includes(renamed)) out.criteria.push(renamed);
110
+ continue;
111
+ }
112
+ const c = known.get(item?.id);
113
+ if (!c) { ignored.push(String(item?.id)); continue; }
114
+ const v = Number(item.threshold);
115
+ if (item.threshold !== undefined && item.threshold !== null && Number.isFinite(v)) out.thresholds[c.id] = v;
116
+ if (item.enabled !== false) out.criteria.push(c.id);
117
+ }
118
+ return { ...out, ignored };
119
+ }
120
+
121
+ /** Pass (true), fail (false) or not available (null) for one value. */
122
+ export function judge(criterion, value) {
123
+ if (!Number.isFinite(value)) return null;
124
+ return criterion.test === 'min' ? value >= criterion.threshold : value <= criterion.threshold;
125
+ }
126
+
127
+ /** Buildings to check: the selection, scenario B layers, all in the area, or a given list ('targets'). */
128
+ export function scopeBuildings(app, scope, targets = null) {
129
+ if (scope === 'targets') return [...(targets || [])];
130
+ const out = [];
131
+ const layers = app.layers.filter((l) => l instanceof ExtrudedLayer && !l.isWall);
132
+ if (scope === 'selected') {
133
+ for (const l of layers) for (const f of app.selectedIndices(l)) out.push({ layer: l, f });
134
+ } else if (scope === 'scenarioB') {
135
+ for (const l of layers.filter((x) => x.entry.scenario === 'B')) for (let f = 0; f < l.count; f++) out.push({ layer: l, f });
136
+ } else {
137
+ const a = app.area();
138
+ for (const l of layers) {
139
+ if (!l.visible) continue;
140
+ for (let f = 0; f < l.count; f++) {
141
+ const x = l.centroid[f * 2], y = l.centroid[f * 2 + 1];
142
+ if (x >= a.minX && x <= a.maxX && y >= a.minY && y <= a.maxY) out.push({ layer: l, f });
143
+ }
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ function areaAround(app, targets, margin = 150) {
150
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
151
+ for (const { layer, f } of targets) {
152
+ const x = layer.centroid[f * 2], y = layer.centroid[f * 2 + 1];
153
+ minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y);
154
+ }
155
+ const sc = app.sceneArea();
156
+ return { minX: Math.max(sc.minX, minX - margin), maxX: Math.min(sc.maxX, maxX + margin),
157
+ minY: Math.max(sc.minY, minY - margin), maxY: Math.min(sc.maxY, maxY + margin) };
158
+ }
159
+
160
+ /**
161
+ * Run the check.
162
+ * @param {any} app
163
+ * @param {{ scope: string, criteria: string[], thresholds: Record<string, number>, onProgress?: (f: number) => void }} o
164
+ */
165
+ export async function planningCheck(app, { scope, criteria, thresholds, profile = '', targets: given = null, fresh = false, onProgress }) {
166
+ const targets = scopeBuildings(app, scope, given);
167
+ if (!targets.length) throw new Error(scope === 'selected' ? 'Select buildings first (click, Shift+click, or select them in QGIS).' : 'No buildings to check.');
168
+ const active = CRITERIA.filter((c) => criteria.includes(c.id)).map((c) => ({ ...c, threshold: thresholds[c.id] ?? c.threshold }));
169
+ const area = scope === 'area' ? app.area() : areaAround(app, targets);
170
+ const cell = Math.max(1, Math.min(2, app.analyst.defaultCell(area, 1024)));
171
+ const a = app.analyst;
172
+ const values = new Map(active.map((c) => [c.id, new Map()])); // id -> key -> value
173
+ const keyOf = ({ layer, f }) => featureKey(layer.index, f + 1);
174
+ const want = new Set(targets.map(keyOf));
175
+ const step = (k) => (f) => onProgress?.((k + f) / 4);
176
+ // 1. Amenity sun: 2 h on 21 March over each building's surroundings.
177
+ if (values.has('amenity_sun')) {
178
+ const s = app.site;
179
+ const dirs = sunPath(typicalDoy(3, 21), s.lat, s.lon, s.utcOffset, { stepMinutes: 10 });
180
+ const run = await a.gridRun({ area, cell, directions: dirs, withIds: true, lift: 0.3, onProgress: step(0) });
181
+ const label = surroundings(run.ids, new Float32Array(run.ids.length), run.grid.cols, run.grid.rows, Math.round(15 / cell), (k) => want.has(k));
182
+ const acc = new Map();
183
+ for (let i = 0; i < label.length; i++) {
184
+ const k = label[i];
185
+ if (!k || !Number.isFinite(run.values[i])) continue;
186
+ const v = acc.get(k) || [0, 0];
187
+ v[1]++;
188
+ if (run.values[i] >= 2) v[0]++;
189
+ acc.set(k, v);
190
+ }
191
+ for (const [k, [ok, n]] of acc) values.get('amenity_sun').set(k, (100 * ok) / n);
192
+ }
193
+ // 2. Daylight on the walls: vertical sky component.
194
+ if (values.has('facade_vsc')) {
195
+ const res = await a.daylightVsc({ area, directions: 192, onProgress: step(1) });
196
+ for (const fld of res[0].fields) {
197
+ fld.layer.setResultField(fld.name, fld.values);
198
+ if (fld.name !== 'vsc_facade') continue;
199
+ fld.values.forEach((v, i) => { if (Number.isFinite(v)) values.get('facade_vsc').set(featureKey(fld.layer.index, i + 1), v); });
200
+ }
201
+ }
202
+ // 3. Neighbour daylight after scenario B (from a previous "Run A vs B" with daylight).
203
+ if (values.has('neighbour_daylight')) {
204
+ for (const t of targets) {
205
+ const A = t.layer.extraFields.get('vsc_facade_A')?.[t.f];
206
+ const B = t.layer.extraFields.get('vsc_facade_B')?.[t.f];
207
+ if (Number.isFinite(A) && Number.isFinite(B) && A > 0) values.get('neighbour_daylight').set(keyOf(t), B / A);
208
+ }
209
+ }
210
+ // 4-5. Wind and summer heat from the climate year (run it if an EPW exists).
211
+ const needClimate = values.has('wind') || values.has('heat');
212
+ if (needClimate && app.weather?.dryBulb) {
213
+ // fresh: heights have changed since the stored climate fields were made.
214
+ const missing = fresh || targets.some((t) => !Number.isFinite(t.layer.extraFields.get('windy_pct')?.[t.f]));
215
+ if (missing) {
216
+ const res = await a.climateYear({ area, cell: Math.max(2, cell), sectors: 8, hourFrom: 7, hourTo: 22, ringMetres: 15, onProgress: step(2) });
217
+ for (const fld of res[0].fields) fld.layer.setResultField(fld.name, fld.values);
218
+ }
219
+ for (const t of targets) {
220
+ const k = keyOf(t);
221
+ if (values.has('wind')) values.get('wind').set(k, t.layer.extraFields.get('windy_pct')?.[t.f]);
222
+ if (values.has('heat')) values.get('heat').set(k, t.layer.extraFields.get(`heat_stress_h_${summerCode(app.site.lat)}`)?.[t.f]);
223
+ }
224
+ }
225
+ // 6. Facade noise level (Lden).
226
+ if (values.has('facade_noise')) {
227
+ const missing = fresh || targets.some((t) => !Number.isFinite(t.layer.extraFields.get('noise_dba_max')?.[t.f]));
228
+ if (missing) {
229
+ const res = await a.noise({ area, cell: Math.max(2, cell) });
230
+ for (const fld of res[0].fields) fld.layer.setResultField(fld.name, fld.values);
231
+ }
232
+ for (const t of targets) {
233
+ values.get('facade_noise').set(keyOf(t), t.layer.extraFields.get('noise_dba_max')?.[t.f]);
234
+ }
235
+ }
236
+ // 7. Green View Index (GVI).
237
+ if (values.has('green_view')) {
238
+ const missing = fresh || targets.some((t) => !Number.isFinite(t.layer.extraFields.get('gvi_pct')?.[t.f]));
239
+ if (missing) {
240
+ const res = await a.greenView({ area, cell: Math.max(2, cell) });
241
+ for (const fld of res[0].fields) fld.layer.setResultField(fld.name, fld.values);
242
+ }
243
+ for (const t of targets) {
244
+ values.get('green_view').set(keyOf(t), t.layer.extraFields.get('gvi_pct')?.[t.f]);
245
+ }
246
+ }
247
+ // 8-9. Street air quality (NO2) and canyon ventilation efficiency (VEI).
248
+ if (values.has('air_no2') || values.has('canyon_vent')) {
249
+ const missing = fresh || targets.some((t) => (
250
+ (values.has('air_no2') && !Number.isFinite(t.layer.extraFields.get('air_no2_ugm3')?.[t.f])) ||
251
+ (values.has('canyon_vent') && !Number.isFinite(t.layer.extraFields.get('vent_index')?.[t.f]))
252
+ ));
253
+ if (missing) {
254
+ const res = await a.ventilation({ area, cell: Math.max(2, cell) });
255
+ for (const item of res) {
256
+ for (const fld of item.fields || []) fld.layer.setResultField(fld.name, fld.values);
257
+ }
258
+ }
259
+ for (const t of targets) {
260
+ const k = keyOf(t);
261
+ if (values.has('air_no2')) values.get('air_no2').set(k, t.layer.extraFields.get('air_no2_ugm3')?.[t.f]);
262
+ if (values.has('canyon_vent')) values.get('canyon_vent').set(k, t.layer.extraFields.get('vent_index')?.[t.f]);
263
+ }
264
+ }
265
+ // 10. Rooftop solar irradiation (kWh/m²).
266
+ if (values.has('solar_roof')) {
267
+ const missing = fresh || targets.some((t) => !Number.isFinite(t.layer.extraFields.get('roof_kwh_m2')?.[t.f]));
268
+ if (missing) {
269
+ const res = await a.solarRadiation({ area, cell: Math.max(2, cell) });
270
+ for (const fld of res[0].fields) fld.layer.setResultField(fld.name, fld.values);
271
+ }
272
+ for (const t of targets) {
273
+ values.get('solar_roof').set(keyOf(t), t.layer.extraFields.get('roof_kwh_m2')?.[t.f]);
274
+ }
275
+ }
276
+ onProgress?.(1);
277
+ // Verdicts.
278
+ const rows = targets.map((t) => {
279
+ const k = keyOf(t);
280
+ const props = t.layer.features[t.f].properties;
281
+ const checks = active.map((c) => {
282
+ const v = values.get(c.id).get(k);
283
+ return { id: c.id, value: Number.isFinite(v) ? v : NaN, pass: judge(c, v) };
284
+ });
285
+ const failed = checks.filter((c) => c.pass === false).length;
286
+ const passed = checks.filter((c) => c.pass === true).length;
287
+ return { key: k, layer: t.layer, f: t.f, name: String(props.name || props.NAME || `#${t.f + 1}`), height: t.layer.heights[t.f], checks, failed, passed };
288
+ });
289
+ rows.sort((p, q) => q.failed - p.failed || p.name.localeCompare(q.name));
290
+ // Fields: per criterion 1 / 0, and the number failed.
291
+ const fields = [];
292
+ const byLayer = new Map();
293
+ for (const r of rows) {
294
+ let m = byLayer.get(r.layer);
295
+ if (!m) {
296
+ m = { failed: new Float32Array(r.layer.count).fill(NaN), per: new Map(active.map((c) => [c.id, new Float32Array(r.layer.count).fill(NaN)])) };
297
+ byLayer.set(r.layer, m);
298
+ }
299
+ m.failed[r.f] = r.failed;
300
+ for (const ch of r.checks) if (ch.pass !== null) m.per.get(ch.id)[r.f] = ch.pass ? 1 : 0;
301
+ }
302
+ for (const [layer, m] of byLayer) {
303
+ fields.push({ layer, name: 'check_failed', values: m.failed });
304
+ for (const [id, v] of m.per) fields.push({ layer, name: `check_${id}`, values: v });
305
+ }
306
+ const passAll = rows.filter((r) => r.failed === 0 && r.passed > 0).length;
307
+ const stats = [['Buildings checked', String(rows.length)], ['Pass every available check', `${passAll} (${Math.round((100 * passAll) / rows.length)} %)`]];
308
+ for (const c of active) {
309
+ const judged = rows.map((r) => r.checks.find((x) => x.id === c.id)).filter((x) => x.pass !== null);
310
+ stats.push([c.short, judged.length ? `${judged.filter((x) => x.pass).length} / ${judged.length} pass` : 'n/a']);
311
+ }
312
+ const result = {
313
+ kind: 'compliance', title: 'Planning check', subtitle: `${rows.length} buildings · ${active.length} criteria`, unit: '',
314
+ fields, stats, rows, criteria: active, scope, profile,
315
+ legend: [{ color: '#2e9e5b', label: 'Passes every check' }, { color: '#f0b429', label: 'Fails one' }, { color: '#d64545', label: 'Fails two or more' },
316
+ { color: '#c9c9c9', label: 'Not checked' }],
317
+ notes: 'Screening proxies of the named guidance (BRE 209 for sun and daylight); confirm borderline cases with a formal assessment.'
318
+ };
319
+ // In QGIS: the same three colours on check_failed, in 2D and in the 3D view.
320
+ const hex = (rgb) => `#${rgb.map((x) => Math.round(x * 255).toString(16).padStart(2, '0')).join('')}`;
321
+ result.qgisStyle = {
322
+ field: 'check_failed',
323
+ categories: [{ value: 0, color: hex(VERDICT[0]), label: 'Passes every check' }, { value: 1, color: hex(VERDICT[1]), label: 'Fails one' },
324
+ ...[2, 3, 4, 5].map((k) => ({ value: k, color: hex(VERDICT[2]), label: `Fails ${k} checks` }))],
325
+ other: { color: '#c9c9c9', label: 'Not checked' }
326
+ };
327
+ // Show it as building colours while the result is on screen.
328
+ result.onShow = () => paintVerdicts(app, rows);
329
+ result.onHide = () => { for (const l of app.layers) if (l instanceof ExtrudedLayer) app.recolor(l); };
330
+ return [result];
331
+ }
332
+
333
+ const VERDICT = { 0: [0.18, 0.62, 0.36], 1: [0.94, 0.71, 0.16], 2: [0.84, 0.27, 0.27] };
334
+ function paintVerdicts(app, rows) {
335
+ const touched = new Set();
336
+ for (const l of app.layers) {
337
+ if (!(l instanceof ExtrudedLayer) || l.isWall) continue;
338
+ for (let f = 0; f < l.count; f++) l.colors.set([0.79, 0.79, 0.79], f * 3);
339
+ touched.add(l);
340
+ }
341
+ for (const r of rows) r.layer.colors.set(VERDICT[Math.min(2, r.failed)], r.f * 3);
342
+ for (const l of touched) l.applyColors();
343
+ }
344
+
345
+ export { splitKey };