voxcity 0.7.0__py3-none-any.whl → 1.0.13__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/__init__.py +14 -14
- voxcity/downloader/ocean.py +559 -0
- voxcity/exporter/__init__.py +12 -12
- voxcity/exporter/cityles.py +633 -633
- voxcity/exporter/envimet.py +733 -728
- voxcity/exporter/magicavoxel.py +333 -333
- voxcity/exporter/netcdf.py +238 -238
- voxcity/exporter/obj.py +1480 -1480
- voxcity/generator/__init__.py +47 -44
- voxcity/generator/api.py +727 -675
- voxcity/generator/grids.py +394 -379
- voxcity/generator/io.py +94 -94
- voxcity/generator/pipeline.py +582 -282
- voxcity/generator/update.py +429 -0
- voxcity/generator/voxelizer.py +18 -6
- voxcity/geoprocessor/__init__.py +75 -75
- voxcity/geoprocessor/draw.py +1494 -1219
- voxcity/geoprocessor/merge_utils.py +91 -91
- voxcity/geoprocessor/mesh.py +806 -806
- voxcity/geoprocessor/network.py +708 -708
- voxcity/geoprocessor/raster/__init__.py +2 -0
- voxcity/geoprocessor/raster/buildings.py +435 -428
- voxcity/geoprocessor/raster/core.py +31 -0
- voxcity/geoprocessor/raster/export.py +93 -93
- voxcity/geoprocessor/raster/landcover.py +178 -51
- voxcity/geoprocessor/raster/raster.py +1 -1
- voxcity/geoprocessor/utils.py +824 -824
- voxcity/models.py +115 -113
- voxcity/simulator/solar/__init__.py +66 -43
- voxcity/simulator/solar/integration.py +336 -336
- voxcity/simulator/solar/sky.py +668 -0
- voxcity/simulator/solar/temporal.py +792 -434
- voxcity/simulator_gpu/__init__.py +115 -0
- voxcity/simulator_gpu/common/__init__.py +9 -0
- voxcity/simulator_gpu/common/geometry.py +11 -0
- voxcity/simulator_gpu/core.py +322 -0
- voxcity/simulator_gpu/domain.py +262 -0
- voxcity/simulator_gpu/environment.yml +11 -0
- voxcity/simulator_gpu/init_taichi.py +154 -0
- voxcity/simulator_gpu/integration.py +15 -0
- voxcity/simulator_gpu/kernels.py +56 -0
- voxcity/simulator_gpu/radiation.py +28 -0
- voxcity/simulator_gpu/raytracing.py +623 -0
- voxcity/simulator_gpu/sky.py +9 -0
- voxcity/simulator_gpu/solar/__init__.py +178 -0
- voxcity/simulator_gpu/solar/core.py +66 -0
- voxcity/simulator_gpu/solar/csf.py +1249 -0
- voxcity/simulator_gpu/solar/domain.py +561 -0
- voxcity/simulator_gpu/solar/epw.py +421 -0
- voxcity/simulator_gpu/solar/integration.py +2953 -0
- voxcity/simulator_gpu/solar/radiation.py +3019 -0
- voxcity/simulator_gpu/solar/raytracing.py +686 -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 +1151 -0
- voxcity/simulator_gpu/solar/voxcity.py +2953 -0
- voxcity/simulator_gpu/temporal.py +13 -0
- voxcity/simulator_gpu/utils.py +25 -0
- voxcity/simulator_gpu/view.py +32 -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/utils/__init__.py +11 -0
- voxcity/utils/classes.py +194 -0
- voxcity/utils/lc.py +80 -39
- voxcity/utils/shape.py +230 -0
- voxcity/visualizer/__init__.py +24 -24
- voxcity/visualizer/builder.py +43 -43
- voxcity/visualizer/grids.py +141 -141
- voxcity/visualizer/maps.py +187 -187
- voxcity/visualizer/renderer.py +1146 -928
- {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/METADATA +56 -52
- voxcity-1.0.13.dist-info/RECORD +116 -0
- voxcity-0.7.0.dist-info/RECORD +0 -77
- {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Sky Discretization Methods for Solar Simulation.
|
|
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
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from numba import njit
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# =============================================================================
|
|
21
|
+
# Tregenza Sky Subdivision (145 patches)
|
|
22
|
+
# =============================================================================
|
|
23
|
+
|
|
24
|
+
# Tregenza band definitions: (elevation_center, num_patches_in_band)
|
|
25
|
+
TREGENZA_BANDS = [
|
|
26
|
+
(6.0, 30), # Band 1: 0°-12°, center at 6°
|
|
27
|
+
(18.0, 30), # Band 2: 12°-24°, center at 18°
|
|
28
|
+
(30.0, 24), # Band 3: 24°-36°, center at 24°
|
|
29
|
+
(42.0, 24), # Band 4: 36°-48°, center at 42°
|
|
30
|
+
(54.0, 18), # Band 5: 48°-60°, center at 54°
|
|
31
|
+
(66.0, 12), # Band 6: 60°-72°, center at 66°
|
|
32
|
+
(78.0, 6), # Band 7: 72°-84°, center at 78°
|
|
33
|
+
(90.0, 1), # Band 8: 84°-90°, zenith patch
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
# Tregenza band elevation boundaries (for binning)
|
|
37
|
+
TREGENZA_BAND_BOUNDARIES = [0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 90.0]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def generate_tregenza_patches():
|
|
41
|
+
"""
|
|
42
|
+
Generate the 145 Tregenza sky patch center directions.
|
|
43
|
+
|
|
44
|
+
The Tregenza subdivision divides the sky hemisphere into 145 patches
|
|
45
|
+
arranged in 8 altitude bands. This is the standard sky discretization
|
|
46
|
+
used in Radiance (genskyvec), EnergyPlus, DAYSIM, and Ladybug Tools.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
patches : np.ndarray, shape (145, 2)
|
|
51
|
+
Array of (azimuth_degrees, elevation_degrees) for each patch center.
|
|
52
|
+
directions : np.ndarray, shape (145, 3)
|
|
53
|
+
Unit direction vectors (dx, dy, dz) pointing to each patch center.
|
|
54
|
+
solid_angles : np.ndarray, shape (145,)
|
|
55
|
+
Solid angle (steradians) of each patch.
|
|
56
|
+
|
|
57
|
+
References
|
|
58
|
+
----------
|
|
59
|
+
Tregenza, P.R. (1987). "Subdivision of the sky hemisphere for luminance
|
|
60
|
+
measurements." Lighting Research & Technology, 19(1), 13-14.
|
|
61
|
+
"""
|
|
62
|
+
patches = []
|
|
63
|
+
directions = []
|
|
64
|
+
solid_angles = []
|
|
65
|
+
|
|
66
|
+
for band_idx, (elev_center, n_patches) in enumerate(TREGENZA_BANDS):
|
|
67
|
+
elev_rad = np.deg2rad(elev_center)
|
|
68
|
+
cos_elev = np.cos(elev_rad)
|
|
69
|
+
sin_elev = np.sin(elev_rad)
|
|
70
|
+
|
|
71
|
+
# Solid angle calculation for band
|
|
72
|
+
if band_idx == 0:
|
|
73
|
+
elev_low = 0.0
|
|
74
|
+
else:
|
|
75
|
+
elev_low = TREGENZA_BAND_BOUNDARIES[band_idx]
|
|
76
|
+
elev_high = TREGENZA_BAND_BOUNDARIES[band_idx + 1]
|
|
77
|
+
|
|
78
|
+
# Solid angle of band = 2π × (sin(θ_high) - sin(θ_low))
|
|
79
|
+
band_solid_angle = 2 * np.pi * (
|
|
80
|
+
np.sin(np.deg2rad(elev_high)) - np.sin(np.deg2rad(elev_low))
|
|
81
|
+
)
|
|
82
|
+
patch_solid_angle = band_solid_angle / n_patches
|
|
83
|
+
|
|
84
|
+
for i in range(n_patches):
|
|
85
|
+
# Azimuth at patch center
|
|
86
|
+
az_deg = (i + 0.5) * 360.0 / n_patches
|
|
87
|
+
az_rad = np.deg2rad(az_deg)
|
|
88
|
+
|
|
89
|
+
# Direction vector
|
|
90
|
+
dx = cos_elev * np.cos(az_rad)
|
|
91
|
+
dy = cos_elev * np.sin(az_rad)
|
|
92
|
+
dz = sin_elev
|
|
93
|
+
|
|
94
|
+
patches.append((az_deg, elev_center))
|
|
95
|
+
directions.append((dx, dy, dz))
|
|
96
|
+
solid_angles.append(patch_solid_angle)
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
np.array(patches, dtype=np.float64),
|
|
100
|
+
np.array(directions, dtype=np.float64),
|
|
101
|
+
np.array(solid_angles, dtype=np.float64),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_tregenza_patch_index(azimuth_deg, elevation_deg):
|
|
106
|
+
"""
|
|
107
|
+
Get the Tregenza patch index for a given sun position.
|
|
108
|
+
|
|
109
|
+
Parameters
|
|
110
|
+
----------
|
|
111
|
+
azimuth_deg : float
|
|
112
|
+
Solar azimuth in degrees (0-360, measured clockwise from north).
|
|
113
|
+
elevation_deg : float
|
|
114
|
+
Solar elevation in degrees (0-90).
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
int
|
|
119
|
+
Patch index (0-144), or -1 if below horizon.
|
|
120
|
+
"""
|
|
121
|
+
if elevation_deg < 0:
|
|
122
|
+
return -1
|
|
123
|
+
|
|
124
|
+
# Find altitude band
|
|
125
|
+
band_idx = 0
|
|
126
|
+
patch_offset = 0
|
|
127
|
+
for i, boundary in enumerate(TREGENZA_BAND_BOUNDARIES[1:]):
|
|
128
|
+
if elevation_deg < boundary:
|
|
129
|
+
band_idx = i
|
|
130
|
+
break
|
|
131
|
+
patch_offset += TREGENZA_BANDS[i][1]
|
|
132
|
+
else:
|
|
133
|
+
# Zenith patch
|
|
134
|
+
return 144
|
|
135
|
+
|
|
136
|
+
# Find azimuth patch within band
|
|
137
|
+
n_patches = TREGENZA_BANDS[band_idx][1]
|
|
138
|
+
az_normalized = azimuth_deg % 360.0
|
|
139
|
+
patch_in_band = int(az_normalized / (360.0 / n_patches))
|
|
140
|
+
patch_in_band = min(patch_in_band, n_patches - 1)
|
|
141
|
+
|
|
142
|
+
return patch_offset + patch_in_band
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@njit(cache=True)
|
|
146
|
+
def get_tregenza_patch_index_fast(azimuth_deg, elevation_deg):
|
|
147
|
+
"""
|
|
148
|
+
Numba-accelerated version of get_tregenza_patch_index.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
azimuth_deg : float
|
|
153
|
+
Solar azimuth in degrees (0-360).
|
|
154
|
+
elevation_deg : float
|
|
155
|
+
Solar elevation in degrees (0-90).
|
|
156
|
+
|
|
157
|
+
Returns
|
|
158
|
+
-------
|
|
159
|
+
int
|
|
160
|
+
Patch index (0-144), or -1 if below horizon.
|
|
161
|
+
"""
|
|
162
|
+
if elevation_deg < 0.0:
|
|
163
|
+
return -1
|
|
164
|
+
|
|
165
|
+
# Band boundaries and patch counts (hardcoded for Numba)
|
|
166
|
+
boundaries = np.array([0.0, 12.0, 24.0, 36.0, 48.0, 60.0, 72.0, 84.0, 90.0])
|
|
167
|
+
patch_counts = np.array([30, 30, 24, 24, 18, 12, 6, 1])
|
|
168
|
+
|
|
169
|
+
# Find band
|
|
170
|
+
band_idx = 7 # Default to zenith band
|
|
171
|
+
for i in range(7):
|
|
172
|
+
if elevation_deg < boundaries[i + 1]:
|
|
173
|
+
band_idx = i
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
# Calculate offset to this band
|
|
177
|
+
patch_offset = 0
|
|
178
|
+
for i in range(band_idx):
|
|
179
|
+
patch_offset += patch_counts[i]
|
|
180
|
+
|
|
181
|
+
# Find patch within band
|
|
182
|
+
n_patches = patch_counts[band_idx]
|
|
183
|
+
if n_patches == 1:
|
|
184
|
+
return patch_offset # Zenith
|
|
185
|
+
|
|
186
|
+
az_normalized = azimuth_deg % 360.0
|
|
187
|
+
patch_in_band = int(az_normalized / (360.0 / n_patches))
|
|
188
|
+
if patch_in_band >= n_patches:
|
|
189
|
+
patch_in_band = n_patches - 1
|
|
190
|
+
|
|
191
|
+
return patch_offset + patch_in_band
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# =============================================================================
|
|
195
|
+
# Reinhart Sky Subdivision (Tregenza × MF²)
|
|
196
|
+
# =============================================================================
|
|
197
|
+
|
|
198
|
+
def generate_reinhart_patches(mf=4):
|
|
199
|
+
"""
|
|
200
|
+
Generate Reinhart sky patches (subdivided Tregenza).
|
|
201
|
+
|
|
202
|
+
The Reinhart subdivision increases resolution by subdividing each Tregenza
|
|
203
|
+
patch by a multiplication factor (MF). With MF=4, this yields 2305 patches.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
mf : int
|
|
208
|
+
Multiplication factor. Common values:
|
|
209
|
+
- MF=1: 145 patches (same as Tregenza)
|
|
210
|
+
- MF=2: 577 patches
|
|
211
|
+
- MF=4: 2305 patches (common for annual daylight simulation)
|
|
212
|
+
- MF=6: 5185 patches
|
|
213
|
+
|
|
214
|
+
Returns
|
|
215
|
+
-------
|
|
216
|
+
patches : np.ndarray, shape (N, 2)
|
|
217
|
+
Array of (azimuth_degrees, elevation_degrees) for each patch center.
|
|
218
|
+
directions : np.ndarray, shape (N, 3)
|
|
219
|
+
Unit direction vectors (dx, dy, dz) for each patch center.
|
|
220
|
+
solid_angles : np.ndarray, shape (N,)
|
|
221
|
+
Solid angle (steradians) of each patch.
|
|
222
|
+
|
|
223
|
+
References
|
|
224
|
+
----------
|
|
225
|
+
Reinhart, C.F. & Walkenhorst, O. (2001). "Validation of dynamic RADIANCE-based
|
|
226
|
+
daylight simulations for a test office with external blinds." Energy and
|
|
227
|
+
Buildings, 33(7), 683-697.
|
|
228
|
+
"""
|
|
229
|
+
mf = max(1, int(mf))
|
|
230
|
+
patches = []
|
|
231
|
+
directions = []
|
|
232
|
+
solid_angles = []
|
|
233
|
+
|
|
234
|
+
for band_idx, (elev_center_base, n_patches_base) in enumerate(TREGENZA_BANDS):
|
|
235
|
+
# Subdivide elevation bands
|
|
236
|
+
if band_idx == len(TREGENZA_BANDS) - 1:
|
|
237
|
+
# Zenith: subdivide into MF² patches arranged in concentric rings
|
|
238
|
+
n_sub_bands = mf
|
|
239
|
+
else:
|
|
240
|
+
n_sub_bands = mf
|
|
241
|
+
|
|
242
|
+
elev_low = TREGENZA_BAND_BOUNDARIES[band_idx]
|
|
243
|
+
elev_high = TREGENZA_BAND_BOUNDARIES[band_idx + 1]
|
|
244
|
+
elev_range = elev_high - elev_low
|
|
245
|
+
|
|
246
|
+
for sub_band in range(n_sub_bands):
|
|
247
|
+
# Sub-band elevation center
|
|
248
|
+
sub_elev_low = elev_low + sub_band * elev_range / n_sub_bands
|
|
249
|
+
sub_elev_high = elev_low + (sub_band + 1) * elev_range / n_sub_bands
|
|
250
|
+
sub_elev_center = (sub_elev_low + sub_elev_high) / 2.0
|
|
251
|
+
|
|
252
|
+
elev_rad = np.deg2rad(sub_elev_center)
|
|
253
|
+
cos_elev = np.cos(elev_rad)
|
|
254
|
+
sin_elev = np.sin(elev_rad)
|
|
255
|
+
|
|
256
|
+
# Solid angle of sub-band
|
|
257
|
+
sub_band_solid_angle = 2 * np.pi * (
|
|
258
|
+
np.sin(np.deg2rad(sub_elev_high)) - np.sin(np.deg2rad(sub_elev_low))
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# Number of azimuth patches in sub-band
|
|
262
|
+
if band_idx == len(TREGENZA_BANDS) - 1:
|
|
263
|
+
# Zenith: for innermost ring, use fewer patches
|
|
264
|
+
n_az = max(1, n_patches_base * mf * (sub_band + 1) // n_sub_bands)
|
|
265
|
+
else:
|
|
266
|
+
n_az = n_patches_base * mf
|
|
267
|
+
|
|
268
|
+
patch_solid_angle = sub_band_solid_angle / n_az
|
|
269
|
+
|
|
270
|
+
for i in range(n_az):
|
|
271
|
+
az_deg = (i + 0.5) * 360.0 / n_az
|
|
272
|
+
az_rad = np.deg2rad(az_deg)
|
|
273
|
+
|
|
274
|
+
dx = cos_elev * np.cos(az_rad)
|
|
275
|
+
dy = cos_elev * np.sin(az_rad)
|
|
276
|
+
dz = sin_elev
|
|
277
|
+
|
|
278
|
+
patches.append((az_deg, sub_elev_center))
|
|
279
|
+
directions.append((dx, dy, dz))
|
|
280
|
+
solid_angles.append(patch_solid_angle)
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
np.array(patches, dtype=np.float64),
|
|
284
|
+
np.array(directions, dtype=np.float64),
|
|
285
|
+
np.array(solid_angles, dtype=np.float64),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# =============================================================================
|
|
290
|
+
# Uniform Grid Subdivision
|
|
291
|
+
# =============================================================================
|
|
292
|
+
|
|
293
|
+
def generate_uniform_grid_patches(n_azimuth=36, n_elevation=9):
|
|
294
|
+
"""
|
|
295
|
+
Generate uniform grid sky patches.
|
|
296
|
+
|
|
297
|
+
Simple subdivision with equal azimuth and elevation spacing.
|
|
298
|
+
Note: This creates non-equal solid angle patches (smaller near zenith).
|
|
299
|
+
|
|
300
|
+
Parameters
|
|
301
|
+
----------
|
|
302
|
+
n_azimuth : int
|
|
303
|
+
Number of azimuth divisions (default: 36 = 10° spacing).
|
|
304
|
+
n_elevation : int
|
|
305
|
+
Number of elevation divisions (default: 9 = 10° spacing).
|
|
306
|
+
|
|
307
|
+
Returns
|
|
308
|
+
-------
|
|
309
|
+
patches : np.ndarray, shape (N, 2)
|
|
310
|
+
Array of (azimuth_degrees, elevation_degrees) for each patch center.
|
|
311
|
+
directions : np.ndarray, shape (N, 3)
|
|
312
|
+
Unit direction vectors for each patch center.
|
|
313
|
+
solid_angles : np.ndarray, shape (N,)
|
|
314
|
+
Solid angle (steradians) of each patch.
|
|
315
|
+
"""
|
|
316
|
+
patches = []
|
|
317
|
+
directions = []
|
|
318
|
+
solid_angles = []
|
|
319
|
+
|
|
320
|
+
elev_step = 90.0 / n_elevation
|
|
321
|
+
az_step = 360.0 / n_azimuth
|
|
322
|
+
|
|
323
|
+
for j in range(n_elevation):
|
|
324
|
+
elev_low = j * elev_step
|
|
325
|
+
elev_high = (j + 1) * elev_step
|
|
326
|
+
elev_center = (elev_low + elev_high) / 2.0
|
|
327
|
+
|
|
328
|
+
elev_rad = np.deg2rad(elev_center)
|
|
329
|
+
cos_elev = np.cos(elev_rad)
|
|
330
|
+
sin_elev = np.sin(elev_rad)
|
|
331
|
+
|
|
332
|
+
# Solid angle for this elevation band
|
|
333
|
+
band_solid_angle = 2 * np.pi * (
|
|
334
|
+
np.sin(np.deg2rad(elev_high)) - np.sin(np.deg2rad(elev_low))
|
|
335
|
+
)
|
|
336
|
+
patch_solid_angle = band_solid_angle / n_azimuth
|
|
337
|
+
|
|
338
|
+
for i in range(n_azimuth):
|
|
339
|
+
az_center = (i + 0.5) * az_step
|
|
340
|
+
az_rad = np.deg2rad(az_center)
|
|
341
|
+
|
|
342
|
+
dx = cos_elev * np.cos(az_rad)
|
|
343
|
+
dy = cos_elev * np.sin(az_rad)
|
|
344
|
+
dz = sin_elev
|
|
345
|
+
|
|
346
|
+
patches.append((az_center, elev_center))
|
|
347
|
+
directions.append((dx, dy, dz))
|
|
348
|
+
solid_angles.append(patch_solid_angle)
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
np.array(patches, dtype=np.float64),
|
|
352
|
+
np.array(directions, dtype=np.float64),
|
|
353
|
+
np.array(solid_angles, dtype=np.float64),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# =============================================================================
|
|
358
|
+
# Fibonacci Spiral (Quasi-Uniform)
|
|
359
|
+
# =============================================================================
|
|
360
|
+
|
|
361
|
+
def generate_fibonacci_patches(n_patches=145):
|
|
362
|
+
"""
|
|
363
|
+
Generate quasi-uniform sky patches using Fibonacci spiral.
|
|
364
|
+
|
|
365
|
+
Uses the golden angle spiral to distribute points nearly uniformly
|
|
366
|
+
on the hemisphere. This provides more uniform patch areas than
|
|
367
|
+
regular grids with fewer total patches.
|
|
368
|
+
|
|
369
|
+
Parameters
|
|
370
|
+
----------
|
|
371
|
+
n_patches : int
|
|
372
|
+
Number of patches to generate (default: 145 to match Tregenza).
|
|
373
|
+
|
|
374
|
+
Returns
|
|
375
|
+
-------
|
|
376
|
+
patches : np.ndarray, shape (N, 2)
|
|
377
|
+
Array of (azimuth_degrees, elevation_degrees) for each patch center.
|
|
378
|
+
directions : np.ndarray, shape (N, 3)
|
|
379
|
+
Unit direction vectors for each patch center.
|
|
380
|
+
solid_angles : np.ndarray, shape (N,)
|
|
381
|
+
Approximate solid angle per patch (uniform for Fibonacci).
|
|
382
|
+
"""
|
|
383
|
+
n = max(1, int(n_patches))
|
|
384
|
+
golden_angle = np.pi * (3.0 - np.sqrt(5.0))
|
|
385
|
+
|
|
386
|
+
# Hemisphere solid angle = 2π steradians
|
|
387
|
+
patch_solid_angle = 2.0 * np.pi / n
|
|
388
|
+
|
|
389
|
+
patches = []
|
|
390
|
+
directions = []
|
|
391
|
+
solid_angles = []
|
|
392
|
+
|
|
393
|
+
for i in range(n):
|
|
394
|
+
# z ranges from 0 (horizon) to 1 (zenith)
|
|
395
|
+
z = (i + 0.5) / n
|
|
396
|
+
elevation_rad = np.arcsin(z)
|
|
397
|
+
elevation_deg = np.rad2deg(elevation_rad)
|
|
398
|
+
|
|
399
|
+
# Azimuth from golden angle
|
|
400
|
+
azimuth_rad = i * golden_angle
|
|
401
|
+
azimuth_deg = np.rad2deg(azimuth_rad) % 360.0
|
|
402
|
+
|
|
403
|
+
# Direction vector
|
|
404
|
+
r = np.sqrt(1.0 - z * z)
|
|
405
|
+
dx = r * np.cos(azimuth_rad)
|
|
406
|
+
dy = r * np.sin(azimuth_rad)
|
|
407
|
+
dz = z
|
|
408
|
+
|
|
409
|
+
patches.append((azimuth_deg, elevation_deg))
|
|
410
|
+
directions.append((dx, dy, dz))
|
|
411
|
+
solid_angles.append(patch_solid_angle)
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
np.array(patches, dtype=np.float64),
|
|
415
|
+
np.array(directions, dtype=np.float64),
|
|
416
|
+
np.array(solid_angles, dtype=np.float64),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# =============================================================================
|
|
421
|
+
# Sun Position Binning
|
|
422
|
+
# =============================================================================
|
|
423
|
+
|
|
424
|
+
def bin_sun_positions_to_patches(
|
|
425
|
+
azimuth_arr,
|
|
426
|
+
elevation_arr,
|
|
427
|
+
dni_arr,
|
|
428
|
+
method="tregenza",
|
|
429
|
+
**kwargs
|
|
430
|
+
):
|
|
431
|
+
"""
|
|
432
|
+
Bin hourly sun positions into sky patches and aggregate DNI.
|
|
433
|
+
|
|
434
|
+
This is the key optimization for cumulative solar irradiance: instead of
|
|
435
|
+
tracing rays for every hourly sun position, aggregate DNI values for each
|
|
436
|
+
sky patch and trace rays once per patch.
|
|
437
|
+
|
|
438
|
+
Parameters
|
|
439
|
+
----------
|
|
440
|
+
azimuth_arr : np.ndarray
|
|
441
|
+
Array of solar azimuth values in degrees.
|
|
442
|
+
elevation_arr : np.ndarray
|
|
443
|
+
Array of solar elevation values in degrees.
|
|
444
|
+
dni_arr : np.ndarray
|
|
445
|
+
Array of Direct Normal Irradiance values (W/m²).
|
|
446
|
+
method : str
|
|
447
|
+
Sky discretization method: "tregenza", "reinhart", "uniform", "fibonacci".
|
|
448
|
+
**kwargs : dict
|
|
449
|
+
Additional parameters for patch generation (e.g., mf for Reinhart).
|
|
450
|
+
|
|
451
|
+
Returns
|
|
452
|
+
-------
|
|
453
|
+
patch_directions : np.ndarray, shape (N, 3)
|
|
454
|
+
Unit direction vectors for each patch.
|
|
455
|
+
patch_cumulative_dni : np.ndarray, shape (N,)
|
|
456
|
+
Cumulative DNI (W·h/m²) for each patch.
|
|
457
|
+
patch_solid_angles : np.ndarray, shape (N,)
|
|
458
|
+
Solid angle of each patch.
|
|
459
|
+
patch_hours : np.ndarray, shape (N,)
|
|
460
|
+
Number of hours with sun in each patch.
|
|
461
|
+
"""
|
|
462
|
+
# Generate patches based on method
|
|
463
|
+
if method.lower() == "tregenza":
|
|
464
|
+
patches, directions, solid_angles = generate_tregenza_patches()
|
|
465
|
+
elif method.lower() == "reinhart":
|
|
466
|
+
mf = kwargs.get("mf", 4)
|
|
467
|
+
patches, directions, solid_angles = generate_reinhart_patches(mf=mf)
|
|
468
|
+
elif method.lower() == "uniform":
|
|
469
|
+
n_az = kwargs.get("n_azimuth", 36)
|
|
470
|
+
n_el = kwargs.get("n_elevation", 9)
|
|
471
|
+
patches, directions, solid_angles = generate_uniform_grid_patches(n_az, n_el)
|
|
472
|
+
elif method.lower() == "fibonacci":
|
|
473
|
+
n = kwargs.get("n_patches", 145)
|
|
474
|
+
patches, directions, solid_angles = generate_fibonacci_patches(n_patches=n)
|
|
475
|
+
else:
|
|
476
|
+
raise ValueError(f"Unknown sky discretization method: {method}")
|
|
477
|
+
|
|
478
|
+
n_patches = len(patches)
|
|
479
|
+
cumulative_dni = np.zeros(n_patches, dtype=np.float64)
|
|
480
|
+
hours_count = np.zeros(n_patches, dtype=np.int32)
|
|
481
|
+
|
|
482
|
+
# Bin each sun position
|
|
483
|
+
for i in range(len(azimuth_arr)):
|
|
484
|
+
elev = elevation_arr[i]
|
|
485
|
+
if elev <= 0:
|
|
486
|
+
continue # Below horizon
|
|
487
|
+
|
|
488
|
+
az = azimuth_arr[i]
|
|
489
|
+
dni = dni_arr[i]
|
|
490
|
+
|
|
491
|
+
# Find nearest patch
|
|
492
|
+
if method.lower() == "tregenza":
|
|
493
|
+
patch_idx = get_tregenza_patch_index(az, elev)
|
|
494
|
+
else:
|
|
495
|
+
# For other methods, find nearest patch by direction
|
|
496
|
+
elev_rad = np.deg2rad(elev)
|
|
497
|
+
az_rad = np.deg2rad(az)
|
|
498
|
+
sun_dir = np.array([
|
|
499
|
+
np.cos(elev_rad) * np.cos(az_rad),
|
|
500
|
+
np.cos(elev_rad) * np.sin(az_rad),
|
|
501
|
+
np.sin(elev_rad)
|
|
502
|
+
])
|
|
503
|
+
# Dot product with all patch directions
|
|
504
|
+
dots = np.sum(directions * sun_dir, axis=1)
|
|
505
|
+
patch_idx = np.argmax(dots)
|
|
506
|
+
|
|
507
|
+
if patch_idx >= 0:
|
|
508
|
+
cumulative_dni[patch_idx] += dni
|
|
509
|
+
hours_count[patch_idx] += 1
|
|
510
|
+
|
|
511
|
+
return directions, cumulative_dni, solid_angles, hours_count
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@njit(cache=True, parallel=True)
|
|
515
|
+
def bin_sun_positions_to_tregenza_fast(azimuth_arr, elevation_arr, dni_arr):
|
|
516
|
+
"""
|
|
517
|
+
Numba-accelerated binning of sun positions to Tregenza patches.
|
|
518
|
+
|
|
519
|
+
Parameters
|
|
520
|
+
----------
|
|
521
|
+
azimuth_arr : np.ndarray
|
|
522
|
+
Array of solar azimuth values in degrees.
|
|
523
|
+
elevation_arr : np.ndarray
|
|
524
|
+
Array of solar elevation values in degrees.
|
|
525
|
+
dni_arr : np.ndarray
|
|
526
|
+
Array of Direct Normal Irradiance values (W/m²).
|
|
527
|
+
|
|
528
|
+
Returns
|
|
529
|
+
-------
|
|
530
|
+
cumulative_dni : np.ndarray, shape (145,)
|
|
531
|
+
Cumulative DNI (W·h/m²) for each Tregenza patch.
|
|
532
|
+
hours_count : np.ndarray, shape (145,)
|
|
533
|
+
Number of hours with sun in each patch.
|
|
534
|
+
"""
|
|
535
|
+
cumulative_dni = np.zeros(145, dtype=np.float64)
|
|
536
|
+
hours_count = np.zeros(145, dtype=np.int32)
|
|
537
|
+
|
|
538
|
+
n = len(azimuth_arr)
|
|
539
|
+
for i in range(n):
|
|
540
|
+
elev = elevation_arr[i]
|
|
541
|
+
if elev <= 0.0:
|
|
542
|
+
continue
|
|
543
|
+
|
|
544
|
+
az = azimuth_arr[i]
|
|
545
|
+
dni = dni_arr[i]
|
|
546
|
+
|
|
547
|
+
patch_idx = get_tregenza_patch_index_fast(az, elev)
|
|
548
|
+
if patch_idx >= 0:
|
|
549
|
+
cumulative_dni[patch_idx] += dni
|
|
550
|
+
hours_count[patch_idx] += 1
|
|
551
|
+
|
|
552
|
+
return cumulative_dni, hours_count
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# =============================================================================
|
|
556
|
+
# Utility Functions
|
|
557
|
+
# =============================================================================
|
|
558
|
+
|
|
559
|
+
def get_patch_info(method="tregenza", **kwargs):
|
|
560
|
+
"""
|
|
561
|
+
Get information about a sky discretization method.
|
|
562
|
+
|
|
563
|
+
Parameters
|
|
564
|
+
----------
|
|
565
|
+
method : str
|
|
566
|
+
Sky discretization method.
|
|
567
|
+
**kwargs : dict
|
|
568
|
+
Additional parameters for the method.
|
|
569
|
+
|
|
570
|
+
Returns
|
|
571
|
+
-------
|
|
572
|
+
dict
|
|
573
|
+
Dictionary with patch count, method name, and parameters.
|
|
574
|
+
"""
|
|
575
|
+
if method.lower() == "tregenza":
|
|
576
|
+
patches, _, _ = generate_tregenza_patches()
|
|
577
|
+
return {
|
|
578
|
+
"method": "Tregenza",
|
|
579
|
+
"n_patches": len(patches),
|
|
580
|
+
"description": "Standard 145-patch subdivision (Radiance, DAYSIM)",
|
|
581
|
+
"reference": "Tregenza (1987)"
|
|
582
|
+
}
|
|
583
|
+
elif method.lower() == "reinhart":
|
|
584
|
+
mf = kwargs.get("mf", 4)
|
|
585
|
+
patches, _, _ = generate_reinhart_patches(mf=mf)
|
|
586
|
+
return {
|
|
587
|
+
"method": "Reinhart",
|
|
588
|
+
"n_patches": len(patches),
|
|
589
|
+
"mf": mf,
|
|
590
|
+
"description": f"High-resolution subdivision with MF={mf}",
|
|
591
|
+
"reference": "Reinhart & Walkenhorst (2001)"
|
|
592
|
+
}
|
|
593
|
+
elif method.lower() == "uniform":
|
|
594
|
+
n_az = kwargs.get("n_azimuth", 36)
|
|
595
|
+
n_el = kwargs.get("n_elevation", 9)
|
|
596
|
+
patches, _, _ = generate_uniform_grid_patches(n_az, n_el)
|
|
597
|
+
return {
|
|
598
|
+
"method": "Uniform Grid",
|
|
599
|
+
"n_patches": len(patches),
|
|
600
|
+
"n_azimuth": n_az,
|
|
601
|
+
"n_elevation": n_el,
|
|
602
|
+
"description": f"Regular grid with {n_az}×{n_el} patches"
|
|
603
|
+
}
|
|
604
|
+
elif method.lower() == "fibonacci":
|
|
605
|
+
n = kwargs.get("n_patches", 145)
|
|
606
|
+
patches, _, _ = generate_fibonacci_patches(n_patches=n)
|
|
607
|
+
return {
|
|
608
|
+
"method": "Fibonacci Spiral",
|
|
609
|
+
"n_patches": len(patches),
|
|
610
|
+
"description": "Quasi-uniform distribution using golden angle"
|
|
611
|
+
}
|
|
612
|
+
else:
|
|
613
|
+
raise ValueError(f"Unknown method: {method}")
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def visualize_sky_patches(method="tregenza", ax=None, show=True, **kwargs):
|
|
617
|
+
"""
|
|
618
|
+
Visualize sky patches on a polar plot.
|
|
619
|
+
|
|
620
|
+
Parameters
|
|
621
|
+
----------
|
|
622
|
+
method : str
|
|
623
|
+
Sky discretization method.
|
|
624
|
+
ax : matplotlib axis, optional
|
|
625
|
+
Existing polar axis to plot on.
|
|
626
|
+
show : bool
|
|
627
|
+
Whether to call plt.show().
|
|
628
|
+
**kwargs : dict
|
|
629
|
+
Additional parameters for patch generation.
|
|
630
|
+
|
|
631
|
+
Returns
|
|
632
|
+
-------
|
|
633
|
+
ax : matplotlib axis
|
|
634
|
+
The plot axis.
|
|
635
|
+
"""
|
|
636
|
+
import matplotlib.pyplot as plt
|
|
637
|
+
|
|
638
|
+
# Generate patches
|
|
639
|
+
if method.lower() == "tregenza":
|
|
640
|
+
patches, _, _ = generate_tregenza_patches()
|
|
641
|
+
elif method.lower() == "reinhart":
|
|
642
|
+
patches, _, _ = generate_reinhart_patches(**kwargs)
|
|
643
|
+
elif method.lower() == "uniform":
|
|
644
|
+
patches, _, _ = generate_uniform_grid_patches(**kwargs)
|
|
645
|
+
elif method.lower() == "fibonacci":
|
|
646
|
+
patches, _, _ = generate_fibonacci_patches(**kwargs)
|
|
647
|
+
else:
|
|
648
|
+
raise ValueError(f"Unknown method: {method}")
|
|
649
|
+
|
|
650
|
+
if ax is None:
|
|
651
|
+
fig, ax = plt.subplots(subplot_kw={'projection': 'polar'}, figsize=(8, 8))
|
|
652
|
+
|
|
653
|
+
# Convert to polar coordinates (theta=azimuth, r=90-elevation)
|
|
654
|
+
theta = np.deg2rad(patches[:, 0])
|
|
655
|
+
r = 90.0 - patches[:, 1] # Zenith at center
|
|
656
|
+
|
|
657
|
+
ax.scatter(theta, r, s=10, alpha=0.7)
|
|
658
|
+
ax.set_theta_zero_location('N')
|
|
659
|
+
ax.set_theta_direction(-1)
|
|
660
|
+
ax.set_rlim(0, 90)
|
|
661
|
+
ax.set_rticks([0, 30, 60, 90])
|
|
662
|
+
ax.set_yticklabels(['90°', '60°', '30°', '0°'])
|
|
663
|
+
ax.set_title(f"{method.capitalize()} Sky Patches (n={len(patches)})")
|
|
664
|
+
|
|
665
|
+
if show:
|
|
666
|
+
plt.show()
|
|
667
|
+
|
|
668
|
+
return ax
|