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,373 @@
|
|
|
1
|
+
"""Weather: EnergyPlus EPW parsing, monthly summaries, wind rose, annual
|
|
2
|
+
sun-direction bins for radiation, and a clear-sky year when no EPW exists.
|
|
3
|
+
|
|
4
|
+
Ported from the 02Geo3D QGIS plugin (``web/src/core/weather.js``).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Optional, Sequence, Union
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from .solar import solar_position, sun_vector
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"MONTH_NAMES",
|
|
21
|
+
"Weather",
|
|
22
|
+
"annual_sun_bins",
|
|
23
|
+
"clear_sky_year",
|
|
24
|
+
"conditions_at",
|
|
25
|
+
"hour_index",
|
|
26
|
+
"monthly_summary",
|
|
27
|
+
"parse_epw",
|
|
28
|
+
"read_epw",
|
|
29
|
+
"typical_doy",
|
|
30
|
+
"wind_rose",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
MONTH_DAYS = (31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
|
|
34
|
+
MONTH_START = tuple(int(x) for x in np.concatenate(([0], np.cumsum(MONTH_DAYS)[:-1])))
|
|
35
|
+
MONTH_NAMES = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
|
36
|
+
HOURS = 8760
|
|
37
|
+
|
|
38
|
+
_NUMERIC_ROW = re.compile(r"^\s*-?\d")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Weather:
|
|
43
|
+
"""Hourly weather for a 365-day typical year (8760 records).
|
|
44
|
+
|
|
45
|
+
Arrays are indexed by hour of the year (0 = 1 January, hour ending 01:00).
|
|
46
|
+
Fields that a source does not provide (e.g. air temperature for a
|
|
47
|
+
:func:`clear_sky_year`) are ``None``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
location: dict[str, Any]
|
|
51
|
+
month: np.ndarray
|
|
52
|
+
day: np.ndarray
|
|
53
|
+
hour: np.ndarray
|
|
54
|
+
ghi: np.ndarray
|
|
55
|
+
dni: np.ndarray
|
|
56
|
+
dhi: np.ndarray
|
|
57
|
+
dry_bulb: Optional[np.ndarray] = None
|
|
58
|
+
dew_point: Optional[np.ndarray] = None
|
|
59
|
+
rh: Optional[np.ndarray] = None
|
|
60
|
+
pressure: Optional[np.ndarray] = None
|
|
61
|
+
ir: Optional[np.ndarray] = None
|
|
62
|
+
wind_dir: Optional[np.ndarray] = None
|
|
63
|
+
wind_speed: Optional[np.ndarray] = None
|
|
64
|
+
sky_cover: Optional[np.ndarray] = None
|
|
65
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def hours(self) -> int:
|
|
69
|
+
return int(self.month.shape[0])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def typical_doy(month: int, day: int) -> int:
|
|
73
|
+
"""Day of year in a 365-day typical year."""
|
|
74
|
+
return MONTH_START[int(month) - 1] + int(day)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _num(text: str, missing: float, fallback: float) -> float:
|
|
78
|
+
try:
|
|
79
|
+
v = float(text)
|
|
80
|
+
except (TypeError, ValueError):
|
|
81
|
+
return fallback
|
|
82
|
+
return v if math.isfinite(v) and v < missing else fallback
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_epw(text: str) -> Weather:
|
|
86
|
+
"""Parse EnergyPlus EPW text into a :class:`Weather`.
|
|
87
|
+
|
|
88
|
+
Missing values use the EPW sentinels and fall back to neutral defaults.
|
|
89
|
+
Leap-year files carry 29 February: it is dropped so the year has 8760 hours.
|
|
90
|
+
|
|
91
|
+
Raises:
|
|
92
|
+
ValueError: if the text has no LOCATION line or fewer than 8760 records.
|
|
93
|
+
"""
|
|
94
|
+
lines = re.split(r"\r?\n", text)
|
|
95
|
+
loc = [s.strip() for s in lines[0].split(",")] if lines else []
|
|
96
|
+
if not loc or loc[0].upper() != "LOCATION" or len(loc) < 10:
|
|
97
|
+
raise ValueError("Not an EPW file (no LOCATION line).")
|
|
98
|
+
location: dict[str, Any] = {
|
|
99
|
+
"city": loc[1],
|
|
100
|
+
"region": loc[2],
|
|
101
|
+
"country": loc[3],
|
|
102
|
+
"source": loc[4],
|
|
103
|
+
"wmo": loc[5],
|
|
104
|
+
"lat": _num(loc[6], math.inf, math.nan),
|
|
105
|
+
"lon": _num(loc[7], math.inf, math.nan),
|
|
106
|
+
"utc_offset": _num(loc[8], math.inf, math.nan),
|
|
107
|
+
"elevation": _num(loc[9], math.inf, math.nan),
|
|
108
|
+
}
|
|
109
|
+
rows = [ln.split(",") for ln in lines[8:] if ln and _NUMERIC_ROW.match(ln)]
|
|
110
|
+
if len(rows) < HOURS:
|
|
111
|
+
raise ValueError(f"EPW has {len(rows)} hourly records; {HOURS} are required.")
|
|
112
|
+
|
|
113
|
+
n = HOURS
|
|
114
|
+
month = np.zeros(n, dtype=np.uint8)
|
|
115
|
+
day = np.zeros(n, dtype=np.uint8)
|
|
116
|
+
hour = np.zeros(n, dtype=np.uint8)
|
|
117
|
+
cols = {
|
|
118
|
+
k: np.zeros(n)
|
|
119
|
+
for k in (
|
|
120
|
+
"dry_bulb",
|
|
121
|
+
"dew_point",
|
|
122
|
+
"rh",
|
|
123
|
+
"pressure",
|
|
124
|
+
"ir",
|
|
125
|
+
"ghi",
|
|
126
|
+
"dni",
|
|
127
|
+
"dhi",
|
|
128
|
+
"wind_dir",
|
|
129
|
+
"wind_speed",
|
|
130
|
+
"sky_cover",
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
spec = (
|
|
134
|
+
("dry_bulb", 6, 99.9, 20.0, False),
|
|
135
|
+
("dew_point", 7, 99.9, 10.0, False),
|
|
136
|
+
("rh", 8, 999, 50.0, False),
|
|
137
|
+
("pressure", 9, 999999, 101325.0, False),
|
|
138
|
+
("ir", 12, 9999, math.nan, False),
|
|
139
|
+
("ghi", 13, 9999, 0.0, True),
|
|
140
|
+
("dni", 14, 9999, 0.0, True),
|
|
141
|
+
("dhi", 15, 9999, 0.0, True),
|
|
142
|
+
("wind_dir", 20, 999, 0.0, False),
|
|
143
|
+
("wind_speed", 21, 999, 0.0, True),
|
|
144
|
+
("sky_cover", 22, 99, 5.0, False),
|
|
145
|
+
)
|
|
146
|
+
k = 0
|
|
147
|
+
for r in rows:
|
|
148
|
+
if k >= n:
|
|
149
|
+
break
|
|
150
|
+
if len(r) < 23:
|
|
151
|
+
r = r + [""] * (23 - len(r))
|
|
152
|
+
m = int(_num(r[1], math.inf, 0))
|
|
153
|
+
d = int(_num(r[2], math.inf, 0))
|
|
154
|
+
if m == 2 and d == 29:
|
|
155
|
+
continue
|
|
156
|
+
month[k] = m
|
|
157
|
+
day[k] = d
|
|
158
|
+
hour[k] = int(_num(r[3], math.inf, 0))
|
|
159
|
+
for name, idx, missing, fallback, nonneg in spec:
|
|
160
|
+
v = _num(r[idx], missing, fallback)
|
|
161
|
+
cols[name][k] = max(0.0, v) if nonneg else v
|
|
162
|
+
k += 1
|
|
163
|
+
if k < n:
|
|
164
|
+
raise ValueError(f"EPW ended before {HOURS} hours.")
|
|
165
|
+
return Weather(location=location, month=month, day=day, hour=hour, **cols) # type: ignore[arg-type]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def read_epw(path: Union[str, Path]) -> Weather:
|
|
169
|
+
"""Read and parse an EPW file from disk (UTF-8, Latin-1 fallback)."""
|
|
170
|
+
raw = Path(path).read_bytes()
|
|
171
|
+
try:
|
|
172
|
+
text = raw.decode("utf-8")
|
|
173
|
+
except UnicodeDecodeError:
|
|
174
|
+
text = raw.decode("latin-1")
|
|
175
|
+
return parse_epw(text)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def hour_index(month: int, day: int, hour_ending: int) -> int:
|
|
179
|
+
"""Index of the EPW record for a month, day and clock hour (1..24 = hour ending)."""
|
|
180
|
+
return (typical_doy(month, day) - 1) * 24 + (int(hour_ending) - 1)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def monthly_summary(w: Weather) -> list[dict[str, Any]]:
|
|
184
|
+
"""Monthly mean/min/max temperature, mean RH and wind, and GHI sum (kWh/m2)."""
|
|
185
|
+
out: list[dict[str, Any]] = []
|
|
186
|
+
for m, name in enumerate(MONTH_NAMES, start=1):
|
|
187
|
+
sel = w.month == m
|
|
188
|
+
cnt = int(sel.sum())
|
|
189
|
+
rec: dict[str, Any] = {
|
|
190
|
+
"name": name,
|
|
191
|
+
"t_mean": 0.0,
|
|
192
|
+
"t_min": math.inf,
|
|
193
|
+
"t_max": -math.inf,
|
|
194
|
+
"rh": 0.0,
|
|
195
|
+
"ghi_kwh": float(w.ghi[sel].sum() / 1000),
|
|
196
|
+
"wind": 0.0,
|
|
197
|
+
"n": cnt,
|
|
198
|
+
}
|
|
199
|
+
if cnt:
|
|
200
|
+
if w.dry_bulb is not None:
|
|
201
|
+
t = w.dry_bulb[sel]
|
|
202
|
+
rec.update(t_mean=float(t.mean()), t_min=float(t.min()), t_max=float(t.max()))
|
|
203
|
+
if w.rh is not None:
|
|
204
|
+
rec["rh"] = float(w.rh[sel].mean())
|
|
205
|
+
if w.wind_speed is not None:
|
|
206
|
+
rec["wind"] = float(w.wind_speed[sel].mean())
|
|
207
|
+
out.append(rec)
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _filter(w: Weather, months: Optional[Sequence[int]], hours: Optional[Sequence[int]]) -> Any:
|
|
212
|
+
sel = np.ones(w.hours, dtype=bool)
|
|
213
|
+
if months:
|
|
214
|
+
sel &= np.isin(w.month, list(months))
|
|
215
|
+
if hours:
|
|
216
|
+
sel &= np.isin(w.hour, list(hours))
|
|
217
|
+
return sel
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def wind_rose(
|
|
221
|
+
w: Weather,
|
|
222
|
+
sectors: int = 16,
|
|
223
|
+
classes: Sequence[float] = (0.5, 2, 4, 6, 8, 11),
|
|
224
|
+
months: Optional[Sequence[int]] = None,
|
|
225
|
+
hours: Optional[Sequence[int]] = None,
|
|
226
|
+
) -> dict[str, Any]:
|
|
227
|
+
"""Wind rose: frequency (fraction of hours) per direction sector and speed class.
|
|
228
|
+
|
|
229
|
+
Calm hours (speed below ``classes[0]``) are counted separately. Returns a
|
|
230
|
+
dict with ``freq`` (sectors x classes), ``sector_freq``, ``calm``,
|
|
231
|
+
``mean_speed``, ``prevailing_direction`` (degrees) and ``prevailing_speed``.
|
|
232
|
+
"""
|
|
233
|
+
if w.wind_speed is None or w.wind_dir is None:
|
|
234
|
+
raise ValueError("weather has no wind data")
|
|
235
|
+
cls = np.asarray(classes, dtype=float)
|
|
236
|
+
sel = _filter(w, months, hours)
|
|
237
|
+
v = w.wind_speed[sel].astype(float)
|
|
238
|
+
dirs = w.wind_dir[sel].astype(float)
|
|
239
|
+
total = int(v.size)
|
|
240
|
+
calm_mask = v < cls[0]
|
|
241
|
+
sector = np.floor(np.mod(dirs, 360) / (360 / sectors) + 0.5).astype(int) % sectors
|
|
242
|
+
# Speed class: first j>=1 with v < classes[j] gives j-1, else the last class.
|
|
243
|
+
c = np.searchsorted(cls, v, side="right") - 1
|
|
244
|
+
c = np.clip(c, 0, len(cls) - 1)
|
|
245
|
+
table = np.zeros((sectors, len(cls)))
|
|
246
|
+
ok = ~calm_mask
|
|
247
|
+
np.add.at(table, (sector[ok], c[ok]), 1)
|
|
248
|
+
freq = table / total if total else table
|
|
249
|
+
sector_freq = freq.sum(axis=1)
|
|
250
|
+
prevailing = int(np.argmax(sector_freq)) if sectors else 0
|
|
251
|
+
p = ok & (sector == prevailing)
|
|
252
|
+
return {
|
|
253
|
+
"sectors": sectors,
|
|
254
|
+
"classes": list(cls),
|
|
255
|
+
"freq": freq,
|
|
256
|
+
"sector_freq": sector_freq,
|
|
257
|
+
"calm": float(calm_mask.sum() / total) if total else 0.0,
|
|
258
|
+
"mean_speed": float(v.mean()) if total else 0.0,
|
|
259
|
+
"prevailing_direction": prevailing * (360 / sectors),
|
|
260
|
+
"prevailing_speed": float(v[p].mean()) if p.any() else 0.0,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ASHRAE clear-sky model constants per month (A in W/m2, B, C; ASHRAE
|
|
265
|
+
# Handbook of Fundamentals, 1985): DNI = A exp(-B / sin(altitude)), DHI = C DNI.
|
|
266
|
+
ASHRAE_CLEAR = (
|
|
267
|
+
(1230, 0.142, 0.058),
|
|
268
|
+
(1215, 0.144, 0.060),
|
|
269
|
+
(1186, 0.156, 0.071),
|
|
270
|
+
(1136, 0.180, 0.097),
|
|
271
|
+
(1104, 0.196, 0.121),
|
|
272
|
+
(1088, 0.205, 0.134),
|
|
273
|
+
(1085, 0.207, 0.136),
|
|
274
|
+
(1107, 0.201, 0.122),
|
|
275
|
+
(1151, 0.177, 0.092),
|
|
276
|
+
(1192, 0.160, 0.073),
|
|
277
|
+
(1221, 0.149, 0.063),
|
|
278
|
+
(1233, 0.142, 0.057),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def clear_sky_year(lat: float, lon: float, utc_offset: float) -> Weather:
|
|
283
|
+
"""Hourly clear-sky DNI/DHI/GHI (Wh/m2 per hour): an upper bound with no clouds."""
|
|
284
|
+
n = HOURS
|
|
285
|
+
month = np.zeros(n, dtype=np.uint8)
|
|
286
|
+
day = np.zeros(n, dtype=np.uint8)
|
|
287
|
+
hour = np.zeros(n, dtype=np.uint8)
|
|
288
|
+
ghi = np.zeros(n)
|
|
289
|
+
dni = np.zeros(n)
|
|
290
|
+
dhi = np.zeros(n)
|
|
291
|
+
k = 0
|
|
292
|
+
for m in range(1, 13):
|
|
293
|
+
a, b, c = ASHRAE_CLEAR[m - 1]
|
|
294
|
+
for d in range(1, MONTH_DAYS[m - 1] + 1):
|
|
295
|
+
doy = typical_doy(m, d)
|
|
296
|
+
for h in range(1, 25):
|
|
297
|
+
month[k], day[k], hour[k] = m, d, h
|
|
298
|
+
p = solar_position(doy, h - 0.5, lat, lon, utc_offset)
|
|
299
|
+
if p.elevation > 0.0175:
|
|
300
|
+
s = math.sin(p.elevation)
|
|
301
|
+
beam = a * math.exp(-b / s)
|
|
302
|
+
dni[k] = beam
|
|
303
|
+
dhi[k] = c * beam
|
|
304
|
+
ghi[k] = beam * s + c * beam
|
|
305
|
+
k += 1
|
|
306
|
+
loc = {"lat": lat, "lon": lon, "utc_offset": utc_offset, "city": "Clear sky", "clear_sky": True}
|
|
307
|
+
return Weather(location=loc, month=month, day=day, hour=hour, ghi=ghi, dni=dni, dhi=dhi)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def annual_sun_bins(
|
|
311
|
+
w: Weather,
|
|
312
|
+
lat: float,
|
|
313
|
+
lon: float,
|
|
314
|
+
utc_offset: float,
|
|
315
|
+
months: Optional[Sequence[int]] = None,
|
|
316
|
+
alt_step_deg: float = 5,
|
|
317
|
+
az_step_deg: float = 7.5,
|
|
318
|
+
) -> dict[str, Any]:
|
|
319
|
+
"""Bin the year's direct-beam hours into sky patches.
|
|
320
|
+
|
|
321
|
+
Each patch becomes one direction (the DNI-weighted mean sun vector of its
|
|
322
|
+
hours, scene axes +x east, +y up, -z north) with the patch's DNI sum in
|
|
323
|
+
Wh/m2 as weight. Also returns DHI, GHI and DNI sums.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
``{"directions": [{"dir": (x, y, z), "weight": wh}], "dhi_sum", "ghi_sum", "dni_sum"}``
|
|
327
|
+
"""
|
|
328
|
+
sel = _filter(w, months, None)
|
|
329
|
+
bins: dict[int, list[float]] = {}
|
|
330
|
+
dhi_sum = float(w.dhi[sel].sum())
|
|
331
|
+
ghi_sum = float(w.ghi[sel].sum())
|
|
332
|
+
dni_sum = 0.0
|
|
333
|
+
alt_step = math.radians(alt_step_deg)
|
|
334
|
+
az_step = math.radians(az_step_deg)
|
|
335
|
+
for i in np.nonzero(sel & (w.dni > 0))[0]:
|
|
336
|
+
dni = float(w.dni[i])
|
|
337
|
+
p = solar_position(
|
|
338
|
+
typical_doy(int(w.month[i]), int(w.day[i])), int(w.hour[i]) - 0.5, lat, lon, utc_offset
|
|
339
|
+
)
|
|
340
|
+
if p.elevation <= 0.0087: # below 0.5 degrees
|
|
341
|
+
continue
|
|
342
|
+
dni_sum += dni
|
|
343
|
+
key = int(math.floor(p.elevation / alt_step)) * 1000 + int(math.floor(p.azimuth / az_step))
|
|
344
|
+
v = sun_vector(p.elevation, p.azimuth)
|
|
345
|
+
b = bins.setdefault(key, [0.0, 0.0, 0.0, 0.0])
|
|
346
|
+
b[0] += v[0] * dni
|
|
347
|
+
b[1] += v[1] * dni
|
|
348
|
+
b[2] += v[2] * dni
|
|
349
|
+
b[3] += dni
|
|
350
|
+
directions = []
|
|
351
|
+
for x, y, z, wt in bins.values():
|
|
352
|
+
length = math.sqrt(x * x + y * y + z * z) or 1.0
|
|
353
|
+
directions.append({"dir": (x / length, y / length, z / length), "weight": wt})
|
|
354
|
+
return {"directions": directions, "dhi_sum": dhi_sum, "ghi_sum": ghi_sum, "dni_sum": dni_sum}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _at(arr: Optional[np.ndarray], i: int) -> Optional[float]:
|
|
358
|
+
return None if arr is None else float(arr[i])
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def conditions_at(w: Weather, month: int, day: int, hour_ending: int) -> dict[str, Optional[float]]:
|
|
362
|
+
"""Hourly conditions for comfort analysis (``None`` for fields the source lacks)."""
|
|
363
|
+
i = hour_index(month, day, hour_ending)
|
|
364
|
+
return {
|
|
365
|
+
"air_temp": _at(w.dry_bulb, i),
|
|
366
|
+
"rh": _at(w.rh, i),
|
|
367
|
+
"wind_speed": _at(w.wind_speed, i),
|
|
368
|
+
"wind_dir": _at(w.wind_dir, i),
|
|
369
|
+
"ghi": float(w.ghi[i]),
|
|
370
|
+
"dni": float(w.dni[i]),
|
|
371
|
+
"dhi": float(w.dhi[i]),
|
|
372
|
+
"ir": _at(w.ir, i),
|
|
373
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>02 Geo3D Analyzer</title>
|
|
7
|
+
<link rel="icon" href="data:,">
|
|
8
|
+
<link rel="stylesheet" href="./src/style.css">
|
|
9
|
+
<script type="importmap">
|
|
10
|
+
{
|
|
11
|
+
"imports": {
|
|
12
|
+
"three": "./vendor/three/three.module.js",
|
|
13
|
+
"three/addons/": "./vendor/three/addons/"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
</script>
|
|
17
|
+
</head>
|
|
18
|
+
<body>
|
|
19
|
+
<div id="app">
|
|
20
|
+
<header id="topbar">
|
|
21
|
+
<div class="brand"><span class="logo">02</span><span>Geo3D Analyzer</span></div>
|
|
22
|
+
<div id="scene-title" class="scene-title"></div>
|
|
23
|
+
<div id="sun-clock" class="sun-clock"></div>
|
|
24
|
+
<div id="fps" class="fps"></div>
|
|
25
|
+
<a href="https://geophilo.com/zero2geospatial3d/" target="_blank" rel="noopener" class="topbar-manual" title="Open the 02 Geo3D Analyzer Reference Manual">Manual ↗</a>
|
|
26
|
+
</header>
|
|
27
|
+
<aside id="sidebar">
|
|
28
|
+
<nav id="tabs"></nav>
|
|
29
|
+
<div id="panels"></div>
|
|
30
|
+
</aside>
|
|
31
|
+
<main id="view">
|
|
32
|
+
<div id="tool-hint" class="tool-hint" hidden></div>
|
|
33
|
+
<div id="legend" class="legend" hidden></div>
|
|
34
|
+
<div id="inspector" class="inspector" hidden></div>
|
|
35
|
+
<div id="empty" class="empty" hidden>
|
|
36
|
+
<h2>No scene to show</h2>
|
|
37
|
+
<p>Open the viewer from QGIS: <b>02 Geo3D Analyzer</b> panel → <b>Open in 3D</b>.</p>
|
|
38
|
+
<p class="detail"></p>
|
|
39
|
+
</div>
|
|
40
|
+
<div id="toast" class="toast"></div>
|
|
41
|
+
</main>
|
|
42
|
+
<footer id="statusbar">
|
|
43
|
+
<span id="status-text">Starting …</span>
|
|
44
|
+
<span id="status-progress" class="progress" hidden><span></span></span>
|
|
45
|
+
<span class="hint">Drag to orbit · right-drag to pan · wheel to zoom · click a building to inspect</span>
|
|
46
|
+
</footer>
|
|
47
|
+
</div>
|
|
48
|
+
<script type="module" src="./src/main.js"></script>
|
|
49
|
+
</body>
|
|
50
|
+
</html>
|