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,443 @@
|
|
|
1
|
+
"""Raster sun, shadow, sky-view and visibility engines on a height field.
|
|
2
|
+
|
|
3
|
+
The plugin computes these on the GPU with depth maps of the 3D scene. Here the
|
|
4
|
+
scene is a 2.5-D *surface*: a :class:`~zero2geospatial3d.grid.Grid` whose
|
|
5
|
+
values are the terrain plus the height of anything standing on it (buildings,
|
|
6
|
+
walls, tree crowns). :func:`zero2geospatial3d.scene.Scene.surface` builds one
|
|
7
|
+
from a DEM and extruded footprints.
|
|
8
|
+
|
|
9
|
+
Every receiver is lifted ``lift`` metres above its cell (0.3 m as in the
|
|
10
|
+
viewer) so a flat surface does not shade itself. Rays are marched in steps of
|
|
11
|
+
``step`` (default: half a cell) with bilinear sampling; anything outside the
|
|
12
|
+
grid is treated as open sky.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import math
|
|
18
|
+
from typing import Callable, Iterable, NamedTuple, Optional, Sequence
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .grid import Grid
|
|
23
|
+
from .solar import (
|
|
24
|
+
SunSample,
|
|
25
|
+
day_of_year,
|
|
26
|
+
sky_hemisphere,
|
|
27
|
+
sun_path,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"shadow_mask",
|
|
32
|
+
"horizon_angles",
|
|
33
|
+
"sky_view_factor",
|
|
34
|
+
"sun_hours",
|
|
35
|
+
"shadow_study",
|
|
36
|
+
"ShadowFrame",
|
|
37
|
+
"viewshed",
|
|
38
|
+
"directional_irradiation",
|
|
39
|
+
"solar_irradiation",
|
|
40
|
+
"IrradiationResult",
|
|
41
|
+
"pv_yield",
|
|
42
|
+
"scene_dir_to_angles",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
Progress = Optional[Callable[[float], None]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bilinear_open(values: np.ndarray, fc: np.ndarray, fr: np.ndarray) -> np.ndarray:
|
|
49
|
+
"""Bilinear sample at fractional (col, row); ``-inf`` outside the grid."""
|
|
50
|
+
rows, cols = values.shape
|
|
51
|
+
inside = (fc >= 0) & (fc <= cols - 1) & (fr >= 0) & (fr <= rows - 1)
|
|
52
|
+
out = np.full(fc.shape, -np.inf)
|
|
53
|
+
if not inside.any():
|
|
54
|
+
return out
|
|
55
|
+
c = fc[inside]
|
|
56
|
+
r = fr[inside]
|
|
57
|
+
c0 = np.minimum(np.floor(c).astype(int), max(cols - 2, 0))
|
|
58
|
+
r0 = np.minimum(np.floor(r).astype(int), max(rows - 2, 0))
|
|
59
|
+
if cols < 2 or rows < 2:
|
|
60
|
+
out[inside] = values.flat[0]
|
|
61
|
+
return out
|
|
62
|
+
tx = c - c0
|
|
63
|
+
ty = r - r0
|
|
64
|
+
a = values[r0, c0] * (1 - tx) + values[r0, c0 + 1] * tx
|
|
65
|
+
b = values[r0 + 1, c0] * (1 - tx) + values[r0 + 1, c0 + 1] * tx
|
|
66
|
+
out[inside] = a * (1 - ty) + b * ty
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _surface_values(surface: Grid) -> np.ndarray:
|
|
71
|
+
z = surface.values
|
|
72
|
+
if np.isnan(z).any():
|
|
73
|
+
z = np.where(np.isnan(z), np.nanmin(z) if np.isfinite(z).any() else 0.0, z)
|
|
74
|
+
return z
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _max_tan_along(
|
|
78
|
+
surface: Grid,
|
|
79
|
+
azimuth: float,
|
|
80
|
+
*,
|
|
81
|
+
lift: float,
|
|
82
|
+
step: Optional[float],
|
|
83
|
+
max_distance: Optional[float],
|
|
84
|
+
stop_tan: Optional[float] = None,
|
|
85
|
+
) -> np.ndarray:
|
|
86
|
+
"""Largest ``(z(p + d u) - z0) / d`` along azimuth ``u`` for every cell.
|
|
87
|
+
|
|
88
|
+
With ``stop_tan`` the march ends as soon as no cell could still exceed it
|
|
89
|
+
(used for single sun directions, where only "above or below" matters).
|
|
90
|
+
"""
|
|
91
|
+
z = _surface_values(surface)
|
|
92
|
+
rows, cols = z.shape
|
|
93
|
+
cell = surface.cell
|
|
94
|
+
step = step or cell * 0.5
|
|
95
|
+
z0 = z + lift
|
|
96
|
+
zmax = float(z.max())
|
|
97
|
+
diag = math.hypot(rows, cols) * cell
|
|
98
|
+
limit = min(diag, max_distance) if max_distance else diag
|
|
99
|
+
dx = math.sin(azimuth) / cell # columns per metre (east)
|
|
100
|
+
dy = -math.cos(azimuth) / cell # rows per metre (row index grows southwards)
|
|
101
|
+
cc, rr = np.meshgrid(np.arange(cols, dtype=float), np.arange(rows, dtype=float))
|
|
102
|
+
best = np.full(z.shape, -np.inf)
|
|
103
|
+
d = step
|
|
104
|
+
while d <= limit + 1e-9:
|
|
105
|
+
zs = _bilinear_open(z, cc + dx * d, rr + dy * d)
|
|
106
|
+
if not np.isfinite(zs).any():
|
|
107
|
+
break # every ray has left the grid
|
|
108
|
+
np.maximum(best, (zs - z0) / d, out=best)
|
|
109
|
+
# Nothing further away can beat the current best (or the threshold).
|
|
110
|
+
bound = (zmax - z0) / d
|
|
111
|
+
target = best if stop_tan is None else np.maximum(best, stop_tan)
|
|
112
|
+
if not (bound > target).any():
|
|
113
|
+
break
|
|
114
|
+
d += step
|
|
115
|
+
return best
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def shadow_mask(
|
|
119
|
+
surface: Grid,
|
|
120
|
+
elevation: float,
|
|
121
|
+
azimuth: float,
|
|
122
|
+
*,
|
|
123
|
+
lift: float = 0.3,
|
|
124
|
+
step: Optional[float] = None,
|
|
125
|
+
max_distance: Optional[float] = None,
|
|
126
|
+
) -> np.ndarray:
|
|
127
|
+
"""Boolean ``(rows, cols)`` array, ``True`` where the cell is in sunlight.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
surface: terrain + objects height field.
|
|
131
|
+
elevation: sun elevation in radians (``<= 0`` means everything is dark).
|
|
132
|
+
azimuth: sun azimuth in radians, clockwise from north.
|
|
133
|
+
"""
|
|
134
|
+
if elevation <= 0:
|
|
135
|
+
return np.zeros(surface.shape, dtype=bool)
|
|
136
|
+
tan_el = math.tan(elevation)
|
|
137
|
+
best = _max_tan_along(
|
|
138
|
+
surface, azimuth, lift=lift, step=step, max_distance=max_distance, stop_tan=tan_el
|
|
139
|
+
)
|
|
140
|
+
return best < tan_el
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def horizon_angles(
|
|
144
|
+
surface: Grid,
|
|
145
|
+
n_azimuths: int = 36,
|
|
146
|
+
*,
|
|
147
|
+
lift: float = 0.3,
|
|
148
|
+
step: Optional[float] = None,
|
|
149
|
+
max_distance: Optional[float] = None,
|
|
150
|
+
on_progress: Progress = None,
|
|
151
|
+
) -> np.ndarray:
|
|
152
|
+
"""Horizon elevation (radians, ``>= 0``) for ``n_azimuths`` sectors.
|
|
153
|
+
|
|
154
|
+
Returns an array of shape ``(n_azimuths, rows, cols)``; sector ``k`` looks
|
|
155
|
+
towards azimuth ``2*pi*k/n_azimuths`` (clockwise from north).
|
|
156
|
+
"""
|
|
157
|
+
out = np.empty((n_azimuths,) + surface.shape)
|
|
158
|
+
for k in range(n_azimuths):
|
|
159
|
+
az = 2 * math.pi * k / n_azimuths
|
|
160
|
+
best = _max_tan_along(
|
|
161
|
+
surface, az, lift=lift, step=step, max_distance=max_distance, stop_tan=0.0
|
|
162
|
+
)
|
|
163
|
+
out[k] = np.arctan(np.maximum(best, 0.0))
|
|
164
|
+
if on_progress:
|
|
165
|
+
on_progress((k + 1) / n_azimuths)
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def sky_view_factor(
|
|
170
|
+
surface: Grid,
|
|
171
|
+
n_azimuths: int = 36,
|
|
172
|
+
*,
|
|
173
|
+
lift: float = 0.3,
|
|
174
|
+
step: Optional[float] = None,
|
|
175
|
+
max_distance: Optional[float] = None,
|
|
176
|
+
horizons: Optional[np.ndarray] = None,
|
|
177
|
+
on_progress: Progress = None,
|
|
178
|
+
) -> np.ndarray:
|
|
179
|
+
"""Sky view factor of a horizontal receiver (1 = open sky, 0 = enclosed).
|
|
180
|
+
|
|
181
|
+
Uses the horizon-angle form ``SVF = 1 - mean_k sin^2(h_k)``, which equals the
|
|
182
|
+
cosine-weighted hemisphere integral the plugin samples on the GPU.
|
|
183
|
+
"""
|
|
184
|
+
if horizons is None:
|
|
185
|
+
horizons = horizon_angles(
|
|
186
|
+
surface,
|
|
187
|
+
n_azimuths,
|
|
188
|
+
lift=lift,
|
|
189
|
+
step=step,
|
|
190
|
+
max_distance=max_distance,
|
|
191
|
+
on_progress=on_progress,
|
|
192
|
+
)
|
|
193
|
+
return 1.0 - np.mean(np.sin(horizons) ** 2, axis=0)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _sector_horizon(horizons: np.ndarray, azimuth: float) -> np.ndarray:
|
|
197
|
+
"""Horizon at an arbitrary azimuth, linearly interpolated between sectors."""
|
|
198
|
+
n = horizons.shape[0]
|
|
199
|
+
f = (azimuth % (2 * math.pi)) / (2 * math.pi) * n
|
|
200
|
+
k0 = int(math.floor(f)) % n
|
|
201
|
+
k1 = (k0 + 1) % n
|
|
202
|
+
t = f - math.floor(f)
|
|
203
|
+
return horizons[k0] * (1 - t) + horizons[k1] * t
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def sun_hours(
|
|
207
|
+
surface: Grid,
|
|
208
|
+
lat: float,
|
|
209
|
+
lon: float,
|
|
210
|
+
utc_offset: float,
|
|
211
|
+
*,
|
|
212
|
+
month: int = 3,
|
|
213
|
+
day: int = 21,
|
|
214
|
+
year: Optional[int] = None,
|
|
215
|
+
start_hour: float = 0.0,
|
|
216
|
+
end_hour: float = 24.0,
|
|
217
|
+
step_minutes: float = 10.0,
|
|
218
|
+
min_elevation_deg: float = 0.5,
|
|
219
|
+
lift: float = 0.3,
|
|
220
|
+
step: Optional[float] = None,
|
|
221
|
+
on_progress: Progress = None,
|
|
222
|
+
) -> np.ndarray:
|
|
223
|
+
"""Hours of direct sun per cell on one day (BRE 209 uses 21 March, >= 2 h).
|
|
224
|
+
|
|
225
|
+
``year=None`` uses a typical non-leap year, as the viewer does.
|
|
226
|
+
"""
|
|
227
|
+
doy = day_of_year(year or 2001, month, day)
|
|
228
|
+
path = sun_path(
|
|
229
|
+
doy,
|
|
230
|
+
lat,
|
|
231
|
+
lon,
|
|
232
|
+
utc_offset,
|
|
233
|
+
start_hour=start_hour,
|
|
234
|
+
end_hour=end_hour,
|
|
235
|
+
step_minutes=step_minutes,
|
|
236
|
+
min_elevation_deg=min_elevation_deg,
|
|
237
|
+
)
|
|
238
|
+
total = np.zeros(surface.shape)
|
|
239
|
+
for i, s in enumerate(path):
|
|
240
|
+
lit = shadow_mask(surface, s.elevation, s.azimuth, lift=lift, step=step)
|
|
241
|
+
total += lit * s.weight
|
|
242
|
+
if on_progress:
|
|
243
|
+
on_progress((i + 1) / len(path))
|
|
244
|
+
return total
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class ShadowFrame(NamedTuple):
|
|
248
|
+
"""One frame of a shadow study: clock hour, sun angles (rad) and lit mask."""
|
|
249
|
+
|
|
250
|
+
hour: float
|
|
251
|
+
elevation: float
|
|
252
|
+
azimuth: float
|
|
253
|
+
lit: np.ndarray
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def shadow_study(
|
|
257
|
+
surface: Grid,
|
|
258
|
+
lat: float,
|
|
259
|
+
lon: float,
|
|
260
|
+
utc_offset: float,
|
|
261
|
+
*,
|
|
262
|
+
month: int = 6,
|
|
263
|
+
day: int = 21,
|
|
264
|
+
year: Optional[int] = None,
|
|
265
|
+
start_hour: float = 6.0,
|
|
266
|
+
end_hour: float = 20.0,
|
|
267
|
+
step_minutes: float = 30.0,
|
|
268
|
+
lift: float = 0.3,
|
|
269
|
+
) -> list[ShadowFrame]:
|
|
270
|
+
"""Sun-or-shade masks at regular clock times through one day."""
|
|
271
|
+
doy = day_of_year(year or 2001, month, day)
|
|
272
|
+
frames: list[ShadowFrame] = []
|
|
273
|
+
from .solar import solar_position
|
|
274
|
+
|
|
275
|
+
h = start_hour
|
|
276
|
+
while h <= end_hour + 1e-9:
|
|
277
|
+
p = solar_position(doy, h, lat, lon, utc_offset)
|
|
278
|
+
lit = shadow_mask(surface, p.elevation, p.azimuth, lift=lift)
|
|
279
|
+
frames.append(ShadowFrame(h, p.elevation, p.azimuth, lit))
|
|
280
|
+
h += step_minutes / 60.0
|
|
281
|
+
return frames
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def viewshed(
|
|
285
|
+
surface: Grid,
|
|
286
|
+
observer_xy: tuple[float, float],
|
|
287
|
+
*,
|
|
288
|
+
observer_height: float = 1.6,
|
|
289
|
+
target_height: float = 0.3,
|
|
290
|
+
radius: Optional[float] = None,
|
|
291
|
+
) -> np.ndarray:
|
|
292
|
+
"""Cells visible from an observer standing on the surface.
|
|
293
|
+
|
|
294
|
+
Returns a float array: 1 visible, 0 hidden, NaN beyond ``radius``.
|
|
295
|
+
"""
|
|
296
|
+
z = _surface_values(surface)
|
|
297
|
+
rows, cols = z.shape
|
|
298
|
+
oc = (observer_xy[0] - surface.min_x) / surface.cell
|
|
299
|
+
orr = (surface.max_y - observer_xy[1]) / surface.cell
|
|
300
|
+
oz = float(surface.sample(*observer_xy)) + observer_height
|
|
301
|
+
cc, rr = np.meshgrid(np.arange(cols, dtype=float), np.arange(rows, dtype=float))
|
|
302
|
+
dist_cells = np.hypot(cc - oc, rr - orr)
|
|
303
|
+
tz = z + target_height
|
|
304
|
+
target_slope = (tz - oz) / np.maximum(dist_cells, 1e-9)
|
|
305
|
+
blocking = np.full(z.shape, -np.inf)
|
|
306
|
+
n = int(math.ceil(dist_cells.max())) + 1
|
|
307
|
+
for k in range(1, n):
|
|
308
|
+
t = k / n
|
|
309
|
+
fc = oc + (cc - oc) * t
|
|
310
|
+
fr = orr + (rr - orr) * t
|
|
311
|
+
d = dist_cells * t
|
|
312
|
+
# Only samples at least half a cell before the target can block it.
|
|
313
|
+
valid = (dist_cells - d) > 0.5
|
|
314
|
+
zs = _bilinear_open(z, fc, fr)
|
|
315
|
+
slope = np.where(valid & (d > 0.5), (zs - oz) / np.maximum(d, 1e-9), -np.inf)
|
|
316
|
+
np.maximum(blocking, slope, out=blocking)
|
|
317
|
+
vis = (target_slope >= blocking).astype(float)
|
|
318
|
+
if radius is not None:
|
|
319
|
+
vis[dist_cells * surface.cell > radius] = np.nan
|
|
320
|
+
return vis
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def scene_dir_to_angles(direction: Sequence[float]) -> tuple[float, float]:
|
|
324
|
+
"""(elevation, azimuth) in radians for a scene-axis vector (+x E, +y up, -z N)."""
|
|
325
|
+
x, y, zc = float(direction[0]), float(direction[1]), float(direction[2])
|
|
326
|
+
el = math.asin(max(-1.0, min(1.0, y / (math.sqrt(x * x + y * y + zc * zc) or 1.0))))
|
|
327
|
+
az = math.atan2(x, -zc) % (2 * math.pi)
|
|
328
|
+
return el, az
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _normal(tilt_deg: float, azimuth_deg: float) -> np.ndarray:
|
|
332
|
+
"""Panel normal in scene axes for a tilt and a facing azimuth."""
|
|
333
|
+
t = math.radians(tilt_deg)
|
|
334
|
+
a = math.radians(azimuth_deg)
|
|
335
|
+
return np.array([math.sin(a) * math.sin(t), math.cos(t), -math.cos(a) * math.sin(t)])
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def directional_irradiation(
|
|
339
|
+
surface: Grid,
|
|
340
|
+
directions: Iterable[SunSample] | Iterable[tuple[Sequence[float], float]],
|
|
341
|
+
*,
|
|
342
|
+
tilt_deg: float = 0.0,
|
|
343
|
+
azimuth_deg: float = 180.0,
|
|
344
|
+
horizons: Optional[np.ndarray] = None,
|
|
345
|
+
n_azimuths: int = 36,
|
|
346
|
+
lift: float = 0.3,
|
|
347
|
+
) -> tuple[np.ndarray, float]:
|
|
348
|
+
"""Sum of ``weight * cos(incidence)`` over visible directions.
|
|
349
|
+
|
|
350
|
+
``directions`` holds ``(dir, weight)`` pairs in scene axes (as produced by
|
|
351
|
+
:func:`~zero2geospatial3d.solar.sun_path`, ``sky_hemisphere`` or the
|
|
352
|
+
weather module's ``annual_sun_bins``). Visibility is tested against the
|
|
353
|
+
horizon of each cell. Returns ``(values, unobstructed_potential)``.
|
|
354
|
+
"""
|
|
355
|
+
if horizons is None:
|
|
356
|
+
horizons = horizon_angles(surface, n_azimuths, lift=lift)
|
|
357
|
+
n = _normal(tilt_deg, azimuth_deg)
|
|
358
|
+
acc = np.zeros(surface.shape)
|
|
359
|
+
potential = 0.0
|
|
360
|
+
for item in directions:
|
|
361
|
+
vec, weight = item[0], item[1]
|
|
362
|
+
cos_i = max(0.0, float(np.dot(n, np.asarray(vec, dtype=float))))
|
|
363
|
+
if cos_i <= 0 or weight <= 0:
|
|
364
|
+
continue
|
|
365
|
+
el, az = scene_dir_to_angles(vec)
|
|
366
|
+
if el <= 0:
|
|
367
|
+
continue
|
|
368
|
+
visible = el > _sector_horizon(horizons, az)
|
|
369
|
+
acc += visible * (weight * cos_i)
|
|
370
|
+
potential += weight * cos_i
|
|
371
|
+
return acc, potential
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
class IrradiationResult(NamedTuple):
|
|
375
|
+
"""Per-cell irradiation in kWh/m2 over the period, and the unshaded potential."""
|
|
376
|
+
|
|
377
|
+
total: np.ndarray
|
|
378
|
+
direct: np.ndarray
|
|
379
|
+
diffuse: np.ndarray
|
|
380
|
+
reflected: float
|
|
381
|
+
potential: float
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def solar_irradiation(
|
|
385
|
+
surface: Grid,
|
|
386
|
+
sun_bins: Iterable[tuple[Sequence[float], float]],
|
|
387
|
+
dhi_sum_wh: float,
|
|
388
|
+
ghi_sum_wh: float,
|
|
389
|
+
*,
|
|
390
|
+
tilt_deg: float = 0.0,
|
|
391
|
+
azimuth_deg: float = 180.0,
|
|
392
|
+
albedo: float = 0.2,
|
|
393
|
+
sky_directions: int = 144,
|
|
394
|
+
n_azimuths: int = 36,
|
|
395
|
+
lift: float = 0.3,
|
|
396
|
+
) -> IrradiationResult:
|
|
397
|
+
"""Direct + isotropic diffuse + ground-reflected irradiation (kWh/m2).
|
|
398
|
+
|
|
399
|
+
Mirrors the viewer's ``solarRadiation`` runner: ``sun_bins`` are
|
|
400
|
+
``(direction, DNI Wh/m2)`` pairs (e.g. ``annual_sun_bins(...)["directions"]``),
|
|
401
|
+
diffuse light is ``DHI * sky-view`` on the tilted plane and the ground adds
|
|
402
|
+
``albedo * GHI * (1 - cos tilt) / 2``.
|
|
403
|
+
"""
|
|
404
|
+
horizons = horizon_angles(surface, n_azimuths, lift=lift)
|
|
405
|
+
bins = [(d, w / 1000.0) for d, w in sun_bins]
|
|
406
|
+
direct, direct_pot = directional_irradiation(
|
|
407
|
+
surface, bins, tilt_deg=tilt_deg, azimuth_deg=azimuth_deg, horizons=horizons
|
|
408
|
+
)
|
|
409
|
+
sky = [(s.dir, s.weight) for s in sky_hemisphere(sky_directions)]
|
|
410
|
+
diff_frac, diff_pot = directional_irradiation(
|
|
411
|
+
surface, sky, tilt_deg=tilt_deg, azimuth_deg=azimuth_deg, horizons=horizons
|
|
412
|
+
)
|
|
413
|
+
dhi = dhi_sum_wh / 1000.0
|
|
414
|
+
ghi = ghi_sum_wh / 1000.0
|
|
415
|
+
reflected = albedo * ghi * (1 - math.cos(math.radians(tilt_deg))) / 2
|
|
416
|
+
diffuse = dhi * diff_frac
|
|
417
|
+
total = direct + diffuse + reflected
|
|
418
|
+
return IrradiationResult(
|
|
419
|
+
total, direct, diffuse, reflected, direct_pot + dhi * diff_pot + reflected
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def pv_yield(
|
|
424
|
+
irradiation: np.ndarray,
|
|
425
|
+
potential: np.ndarray | float,
|
|
426
|
+
*,
|
|
427
|
+
efficiency: float = 0.2,
|
|
428
|
+
performance_ratio: float = 0.8,
|
|
429
|
+
coverage: float = 0.6,
|
|
430
|
+
min_share: float = 0.7,
|
|
431
|
+
mask: Optional[np.ndarray] = None,
|
|
432
|
+
) -> np.ndarray:
|
|
433
|
+
"""PV electricity (kWh/m2 of ground) per cell, as in the viewer.
|
|
434
|
+
|
|
435
|
+
``yield = irradiation * efficiency * performance_ratio * coverage`` on cells
|
|
436
|
+
keeping at least ``min_share`` of their unshaded ``potential``; 0 elsewhere.
|
|
437
|
+
``mask`` (e.g. roof cells) restricts the panels further.
|
|
438
|
+
"""
|
|
439
|
+
irr = np.asarray(irradiation, dtype=float)
|
|
440
|
+
ok = irr >= min_share * np.asarray(potential, dtype=float)
|
|
441
|
+
if mask is not None:
|
|
442
|
+
ok &= np.asarray(mask, dtype=bool)
|
|
443
|
+
return np.where(ok, irr * efficiency * performance_ratio * coverage, 0.0)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Solar geometry: NOAA sun position, sunrise/sunset, sun paths and sky sampling.
|
|
2
|
+
|
|
3
|
+
Port of the plugin's ``web/src/core/solar.js``. Angles are returned in radians;
|
|
4
|
+
azimuth is measured clockwise from north. Direction vectors use the viewer's
|
|
5
|
+
scene axes (+x east, +y up, -z north) so that results stay comparable with the
|
|
6
|
+
plugin; :func:`sun_vector_enu` gives the east-north-up form used by the raster
|
|
7
|
+
engines in :mod:`zero2geospatial3d.shading`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime as _dt
|
|
13
|
+
import math
|
|
14
|
+
from typing import NamedTuple, Optional
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"SolarPosition",
|
|
18
|
+
"SunSample",
|
|
19
|
+
"SkySample",
|
|
20
|
+
"day_of_year",
|
|
21
|
+
"is_leap",
|
|
22
|
+
"solar_position",
|
|
23
|
+
"sun_vector",
|
|
24
|
+
"sun_vector_enu",
|
|
25
|
+
"sunrise_sunset",
|
|
26
|
+
"sun_path",
|
|
27
|
+
"sky_hemisphere",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
DEG = math.pi / 180.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SolarPosition(NamedTuple):
|
|
34
|
+
"""Sun position; ``elevation``/``azimuth``/``declination`` in radians, ``eq_time`` in minutes."""
|
|
35
|
+
|
|
36
|
+
elevation: float
|
|
37
|
+
azimuth: float
|
|
38
|
+
declination: float
|
|
39
|
+
eq_time: float
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SunSample(NamedTuple):
|
|
43
|
+
"""One sun-path sample: scene-axis direction, duration weight (h), clock hour."""
|
|
44
|
+
|
|
45
|
+
dir: tuple[float, float, float]
|
|
46
|
+
weight: float
|
|
47
|
+
hour: float
|
|
48
|
+
elevation: float
|
|
49
|
+
azimuth: float
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SkySample(NamedTuple):
|
|
53
|
+
"""One sky direction (scene axes) with its solid-angle weight."""
|
|
54
|
+
|
|
55
|
+
dir: tuple[float, float, float]
|
|
56
|
+
weight: float
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def day_of_year(year: int, month: int, day: int) -> int:
|
|
60
|
+
"""Day of year (1..366) for a calendar date."""
|
|
61
|
+
return _dt.date(year, month, day).timetuple().tm_yday
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_leap(year: int) -> bool:
|
|
65
|
+
"""Gregorian leap-year test."""
|
|
66
|
+
return (year % 4 == 0 and year % 100 != 0) or year % 400 == 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def solar_position(
|
|
70
|
+
doy: float,
|
|
71
|
+
hour: float,
|
|
72
|
+
lat: float,
|
|
73
|
+
lon: float,
|
|
74
|
+
utc_offset: float,
|
|
75
|
+
days_in_year: int = 365,
|
|
76
|
+
) -> SolarPosition:
|
|
77
|
+
"""NOAA "General Solar Position Calculations".
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
doy: day of year (1-based).
|
|
81
|
+
hour: local clock time in hours (0..24, fractional).
|
|
82
|
+
lat: latitude, degrees north.
|
|
83
|
+
lon: longitude, degrees east.
|
|
84
|
+
utc_offset: time zone of the clock, hours east of UTC.
|
|
85
|
+
days_in_year: 365 or 366.
|
|
86
|
+
"""
|
|
87
|
+
g = (2 * math.pi / days_in_year) * (doy - 1 + (hour - utc_offset - 12) / 24)
|
|
88
|
+
eq_time = 229.18 * (
|
|
89
|
+
0.000075
|
|
90
|
+
+ 0.001868 * math.cos(g)
|
|
91
|
+
- 0.032077 * math.sin(g)
|
|
92
|
+
- 0.014615 * math.cos(2 * g)
|
|
93
|
+
- 0.040849 * math.sin(2 * g)
|
|
94
|
+
)
|
|
95
|
+
decl = (
|
|
96
|
+
0.006918
|
|
97
|
+
- 0.399912 * math.cos(g)
|
|
98
|
+
+ 0.070257 * math.sin(g)
|
|
99
|
+
- 0.006758 * math.cos(2 * g)
|
|
100
|
+
+ 0.000907 * math.sin(2 * g)
|
|
101
|
+
- 0.002697 * math.cos(3 * g)
|
|
102
|
+
+ 0.00148 * math.sin(3 * g)
|
|
103
|
+
)
|
|
104
|
+
true_solar_minutes = hour * 60 + eq_time + 4 * lon - 60 * utc_offset
|
|
105
|
+
ha = (true_solar_minutes / 4 - 180) * DEG
|
|
106
|
+
phi = lat * DEG
|
|
107
|
+
sin_el = math.sin(phi) * math.sin(decl) + math.cos(phi) * math.cos(decl) * math.cos(ha)
|
|
108
|
+
elevation = math.asin(max(-1.0, min(1.0, sin_el)))
|
|
109
|
+
azimuth = math.atan2(
|
|
110
|
+
-math.sin(ha) * math.cos(decl),
|
|
111
|
+
math.cos(phi) * math.sin(decl) - math.sin(phi) * math.cos(decl) * math.cos(ha),
|
|
112
|
+
)
|
|
113
|
+
if azimuth < 0:
|
|
114
|
+
azimuth += 2 * math.pi
|
|
115
|
+
return SolarPosition(elevation, azimuth, decl, eq_time)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def sun_vector(elevation: float, azimuth: float) -> tuple[float, float, float]:
|
|
119
|
+
"""Unit vector towards the sun in scene axes (+x east, +y up, -z north)."""
|
|
120
|
+
c = math.cos(elevation)
|
|
121
|
+
return (math.sin(azimuth) * c, math.sin(elevation), -math.cos(azimuth) * c)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def sun_vector_enu(elevation: float, azimuth: float) -> tuple[float, float, float]:
|
|
125
|
+
"""Unit vector towards the sun as (east, north, up)."""
|
|
126
|
+
c = math.cos(elevation)
|
|
127
|
+
return (math.sin(azimuth) * c, math.cos(azimuth) * c, math.sin(elevation))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _bisect(above, lo: float, hi: float, start_above: bool) -> float:
|
|
131
|
+
for _ in range(30):
|
|
132
|
+
mid = (lo + hi) / 2
|
|
133
|
+
if above(mid) == start_above:
|
|
134
|
+
lo = mid
|
|
135
|
+
else:
|
|
136
|
+
hi = mid
|
|
137
|
+
return (lo + hi) / 2
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def sunrise_sunset(
|
|
141
|
+
doy: float, lat: float, lon: float, utc_offset: float
|
|
142
|
+
) -> tuple[Optional[float], Optional[float]]:
|
|
143
|
+
"""Clock hours ``(sunrise, sunset)``; ``None`` entries for polar day/night."""
|
|
144
|
+
|
|
145
|
+
def above(h: float) -> bool:
|
|
146
|
+
return solar_position(doy, h, lat, lon, utc_offset).elevation > 0
|
|
147
|
+
|
|
148
|
+
step = 0.25
|
|
149
|
+
rise: Optional[float] = None
|
|
150
|
+
sset: Optional[float] = None
|
|
151
|
+
h = 0.0
|
|
152
|
+
while h < 24:
|
|
153
|
+
a = above(h)
|
|
154
|
+
b = above(h + step)
|
|
155
|
+
if not a and b and rise is None:
|
|
156
|
+
rise = _bisect(above, h, h + step, False)
|
|
157
|
+
if a and not b:
|
|
158
|
+
sset = _bisect(above, h, h + step, True)
|
|
159
|
+
h += step
|
|
160
|
+
return rise, sset
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def sun_path(
|
|
164
|
+
doy: float,
|
|
165
|
+
lat: float,
|
|
166
|
+
lon: float,
|
|
167
|
+
utc_offset: float,
|
|
168
|
+
*,
|
|
169
|
+
start_hour: float = 0.0,
|
|
170
|
+
end_hour: float = 24.0,
|
|
171
|
+
step_minutes: float = 10.0,
|
|
172
|
+
min_elevation_deg: float = 0.5,
|
|
173
|
+
) -> list[SunSample]:
|
|
174
|
+
"""Sun directions through one day.
|
|
175
|
+
|
|
176
|
+
Samples every ``step_minutes`` between ``start_hour`` and ``end_hour`` while
|
|
177
|
+
the sun is above ``min_elevation_deg``. Each sample is weighted with its
|
|
178
|
+
duration in hours, so the weights of unobstructed samples sum to sun hours.
|
|
179
|
+
"""
|
|
180
|
+
out: list[SunSample] = []
|
|
181
|
+
step = step_minutes / 60.0
|
|
182
|
+
h = start_hour + step / 2
|
|
183
|
+
while h < end_hour:
|
|
184
|
+
p = solar_position(doy, h, lat, lon, utc_offset)
|
|
185
|
+
if p.elevation >= min_elevation_deg * DEG:
|
|
186
|
+
out.append(
|
|
187
|
+
SunSample(sun_vector(p.elevation, p.azimuth), step, h, p.elevation, p.azimuth)
|
|
188
|
+
)
|
|
189
|
+
h += step
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def sky_hemisphere(n: int = 192) -> list[SkySample]:
|
|
194
|
+
"""Quasi-uniform directions over the upper hemisphere (Fibonacci lattice).
|
|
195
|
+
|
|
196
|
+
Each direction stands for the solid angle ``2*pi/n``. With the cosine factor
|
|
197
|
+
``max(0, n.d)`` and weight ``2/n`` the sum is the sky view factor of a
|
|
198
|
+
surface with normal ``n``: 1 for an open roof, 0.5 for an open wall.
|
|
199
|
+
"""
|
|
200
|
+
out: list[SkySample] = []
|
|
201
|
+
golden = math.pi * (3 - math.sqrt(5))
|
|
202
|
+
for i in range(n):
|
|
203
|
+
y = 1 - (i + 0.5) / n
|
|
204
|
+
r = math.sqrt(max(0.0, 1 - y * y))
|
|
205
|
+
a = i * golden
|
|
206
|
+
out.append(SkySample((math.cos(a) * r, y, math.sin(a) * r), 2.0 / n))
|
|
207
|
+
return out
|