voxcity 1.0.2__py3-none-any.whl → 1.0.15__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.
- voxcity/downloader/ocean.py +559 -0
- voxcity/generator/api.py +6 -0
- voxcity/generator/grids.py +45 -32
- voxcity/generator/pipeline.py +327 -27
- voxcity/geoprocessor/draw.py +14 -8
- voxcity/geoprocessor/raster/__init__.py +2 -0
- voxcity/geoprocessor/raster/core.py +31 -0
- voxcity/geoprocessor/raster/landcover.py +173 -49
- voxcity/geoprocessor/raster/raster.py +1 -1
- voxcity/models.py +2 -0
- voxcity/simulator/solar/__init__.py +13 -0
- voxcity/simulator_gpu/__init__.py +90 -0
- voxcity/simulator_gpu/core.py +322 -0
- voxcity/simulator_gpu/domain.py +36 -0
- voxcity/simulator_gpu/init_taichi.py +154 -0
- voxcity/simulator_gpu/raytracing.py +776 -0
- voxcity/simulator_gpu/solar/__init__.py +222 -0
- voxcity/simulator_gpu/solar/core.py +66 -0
- voxcity/simulator_gpu/solar/csf.py +1249 -0
- voxcity/simulator_gpu/solar/domain.py +618 -0
- voxcity/simulator_gpu/solar/epw.py +421 -0
- voxcity/simulator_gpu/solar/integration.py +4322 -0
- voxcity/simulator_gpu/solar/mask.py +459 -0
- voxcity/simulator_gpu/solar/radiation.py +3019 -0
- voxcity/simulator_gpu/solar/raytracing.py +182 -0
- voxcity/simulator_gpu/solar/reflection.py +533 -0
- voxcity/simulator_gpu/solar/sky.py +907 -0
- voxcity/simulator_gpu/solar/solar.py +337 -0
- voxcity/simulator_gpu/solar/svf.py +446 -0
- voxcity/simulator_gpu/solar/volumetric.py +2099 -0
- voxcity/simulator_gpu/visibility/__init__.py +109 -0
- voxcity/simulator_gpu/visibility/geometry.py +278 -0
- voxcity/simulator_gpu/visibility/integration.py +808 -0
- voxcity/simulator_gpu/visibility/landmark.py +753 -0
- voxcity/simulator_gpu/visibility/view.py +944 -0
- voxcity/visualizer/renderer.py +2 -1
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/METADATA +16 -53
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/RECORD +41 -16
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/WHEEL +0 -0
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sky Discretization Methods for Cumulative Solar Irradiance Calculation.
|
|
3
|
+
|
|
4
|
+
This module provides various methods for dividing the sky hemisphere into patches
|
|
5
|
+
to improve efficiency of cumulative solar irradiance calculations. Instead of
|
|
6
|
+
tracing rays for each hourly sun position, sun positions can be binned into sky
|
|
7
|
+
patches and rays traced once per patch.
|
|
8
|
+
|
|
9
|
+
Supported methods:
|
|
10
|
+
- Tregenza: 145 patches (standard in Radiance, EnergyPlus, DAYSIM)
|
|
11
|
+
- Reinhart: Tregenza × MF² patches (high-resolution, used in DAYSIM/Honeybee)
|
|
12
|
+
- Uniform Grid: Regular azimuth × elevation grid
|
|
13
|
+
- Fibonacci: Quasi-uniform distribution using golden angle spiral
|
|
14
|
+
|
|
15
|
+
This approach significantly reduces computation time for annual simulations:
|
|
16
|
+
- 8760 hourly timesteps → ~145-2305 ray traces (30-60× speedup)
|
|
17
|
+
- Each patch accumulates radiation from multiple sun positions
|
|
18
|
+
- Patch solid angles weight the contributions correctly
|
|
19
|
+
|
|
20
|
+
References:
|
|
21
|
+
- Tregenza, P.R. (1987). "Subdivision of the sky hemisphere for luminance
|
|
22
|
+
measurements." Lighting Research & Technology, 19(1), 13-14.
|
|
23
|
+
- Reinhart, C.F. & Walkenhorst, O. (2001). "Validation of dynamic RADIANCE-based
|
|
24
|
+
daylight simulations for a test office with external blinds." Energy and
|
|
25
|
+
Buildings, 33(7), 683-697.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from typing import Tuple, Optional, Dict, Any
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from numba import njit
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# =============================================================================
|
|
35
|
+
# Constants and Data
|
|
36
|
+
# =============================================================================
|
|
37
|
+
|
|
38
|
+
# Tregenza band definitions: (elevation_center, num_patches_in_band)
|
|
39
|
+
TREGENZA_BANDS = [
|
|
40
|
+
(6.0, 30), # Band 1: 0°-12°, center at 6°
|
|
41
|
+
(18.0, 30), # Band 2: 12°-24°, center at 18°
|
|
42
|
+
(30.0, 24), # Band 3: 24°-36°, center at 30°
|
|
43
|
+
(42.0, 24), # Band 4: 36°-48°, center at 42°
|
|
44
|
+
(54.0, 18), # Band 5: 48°-60°, center at 54°
|
|
45
|
+
(66.0, 12), # Band 6: 60°-72°, center at 66°
|
|
46
|
+
(78.0, 6), # Band 7: 72°-84°, center at 78°
|
|
47
|
+
(90.0, 1), # Band 8: 84°-90°, zenith patch
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
# Tregenza band elevation boundaries (for binning)
|
|
51
|
+
TREGENZA_BAND_BOUNDARIES = np.array([0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 90.0])
|
|
52
|
+
|
|
53
|
+
# Number of patches per Tregenza band (for fast lookup)
|
|
54
|
+
TREGENZA_PATCH_COUNTS = np.array([30, 30, 24, 24, 18, 12, 6, 1], dtype=np.int32)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# =============================================================================
|
|
58
|
+
# Sky Patch Data Structure
|
|
59
|
+
# =============================================================================
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class SkyPatches:
|
|
63
|
+
"""
|
|
64
|
+
Container for sky patch discretization data.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
method: Discretization method name
|
|
68
|
+
n_patches: Total number of patches
|
|
69
|
+
patches: Array of (azimuth, elevation) in degrees, shape (N, 2)
|
|
70
|
+
directions: Unit direction vectors (dx, dy, dz), shape (N, 3)
|
|
71
|
+
solid_angles: Solid angle per patch in steradians, shape (N,)
|
|
72
|
+
metadata: Additional method-specific parameters
|
|
73
|
+
"""
|
|
74
|
+
method: str
|
|
75
|
+
n_patches: int
|
|
76
|
+
patches: np.ndarray # (N, 2) - azimuth, elevation in degrees
|
|
77
|
+
directions: np.ndarray # (N, 3) - unit vectors pointing to sky
|
|
78
|
+
solid_angles: np.ndarray # (N,) - steradians
|
|
79
|
+
metadata: Dict[str, Any]
|
|
80
|
+
|
|
81
|
+
# VoxCity compatibility: allow tuple-unpacking
|
|
82
|
+
# VoxCity returns (patches, directions, solid_angles)
|
|
83
|
+
def __iter__(self):
|
|
84
|
+
yield self.patches
|
|
85
|
+
yield self.directions
|
|
86
|
+
yield self.solid_angles
|
|
87
|
+
|
|
88
|
+
# VoxCity compatibility aliases
|
|
89
|
+
@property
|
|
90
|
+
def patch_directions(self):
|
|
91
|
+
return self.directions
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def patch_solid_angles(self):
|
|
95
|
+
return self.solid_angles
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class BinnedSolarData:
|
|
100
|
+
"""
|
|
101
|
+
Solar data binned into sky patches for cumulative simulation.
|
|
102
|
+
|
|
103
|
+
Attributes:
|
|
104
|
+
sky_patches: SkyPatches object with patch geometry
|
|
105
|
+
cumulative_dni: Cumulative DNI (Wh/m²) per patch, shape (N,)
|
|
106
|
+
cumulative_dhi: Cumulative DHI (Wh/m²) distributed by patch solid angle
|
|
107
|
+
hours_per_patch: Number of sun hours in each patch, shape (N,)
|
|
108
|
+
total_daytime_hours: Total hours with sun above horizon
|
|
109
|
+
"""
|
|
110
|
+
sky_patches: SkyPatches
|
|
111
|
+
cumulative_dni: np.ndarray # (N,) Wh/m²
|
|
112
|
+
cumulative_dhi: np.ndarray # Total DHI for isotropic distribution
|
|
113
|
+
hours_per_patch: np.ndarray # (N,)
|
|
114
|
+
total_daytime_hours: int
|
|
115
|
+
|
|
116
|
+
# VoxCity compatibility: allow tuple-unpacking
|
|
117
|
+
# VoxCity returns (directions, cumulative_dni, solid_angles, hours_count)
|
|
118
|
+
def __iter__(self):
|
|
119
|
+
yield self.sky_patches.directions
|
|
120
|
+
yield self.cumulative_dni
|
|
121
|
+
yield self.sky_patches.solid_angles
|
|
122
|
+
yield self.hours_per_patch
|
|
123
|
+
|
|
124
|
+
# VoxCity compatibility aliases
|
|
125
|
+
@property
|
|
126
|
+
def directions(self):
|
|
127
|
+
return self.sky_patches.directions
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def solid_angles(self):
|
|
131
|
+
return self.sky_patches.solid_angles
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def hours_count(self):
|
|
135
|
+
return self.hours_per_patch
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def patch_directions(self):
|
|
139
|
+
return self.sky_patches.directions
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def patch_cumulative_dni(self):
|
|
143
|
+
return self.cumulative_dni
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def patch_solid_angles(self):
|
|
147
|
+
return self.sky_patches.solid_angles
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def patch_hours(self):
|
|
151
|
+
return self.hours_per_patch
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# =============================================================================
|
|
155
|
+
# Tregenza Sky Subdivision (145 patches)
|
|
156
|
+
# =============================================================================
|
|
157
|
+
|
|
158
|
+
def generate_tregenza_patches() -> SkyPatches:
|
|
159
|
+
"""
|
|
160
|
+
Generate the 145 Tregenza sky patch center directions.
|
|
161
|
+
|
|
162
|
+
The Tregenza subdivision divides the sky hemisphere into 145 patches
|
|
163
|
+
arranged in 8 altitude bands. This is the standard sky discretization
|
|
164
|
+
used in Radiance (genskyvec), EnergyPlus, DAYSIM, and Ladybug Tools.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
SkyPatches object with patch data
|
|
168
|
+
|
|
169
|
+
Example:
|
|
170
|
+
>>> patches = generate_tregenza_patches()
|
|
171
|
+
>>> print(f"Number of patches: {patches.n_patches}") # 145
|
|
172
|
+
>>> print(f"Total solid angle: {patches.solid_angles.sum():.4f}") # ~2π
|
|
173
|
+
"""
|
|
174
|
+
patches = []
|
|
175
|
+
directions = []
|
|
176
|
+
solid_angles = []
|
|
177
|
+
|
|
178
|
+
for band_idx, (elev_center, n_patches) in enumerate(TREGENZA_BANDS):
|
|
179
|
+
elev_rad = np.deg2rad(elev_center)
|
|
180
|
+
cos_elev = np.cos(elev_rad)
|
|
181
|
+
sin_elev = np.sin(elev_rad)
|
|
182
|
+
|
|
183
|
+
# Solid angle calculation for band
|
|
184
|
+
elev_low = TREGENZA_BAND_BOUNDARIES[band_idx]
|
|
185
|
+
elev_high = TREGENZA_BAND_BOUNDARIES[band_idx + 1]
|
|
186
|
+
|
|
187
|
+
# Solid angle of band = 2π × (sin(θ_high) - sin(θ_low))
|
|
188
|
+
band_solid_angle = 2 * np.pi * (
|
|
189
|
+
np.sin(np.deg2rad(elev_high)) - np.sin(np.deg2rad(elev_low))
|
|
190
|
+
)
|
|
191
|
+
patch_solid_angle = band_solid_angle / n_patches
|
|
192
|
+
|
|
193
|
+
for i in range(n_patches):
|
|
194
|
+
# Azimuth at patch center (0° = North, clockwise)
|
|
195
|
+
az_deg = (i + 0.5) * 360.0 / n_patches
|
|
196
|
+
az_rad = np.deg2rad(az_deg)
|
|
197
|
+
|
|
198
|
+
# Direction vector (x=East, y=North, z=Up)
|
|
199
|
+
dx = cos_elev * np.sin(az_rad) # East component
|
|
200
|
+
dy = cos_elev * np.cos(az_rad) # North component
|
|
201
|
+
dz = sin_elev # Up component
|
|
202
|
+
|
|
203
|
+
patches.append((az_deg, elev_center))
|
|
204
|
+
directions.append((dx, dy, dz))
|
|
205
|
+
solid_angles.append(patch_solid_angle)
|
|
206
|
+
|
|
207
|
+
return SkyPatches(
|
|
208
|
+
method="tregenza",
|
|
209
|
+
n_patches=145,
|
|
210
|
+
patches=np.array(patches, dtype=np.float64),
|
|
211
|
+
directions=np.array(directions, dtype=np.float64),
|
|
212
|
+
solid_angles=np.array(solid_angles, dtype=np.float64),
|
|
213
|
+
metadata={"bands": 8}
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@njit(cache=True)
|
|
218
|
+
def get_tregenza_patch_index(azimuth_deg: float, elevation_deg: float) -> int:
|
|
219
|
+
"""
|
|
220
|
+
Get the Tregenza patch index for a given sun position.
|
|
221
|
+
|
|
222
|
+
Numba-accelerated for fast binning of many sun positions.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
azimuth_deg: Solar azimuth in degrees (0-360, 0=North, clockwise)
|
|
226
|
+
elevation_deg: Solar elevation in degrees (0-90)
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Patch index (0-144), or -1 if below horizon
|
|
230
|
+
"""
|
|
231
|
+
if elevation_deg < 0.0:
|
|
232
|
+
return -1
|
|
233
|
+
|
|
234
|
+
# Band boundaries and patch counts
|
|
235
|
+
boundaries = np.array([0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 90.0])
|
|
236
|
+
patch_counts = np.array([30, 30, 24, 24, 18, 12, 6, 1])
|
|
237
|
+
|
|
238
|
+
# Find band
|
|
239
|
+
band_idx = 7 # Default to zenith band
|
|
240
|
+
for i in range(7):
|
|
241
|
+
if elevation_deg < boundaries[i + 1]:
|
|
242
|
+
band_idx = i
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
# Calculate offset to this band
|
|
246
|
+
patch_offset = 0
|
|
247
|
+
for i in range(band_idx):
|
|
248
|
+
patch_offset += patch_counts[i]
|
|
249
|
+
|
|
250
|
+
# Find patch within band
|
|
251
|
+
n_patches = patch_counts[band_idx]
|
|
252
|
+
if n_patches == 1:
|
|
253
|
+
return patch_offset # Zenith
|
|
254
|
+
|
|
255
|
+
az_normalized = azimuth_deg % 360.0
|
|
256
|
+
patch_in_band = int(az_normalized / (360.0 / n_patches))
|
|
257
|
+
if patch_in_band >= n_patches:
|
|
258
|
+
patch_in_band = n_patches - 1
|
|
259
|
+
|
|
260
|
+
return patch_offset + patch_in_band
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# =============================================================================
|
|
264
|
+
# Reinhart Sky Subdivision (Tregenza × MF²)
|
|
265
|
+
# =============================================================================
|
|
266
|
+
|
|
267
|
+
def generate_reinhart_patches(mf: int = 4) -> SkyPatches:
|
|
268
|
+
"""
|
|
269
|
+
Generate Reinhart sky patches (subdivided Tregenza).
|
|
270
|
+
|
|
271
|
+
The Reinhart subdivision increases resolution by subdividing each Tregenza
|
|
272
|
+
band by a multiplication factor (MF). This allows higher accuracy for
|
|
273
|
+
detailed solar studies.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
mf: Multiplication factor. Common values:
|
|
277
|
+
- MF=1: 145 patches (same as Tregenza)
|
|
278
|
+
- MF=2: 577 patches
|
|
279
|
+
- MF=4: 2305 patches (common for annual daylight simulation)
|
|
280
|
+
- MF=6: 5185 patches
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
SkyPatches object with patch data
|
|
284
|
+
|
|
285
|
+
Example:
|
|
286
|
+
>>> patches = generate_reinhart_patches(mf=4)
|
|
287
|
+
>>> print(f"Number of patches: {patches.n_patches}") # ~2305
|
|
288
|
+
|
|
289
|
+
References:
|
|
290
|
+
Reinhart, C.F. & Walkenhorst, O. (2001). Energy and Buildings.
|
|
291
|
+
"""
|
|
292
|
+
mf = max(1, int(mf))
|
|
293
|
+
patches = []
|
|
294
|
+
directions = []
|
|
295
|
+
solid_angles = []
|
|
296
|
+
|
|
297
|
+
for band_idx, (elev_center_base, n_patches_base) in enumerate(TREGENZA_BANDS):
|
|
298
|
+
# Subdivide elevation bands
|
|
299
|
+
n_sub_bands = mf
|
|
300
|
+
|
|
301
|
+
elev_low = TREGENZA_BAND_BOUNDARIES[band_idx]
|
|
302
|
+
elev_high = TREGENZA_BAND_BOUNDARIES[band_idx + 1]
|
|
303
|
+
elev_range = elev_high - elev_low
|
|
304
|
+
|
|
305
|
+
for sub_band in range(n_sub_bands):
|
|
306
|
+
# Sub-band elevation bounds
|
|
307
|
+
sub_elev_low = elev_low + sub_band * elev_range / n_sub_bands
|
|
308
|
+
sub_elev_high = elev_low + (sub_band + 1) * elev_range / n_sub_bands
|
|
309
|
+
sub_elev_center = (sub_elev_low + sub_elev_high) / 2.0
|
|
310
|
+
|
|
311
|
+
elev_rad = np.deg2rad(sub_elev_center)
|
|
312
|
+
cos_elev = np.cos(elev_rad)
|
|
313
|
+
sin_elev = np.sin(elev_rad)
|
|
314
|
+
|
|
315
|
+
# Solid angle of sub-band
|
|
316
|
+
sub_band_solid_angle = 2 * np.pi * (
|
|
317
|
+
np.sin(np.deg2rad(sub_elev_high)) - np.sin(np.deg2rad(sub_elev_low))
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
# Number of azimuth patches in sub-band
|
|
321
|
+
if band_idx == len(TREGENZA_BANDS) - 1:
|
|
322
|
+
# Zenith: reduce patches for inner rings
|
|
323
|
+
n_az = max(1, n_patches_base * mf * (sub_band + 1) // n_sub_bands)
|
|
324
|
+
else:
|
|
325
|
+
n_az = n_patches_base * mf
|
|
326
|
+
|
|
327
|
+
patch_solid_angle = sub_band_solid_angle / n_az
|
|
328
|
+
|
|
329
|
+
for i in range(n_az):
|
|
330
|
+
az_deg = (i + 0.5) * 360.0 / n_az
|
|
331
|
+
az_rad = np.deg2rad(az_deg)
|
|
332
|
+
|
|
333
|
+
dx = cos_elev * np.sin(az_rad)
|
|
334
|
+
dy = cos_elev * np.cos(az_rad)
|
|
335
|
+
dz = sin_elev
|
|
336
|
+
|
|
337
|
+
patches.append((az_deg, sub_elev_center))
|
|
338
|
+
directions.append((dx, dy, dz))
|
|
339
|
+
solid_angles.append(patch_solid_angle)
|
|
340
|
+
|
|
341
|
+
return SkyPatches(
|
|
342
|
+
method="reinhart",
|
|
343
|
+
n_patches=len(patches),
|
|
344
|
+
patches=np.array(patches, dtype=np.float64),
|
|
345
|
+
directions=np.array(directions, dtype=np.float64),
|
|
346
|
+
solid_angles=np.array(solid_angles, dtype=np.float64),
|
|
347
|
+
metadata={"mf": mf}
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# =============================================================================
|
|
352
|
+
# Uniform Grid Subdivision
|
|
353
|
+
# =============================================================================
|
|
354
|
+
|
|
355
|
+
def generate_uniform_grid_patches(
|
|
356
|
+
n_azimuth: int = 36,
|
|
357
|
+
n_elevation: int = 9
|
|
358
|
+
) -> SkyPatches:
|
|
359
|
+
"""
|
|
360
|
+
Generate uniform grid sky patches.
|
|
361
|
+
|
|
362
|
+
Simple subdivision with equal azimuth and elevation spacing.
|
|
363
|
+
Note: This creates non-equal solid angle patches (smaller near zenith).
|
|
364
|
+
Useful when uniform angular sampling is preferred over uniform area.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
n_azimuth: Number of azimuth divisions (default: 36 = 10° spacing)
|
|
368
|
+
n_elevation: Number of elevation divisions (default: 9 = 10° spacing)
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
SkyPatches object with patch data
|
|
372
|
+
|
|
373
|
+
Example:
|
|
374
|
+
>>> patches = generate_uniform_grid_patches(36, 9)
|
|
375
|
+
>>> print(f"Number of patches: {patches.n_patches}") # 324
|
|
376
|
+
"""
|
|
377
|
+
patches = []
|
|
378
|
+
directions = []
|
|
379
|
+
solid_angles = []
|
|
380
|
+
|
|
381
|
+
elev_step = 90.0 / n_elevation
|
|
382
|
+
az_step = 360.0 / n_azimuth
|
|
383
|
+
|
|
384
|
+
for j in range(n_elevation):
|
|
385
|
+
elev_low = j * elev_step
|
|
386
|
+
elev_high = (j + 1) * elev_step
|
|
387
|
+
elev_center = (elev_low + elev_high) / 2.0
|
|
388
|
+
|
|
389
|
+
elev_rad = np.deg2rad(elev_center)
|
|
390
|
+
cos_elev = np.cos(elev_rad)
|
|
391
|
+
sin_elev = np.sin(elev_rad)
|
|
392
|
+
|
|
393
|
+
# Solid angle for this elevation band
|
|
394
|
+
band_solid_angle = 2 * np.pi * (
|
|
395
|
+
np.sin(np.deg2rad(elev_high)) - np.sin(np.deg2rad(elev_low))
|
|
396
|
+
)
|
|
397
|
+
patch_solid_angle = band_solid_angle / n_azimuth
|
|
398
|
+
|
|
399
|
+
for i in range(n_azimuth):
|
|
400
|
+
az_center = (i + 0.5) * az_step
|
|
401
|
+
az_rad = np.deg2rad(az_center)
|
|
402
|
+
|
|
403
|
+
dx = cos_elev * np.sin(az_rad)
|
|
404
|
+
dy = cos_elev * np.cos(az_rad)
|
|
405
|
+
dz = sin_elev
|
|
406
|
+
|
|
407
|
+
patches.append((az_center, elev_center))
|
|
408
|
+
directions.append((dx, dy, dz))
|
|
409
|
+
solid_angles.append(patch_solid_angle)
|
|
410
|
+
|
|
411
|
+
return SkyPatches(
|
|
412
|
+
method="uniform",
|
|
413
|
+
n_patches=len(patches),
|
|
414
|
+
patches=np.array(patches, dtype=np.float64),
|
|
415
|
+
directions=np.array(directions, dtype=np.float64),
|
|
416
|
+
solid_angles=np.array(solid_angles, dtype=np.float64),
|
|
417
|
+
metadata={"n_azimuth": n_azimuth, "n_elevation": n_elevation}
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# =============================================================================
|
|
422
|
+
# Fibonacci Spiral (Quasi-Uniform)
|
|
423
|
+
# =============================================================================
|
|
424
|
+
|
|
425
|
+
def generate_fibonacci_patches(n_patches: int = 145) -> SkyPatches:
|
|
426
|
+
"""
|
|
427
|
+
Generate quasi-uniform sky patches using Fibonacci spiral.
|
|
428
|
+
|
|
429
|
+
Uses the golden angle spiral to distribute points nearly uniformly
|
|
430
|
+
on the hemisphere. This provides more uniform patch areas than
|
|
431
|
+
regular grids with fewer total patches.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
n_patches: Number of patches to generate (default: 145 to match Tregenza)
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
SkyPatches object with patch data
|
|
438
|
+
|
|
439
|
+
Example:
|
|
440
|
+
>>> patches = generate_fibonacci_patches(200)
|
|
441
|
+
>>> # Check uniformity: solid angles should be equal
|
|
442
|
+
>>> print(f"Solid angle std: {patches.solid_angles.std():.6f}") # ~0
|
|
443
|
+
"""
|
|
444
|
+
n = max(1, int(n_patches))
|
|
445
|
+
golden_angle = np.pi * (3.0 - np.sqrt(5.0))
|
|
446
|
+
|
|
447
|
+
# Hemisphere solid angle = 2π steradians
|
|
448
|
+
patch_solid_angle = 2.0 * np.pi / n
|
|
449
|
+
|
|
450
|
+
patches = []
|
|
451
|
+
directions = []
|
|
452
|
+
solid_angles = []
|
|
453
|
+
|
|
454
|
+
for i in range(n):
|
|
455
|
+
# z ranges from 0 (horizon) to 1 (zenith)
|
|
456
|
+
z = (i + 0.5) / n
|
|
457
|
+
elevation_rad = np.arcsin(z)
|
|
458
|
+
elevation_deg = np.rad2deg(elevation_rad)
|
|
459
|
+
|
|
460
|
+
# Azimuth from golden angle
|
|
461
|
+
azimuth_rad = i * golden_angle
|
|
462
|
+
azimuth_deg = np.rad2deg(azimuth_rad) % 360.0
|
|
463
|
+
|
|
464
|
+
# Direction vector (x=East, y=North, z=Up)
|
|
465
|
+
r = np.sqrt(1.0 - z * z)
|
|
466
|
+
dx = r * np.sin(azimuth_rad) # Note: using sin for East, cos for North
|
|
467
|
+
dy = r * np.cos(azimuth_rad)
|
|
468
|
+
dz = z
|
|
469
|
+
|
|
470
|
+
patches.append((azimuth_deg, elevation_deg))
|
|
471
|
+
directions.append((dx, dy, dz))
|
|
472
|
+
solid_angles.append(patch_solid_angle)
|
|
473
|
+
|
|
474
|
+
return SkyPatches(
|
|
475
|
+
method="fibonacci",
|
|
476
|
+
n_patches=n,
|
|
477
|
+
patches=np.array(patches, dtype=np.float64),
|
|
478
|
+
directions=np.array(directions, dtype=np.float64),
|
|
479
|
+
solid_angles=np.array(solid_angles, dtype=np.float64),
|
|
480
|
+
metadata={"golden_angle": golden_angle}
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# =============================================================================
|
|
485
|
+
# Sky Patch Generation (Unified Interface)
|
|
486
|
+
# =============================================================================
|
|
487
|
+
|
|
488
|
+
def generate_sky_patches(
|
|
489
|
+
method: str = "tregenza",
|
|
490
|
+
**kwargs
|
|
491
|
+
) -> SkyPatches:
|
|
492
|
+
"""
|
|
493
|
+
Generate sky patches using specified discretization method.
|
|
494
|
+
|
|
495
|
+
This is the main entry point for sky discretization. It dispatches
|
|
496
|
+
to the appropriate method-specific function.
|
|
497
|
+
|
|
498
|
+
Args:
|
|
499
|
+
method: Discretization method:
|
|
500
|
+
- "tregenza": 145 patches (standard, fast)
|
|
501
|
+
- "reinhart": Tregenza × MF² (high-resolution)
|
|
502
|
+
- "uniform": Regular grid (simple)
|
|
503
|
+
- "fibonacci": Quasi-uniform spiral (balanced)
|
|
504
|
+
**kwargs: Method-specific parameters:
|
|
505
|
+
- mf: Multiplication factor for Reinhart (default: 4)
|
|
506
|
+
- n_azimuth, n_elevation: Grid size for uniform
|
|
507
|
+
- n_patches: Number of patches for Fibonacci
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
SkyPatches object with patch data
|
|
511
|
+
|
|
512
|
+
Example:
|
|
513
|
+
>>> # Standard Tregenza
|
|
514
|
+
>>> patches = generate_sky_patches("tregenza")
|
|
515
|
+
|
|
516
|
+
>>> # High-resolution Reinhart
|
|
517
|
+
>>> patches = generate_sky_patches("reinhart", mf=4)
|
|
518
|
+
|
|
519
|
+
>>> # Custom uniform grid
|
|
520
|
+
>>> patches = generate_sky_patches("uniform", n_azimuth=72, n_elevation=18)
|
|
521
|
+
"""
|
|
522
|
+
method = method.lower()
|
|
523
|
+
|
|
524
|
+
if method == "tregenza":
|
|
525
|
+
return generate_tregenza_patches()
|
|
526
|
+
elif method == "reinhart":
|
|
527
|
+
mf = kwargs.get("mf", 4)
|
|
528
|
+
return generate_reinhart_patches(mf=mf)
|
|
529
|
+
elif method == "uniform":
|
|
530
|
+
n_az = kwargs.get("n_azimuth", 36)
|
|
531
|
+
n_el = kwargs.get("n_elevation", 9)
|
|
532
|
+
return generate_uniform_grid_patches(n_az, n_el)
|
|
533
|
+
elif method == "fibonacci":
|
|
534
|
+
n = kwargs.get("n_patches", 145)
|
|
535
|
+
return generate_fibonacci_patches(n_patches=n)
|
|
536
|
+
else:
|
|
537
|
+
raise ValueError(
|
|
538
|
+
f"Unknown sky discretization method: {method}. "
|
|
539
|
+
f"Supported: tregenza, reinhart, uniform, fibonacci"
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# =============================================================================
|
|
544
|
+
# Sun Position Binning
|
|
545
|
+
# =============================================================================
|
|
546
|
+
|
|
547
|
+
def bin_sun_positions_to_patches(
|
|
548
|
+
azimuth_arr: np.ndarray,
|
|
549
|
+
elevation_arr: np.ndarray,
|
|
550
|
+
dni_arr: np.ndarray,
|
|
551
|
+
dhi_arr: Optional[np.ndarray] = None,
|
|
552
|
+
method: str = "tregenza",
|
|
553
|
+
**kwargs
|
|
554
|
+
) -> BinnedSolarData:
|
|
555
|
+
"""
|
|
556
|
+
Bin hourly sun positions into sky patches and aggregate radiation.
|
|
557
|
+
|
|
558
|
+
This is the key optimization for cumulative solar irradiance: instead of
|
|
559
|
+
tracing rays for every hourly sun position, aggregate radiation values
|
|
560
|
+
for each sky patch and trace rays once per patch.
|
|
561
|
+
|
|
562
|
+
The DNI values are summed for each patch where the sun appears.
|
|
563
|
+
The DHI values are distributed isotropically across all patches.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
azimuth_arr: Array of solar azimuth values in degrees (0=North)
|
|
567
|
+
elevation_arr: Array of solar elevation values in degrees
|
|
568
|
+
dni_arr: Array of Direct Normal Irradiance values (W/m² or Wh/m²)
|
|
569
|
+
dhi_arr: Array of Diffuse Horizontal Irradiance values (optional)
|
|
570
|
+
method: Sky discretization method
|
|
571
|
+
**kwargs: Additional parameters for patch generation
|
|
572
|
+
|
|
573
|
+
Returns:
|
|
574
|
+
BinnedSolarData with accumulated radiation per patch
|
|
575
|
+
|
|
576
|
+
Example:
|
|
577
|
+
>>> from palm_solar.epw import prepare_cumulative_simulation_input
|
|
578
|
+
>>> az, el, dni, dhi, loc = prepare_cumulative_simulation_input("weather.epw")
|
|
579
|
+
>>> binned = bin_sun_positions_to_patches(az, el, dni, dhi)
|
|
580
|
+
>>> print(f"Active patches: {(binned.hours_per_patch > 0).sum()}")
|
|
581
|
+
"""
|
|
582
|
+
# VoxCity compatibility: allow positional method as the 4th argument
|
|
583
|
+
# (VoxCity signature is (azimuth_arr, elevation_arr, dni_arr, method='tregenza', **kwargs))
|
|
584
|
+
if isinstance(dhi_arr, str):
|
|
585
|
+
method = dhi_arr
|
|
586
|
+
dhi_arr = None
|
|
587
|
+
|
|
588
|
+
method = str(method).lower()
|
|
589
|
+
|
|
590
|
+
# Generate sky patches
|
|
591
|
+
sky_patches = generate_sky_patches(method, **kwargs)
|
|
592
|
+
|
|
593
|
+
n_patches = sky_patches.n_patches
|
|
594
|
+
cumulative_dni = np.zeros(n_patches, dtype=np.float64)
|
|
595
|
+
hours_count = np.zeros(n_patches, dtype=np.int32)
|
|
596
|
+
|
|
597
|
+
# Bin each sun position
|
|
598
|
+
n_hours = len(azimuth_arr)
|
|
599
|
+
|
|
600
|
+
if method == "tregenza":
|
|
601
|
+
# Use fast Numba-accelerated binning for Tregenza
|
|
602
|
+
for i in range(n_hours):
|
|
603
|
+
elev = elevation_arr[i]
|
|
604
|
+
if elev <= 0:
|
|
605
|
+
continue
|
|
606
|
+
|
|
607
|
+
az = azimuth_arr[i]
|
|
608
|
+
dni = dni_arr[i]
|
|
609
|
+
|
|
610
|
+
patch_idx = get_tregenza_patch_index(az, elev)
|
|
611
|
+
if patch_idx >= 0:
|
|
612
|
+
cumulative_dni[patch_idx] += dni
|
|
613
|
+
hours_count[patch_idx] += 1
|
|
614
|
+
else:
|
|
615
|
+
# For other methods, find nearest patch by direction
|
|
616
|
+
directions = sky_patches.directions
|
|
617
|
+
|
|
618
|
+
for i in range(n_hours):
|
|
619
|
+
elev = elevation_arr[i]
|
|
620
|
+
if elev <= 0:
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
az = azimuth_arr[i]
|
|
624
|
+
dni = dni_arr[i]
|
|
625
|
+
|
|
626
|
+
# Convert sun position to direction vector
|
|
627
|
+
elev_rad = np.deg2rad(elev)
|
|
628
|
+
az_rad = np.deg2rad(az)
|
|
629
|
+
sun_dir = np.array([
|
|
630
|
+
np.cos(elev_rad) * np.sin(az_rad), # East
|
|
631
|
+
np.cos(elev_rad) * np.cos(az_rad), # North
|
|
632
|
+
np.sin(elev_rad) # Up
|
|
633
|
+
])
|
|
634
|
+
|
|
635
|
+
# Find nearest patch by dot product
|
|
636
|
+
dots = np.sum(directions * sun_dir, axis=1)
|
|
637
|
+
patch_idx = np.argmax(dots)
|
|
638
|
+
|
|
639
|
+
cumulative_dni[patch_idx] += dni
|
|
640
|
+
hours_count[patch_idx] += 1
|
|
641
|
+
|
|
642
|
+
# Sum total DHI if provided
|
|
643
|
+
total_dhi = np.sum(dhi_arr) if dhi_arr is not None else 0.0
|
|
644
|
+
|
|
645
|
+
# Total daytime hours
|
|
646
|
+
total_daytime = np.sum(elevation_arr > 0)
|
|
647
|
+
|
|
648
|
+
return BinnedSolarData(
|
|
649
|
+
sky_patches=sky_patches,
|
|
650
|
+
cumulative_dni=cumulative_dni,
|
|
651
|
+
cumulative_dhi=total_dhi,
|
|
652
|
+
hours_per_patch=hours_count,
|
|
653
|
+
total_daytime_hours=int(total_daytime)
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@njit(cache=True, parallel=False)
|
|
658
|
+
def _bin_tregenza_fast(
|
|
659
|
+
azimuth_arr: np.ndarray,
|
|
660
|
+
elevation_arr: np.ndarray,
|
|
661
|
+
dni_arr: np.ndarray
|
|
662
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
663
|
+
"""
|
|
664
|
+
Fast binning of sun positions to Tregenza patches using Numba.
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
azimuth_arr: Solar azimuth array (degrees)
|
|
668
|
+
elevation_arr: Solar elevation array (degrees)
|
|
669
|
+
dni_arr: DNI values array
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
Tuple of (cumulative_dni, hours_count) arrays
|
|
673
|
+
"""
|
|
674
|
+
cumulative_dni = np.zeros(145, dtype=np.float64)
|
|
675
|
+
hours_count = np.zeros(145, dtype=np.int32)
|
|
676
|
+
|
|
677
|
+
n = len(azimuth_arr)
|
|
678
|
+
for i in range(n):
|
|
679
|
+
elev = elevation_arr[i]
|
|
680
|
+
if elev <= 0.0:
|
|
681
|
+
continue
|
|
682
|
+
|
|
683
|
+
az = azimuth_arr[i]
|
|
684
|
+
dni = dni_arr[i]
|
|
685
|
+
|
|
686
|
+
patch_idx = get_tregenza_patch_index(az, elev)
|
|
687
|
+
if patch_idx >= 0:
|
|
688
|
+
cumulative_dni[patch_idx] += dni
|
|
689
|
+
hours_count[patch_idx] += 1
|
|
690
|
+
|
|
691
|
+
return cumulative_dni, hours_count
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# =============================================================================
|
|
695
|
+
# Utility Functions
|
|
696
|
+
# =============================================================================
|
|
697
|
+
|
|
698
|
+
def get_patch_info(method: str = "tregenza", **kwargs) -> dict:
|
|
699
|
+
"""
|
|
700
|
+
Get information about a sky discretization method.
|
|
701
|
+
|
|
702
|
+
Args:
|
|
703
|
+
method: Sky discretization method
|
|
704
|
+
**kwargs: Method-specific parameters
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
Dictionary with method details
|
|
708
|
+
|
|
709
|
+
Example:
|
|
710
|
+
>>> info = get_patch_info("reinhart", mf=4)
|
|
711
|
+
>>> print(f"{info['method']}: {info['n_patches']} patches")
|
|
712
|
+
"""
|
|
713
|
+
patches = generate_sky_patches(method, **kwargs)
|
|
714
|
+
|
|
715
|
+
info = {
|
|
716
|
+
"method": patches.method,
|
|
717
|
+
"n_patches": patches.n_patches,
|
|
718
|
+
"total_solid_angle": patches.solid_angles.sum(),
|
|
719
|
+
"metadata": patches.metadata
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
# Add method-specific descriptions
|
|
723
|
+
if method.lower() == "tregenza":
|
|
724
|
+
info["description"] = "Standard 145-patch subdivision (Radiance, DAYSIM)"
|
|
725
|
+
info["reference"] = "Tregenza (1987)"
|
|
726
|
+
elif method.lower() == "reinhart":
|
|
727
|
+
info["description"] = f"High-resolution subdivision with MF={kwargs.get('mf', 4)}"
|
|
728
|
+
info["reference"] = "Reinhart & Walkenhorst (2001)"
|
|
729
|
+
elif method.lower() == "uniform":
|
|
730
|
+
info["description"] = f"Regular grid with {kwargs.get('n_azimuth', 36)}×{kwargs.get('n_elevation', 9)} patches"
|
|
731
|
+
elif method.lower() == "fibonacci":
|
|
732
|
+
info["description"] = "Quasi-uniform distribution using golden angle"
|
|
733
|
+
|
|
734
|
+
return info
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def calculate_cumulative_irradiance_weights(
|
|
738
|
+
binned_data: BinnedSolarData,
|
|
739
|
+
include_diffuse: bool = True
|
|
740
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
741
|
+
"""
|
|
742
|
+
Calculate patch weights for cumulative irradiance simulation.
|
|
743
|
+
|
|
744
|
+
Returns weights that can be used with ray tracing results to compute
|
|
745
|
+
cumulative irradiance. The direct component uses binned DNI values,
|
|
746
|
+
and the diffuse component is distributed isotropically.
|
|
747
|
+
|
|
748
|
+
Args:
|
|
749
|
+
binned_data: BinnedSolarData from bin_sun_positions_to_patches
|
|
750
|
+
include_diffuse: Whether to include diffuse component
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
Tuple of:
|
|
754
|
+
- direct_weights: DNI weight per patch (Wh/m²)
|
|
755
|
+
- diffuse_weights: DHI weight per patch (Wh/m²)
|
|
756
|
+
|
|
757
|
+
Example:
|
|
758
|
+
>>> binned = bin_sun_positions_to_patches(az, el, dni, dhi)
|
|
759
|
+
>>> direct_w, diffuse_w = calculate_cumulative_irradiance_weights(binned)
|
|
760
|
+
>>> # Use with ray tracing:
|
|
761
|
+
>>> # cumulative_irradiance = sum(visibility * direct_w + svf * diffuse_w)
|
|
762
|
+
"""
|
|
763
|
+
sky_patches = binned_data.sky_patches
|
|
764
|
+
|
|
765
|
+
# Direct weights are simply the cumulative DNI per patch
|
|
766
|
+
direct_weights = binned_data.cumulative_dni.copy()
|
|
767
|
+
|
|
768
|
+
# Diffuse weights: distribute total DHI by solid angle fraction
|
|
769
|
+
if include_diffuse and binned_data.cumulative_dhi > 0:
|
|
770
|
+
# Each patch receives DHI proportional to its solid angle
|
|
771
|
+
# The solid angle fraction represents how much of the hemisphere it covers
|
|
772
|
+
solid_angle_fraction = sky_patches.solid_angles / sky_patches.solid_angles.sum()
|
|
773
|
+
diffuse_weights = binned_data.cumulative_dhi * solid_angle_fraction
|
|
774
|
+
else:
|
|
775
|
+
diffuse_weights = np.zeros(sky_patches.n_patches, dtype=np.float64)
|
|
776
|
+
|
|
777
|
+
return direct_weights, diffuse_weights
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def visualize_sky_patches(
|
|
781
|
+
method: str = "tregenza",
|
|
782
|
+
ax=None,
|
|
783
|
+
show: bool = True,
|
|
784
|
+
**kwargs
|
|
785
|
+
):
|
|
786
|
+
"""
|
|
787
|
+
Visualize sky patches on a polar plot.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
method: Sky discretization method
|
|
791
|
+
show: Whether to call plt.show()
|
|
792
|
+
**kwargs: Method-specific parameters
|
|
793
|
+
|
|
794
|
+
Returns:
|
|
795
|
+
matplotlib axis object
|
|
796
|
+
"""
|
|
797
|
+
import matplotlib.pyplot as plt
|
|
798
|
+
|
|
799
|
+
patches = generate_sky_patches(method, **kwargs)
|
|
800
|
+
|
|
801
|
+
if ax is None:
|
|
802
|
+
_, ax = plt.subplots(subplot_kw={'projection': 'polar'}, figsize=(8, 8))
|
|
803
|
+
|
|
804
|
+
# Convert to polar coordinates (theta=azimuth, r=90-elevation)
|
|
805
|
+
theta = np.deg2rad(patches.patches[:, 0])
|
|
806
|
+
r = 90.0 - patches.patches[:, 1] # Zenith at center
|
|
807
|
+
|
|
808
|
+
# Color by solid angle
|
|
809
|
+
colors = patches.solid_angles / patches.solid_angles.max()
|
|
810
|
+
|
|
811
|
+
scatter = ax.scatter(theta, r, c=colors, s=10, alpha=0.7, cmap='viridis')
|
|
812
|
+
|
|
813
|
+
ax.set_theta_zero_location('N')
|
|
814
|
+
ax.set_theta_direction(-1)
|
|
815
|
+
ax.set_rlim(0, 90)
|
|
816
|
+
ax.set_rticks([0, 30, 60, 90])
|
|
817
|
+
ax.set_yticklabels(['90°', '60°', '30°', '0°'])
|
|
818
|
+
ax.set_title(f"{method.capitalize()} Sky Patches (n={patches.n_patches})")
|
|
819
|
+
|
|
820
|
+
plt.colorbar(scatter, ax=ax, label='Relative Solid Angle')
|
|
821
|
+
|
|
822
|
+
if show:
|
|
823
|
+
plt.show()
|
|
824
|
+
|
|
825
|
+
return ax
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
# =============================================================================
|
|
829
|
+
# VoxCity API Compatibility Aliases
|
|
830
|
+
# =============================================================================
|
|
831
|
+
|
|
832
|
+
# Alias for VoxCity compatibility - the function is already Numba-accelerated
|
|
833
|
+
get_tregenza_patch_index_fast = get_tregenza_patch_index
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def bin_sun_positions_to_tregenza_fast(
|
|
837
|
+
azimuth_arr: np.ndarray,
|
|
838
|
+
elevation_arr: np.ndarray,
|
|
839
|
+
dni_arr: np.ndarray
|
|
840
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
841
|
+
"""
|
|
842
|
+
Numba-accelerated binning of sun positions to Tregenza patches.
|
|
843
|
+
|
|
844
|
+
This function matches the signature of voxcity.simulator.solar.sky.bin_sun_positions_to_tregenza_fast.
|
|
845
|
+
|
|
846
|
+
Args:
|
|
847
|
+
azimuth_arr: Array of solar azimuth values in degrees
|
|
848
|
+
elevation_arr: Array of solar elevation values in degrees
|
|
849
|
+
dni_arr: Array of Direct Normal Irradiance values (W/m²)
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
Tuple of (cumulative_dni, hours_count) arrays:
|
|
853
|
+
- cumulative_dni: shape (145,) - Cumulative DNI (W·h/m²) for each Tregenza patch
|
|
854
|
+
- hours_count: shape (145,) - Number of hours with sun in each patch
|
|
855
|
+
"""
|
|
856
|
+
return _bin_tregenza_fast(azimuth_arr, elevation_arr, dni_arr)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def visualize_binned_radiation(
|
|
860
|
+
binned_data: BinnedSolarData,
|
|
861
|
+
show: bool = True
|
|
862
|
+
):
|
|
863
|
+
"""
|
|
864
|
+
Visualize binned solar radiation on a polar plot.
|
|
865
|
+
|
|
866
|
+
Args:
|
|
867
|
+
binned_data: BinnedSolarData from bin_sun_positions_to_patches
|
|
868
|
+
show: Whether to call plt.show()
|
|
869
|
+
|
|
870
|
+
Returns:
|
|
871
|
+
matplotlib axis object
|
|
872
|
+
"""
|
|
873
|
+
import matplotlib.pyplot as plt
|
|
874
|
+
|
|
875
|
+
patches = binned_data.sky_patches
|
|
876
|
+
|
|
877
|
+
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}, figsize=(8, 8))
|
|
878
|
+
|
|
879
|
+
# Convert to polar coordinates
|
|
880
|
+
theta = np.deg2rad(patches.patches[:, 0])
|
|
881
|
+
r = 90.0 - patches.patches[:, 1]
|
|
882
|
+
|
|
883
|
+
# Color by cumulative DNI (log scale for better visualization)
|
|
884
|
+
dni = binned_data.cumulative_dni
|
|
885
|
+
dni_log = np.log10(dni + 1) # Add 1 to avoid log(0)
|
|
886
|
+
|
|
887
|
+
# Size by hours
|
|
888
|
+
sizes = 10 + binned_data.hours_per_patch * 2
|
|
889
|
+
|
|
890
|
+
scatter = ax.scatter(theta, r, c=dni_log, s=sizes, alpha=0.7, cmap='hot')
|
|
891
|
+
|
|
892
|
+
ax.set_theta_zero_location('N')
|
|
893
|
+
ax.set_theta_direction(-1)
|
|
894
|
+
ax.set_rlim(0, 90)
|
|
895
|
+
ax.set_rticks([0, 30, 60, 90])
|
|
896
|
+
ax.set_yticklabels(['90°', '60°', '30°', '0°'])
|
|
897
|
+
ax.set_title(
|
|
898
|
+
f"Binned Solar Radiation ({patches.method.capitalize()})\n"
|
|
899
|
+
f"Total Hours: {binned_data.total_daytime_hours}"
|
|
900
|
+
)
|
|
901
|
+
|
|
902
|
+
cbar = plt.colorbar(scatter, ax=ax, label='log₁₀(DNI + 1) [Wh/m²]')
|
|
903
|
+
|
|
904
|
+
if show:
|
|
905
|
+
plt.show()
|
|
906
|
+
|
|
907
|
+
return ax
|