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,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