voxcity 0.6.26__py3-none-any.whl → 1.0.2__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 (81) hide show
  1. voxcity/__init__.py +10 -4
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/gba.py +210 -0
  4. voxcity/downloader/gee.py +5 -1
  5. voxcity/downloader/mbfp.py +1 -1
  6. voxcity/downloader/oemj.py +80 -8
  7. voxcity/downloader/utils.py +73 -73
  8. voxcity/errors.py +30 -0
  9. voxcity/exporter/__init__.py +9 -1
  10. voxcity/exporter/cityles.py +129 -34
  11. voxcity/exporter/envimet.py +51 -26
  12. voxcity/exporter/magicavoxel.py +42 -5
  13. voxcity/exporter/netcdf.py +27 -0
  14. voxcity/exporter/obj.py +103 -28
  15. voxcity/generator/__init__.py +47 -0
  16. voxcity/generator/api.py +721 -0
  17. voxcity/generator/grids.py +381 -0
  18. voxcity/generator/io.py +94 -0
  19. voxcity/generator/pipeline.py +282 -0
  20. voxcity/generator/update.py +429 -0
  21. voxcity/generator/voxelizer.py +392 -0
  22. voxcity/geoprocessor/__init__.py +75 -6
  23. voxcity/geoprocessor/conversion.py +153 -0
  24. voxcity/geoprocessor/draw.py +1488 -1169
  25. voxcity/geoprocessor/heights.py +199 -0
  26. voxcity/geoprocessor/io.py +101 -0
  27. voxcity/geoprocessor/merge_utils.py +91 -0
  28. voxcity/geoprocessor/mesh.py +26 -10
  29. voxcity/geoprocessor/network.py +35 -6
  30. voxcity/geoprocessor/overlap.py +84 -0
  31. voxcity/geoprocessor/raster/__init__.py +82 -0
  32. voxcity/geoprocessor/raster/buildings.py +435 -0
  33. voxcity/geoprocessor/raster/canopy.py +258 -0
  34. voxcity/geoprocessor/raster/core.py +150 -0
  35. voxcity/geoprocessor/raster/export.py +93 -0
  36. voxcity/geoprocessor/raster/landcover.py +159 -0
  37. voxcity/geoprocessor/raster/raster.py +110 -0
  38. voxcity/geoprocessor/selection.py +85 -0
  39. voxcity/geoprocessor/utils.py +824 -820
  40. voxcity/models.py +113 -0
  41. voxcity/simulator/common/__init__.py +22 -0
  42. voxcity/simulator/common/geometry.py +98 -0
  43. voxcity/simulator/common/raytracing.py +450 -0
  44. voxcity/simulator/solar/__init__.py +66 -0
  45. voxcity/simulator/solar/integration.py +336 -0
  46. voxcity/simulator/solar/kernels.py +62 -0
  47. voxcity/simulator/solar/radiation.py +648 -0
  48. voxcity/simulator/solar/sky.py +668 -0
  49. voxcity/simulator/solar/temporal.py +792 -0
  50. voxcity/simulator/view.py +36 -2286
  51. voxcity/simulator/visibility/__init__.py +29 -0
  52. voxcity/simulator/visibility/landmark.py +392 -0
  53. voxcity/simulator/visibility/view.py +508 -0
  54. voxcity/utils/__init__.py +11 -0
  55. voxcity/utils/classes.py +194 -0
  56. voxcity/utils/lc.py +80 -39
  57. voxcity/utils/logging.py +61 -0
  58. voxcity/utils/orientation.py +51 -0
  59. voxcity/utils/shape.py +230 -0
  60. voxcity/utils/weather/__init__.py +26 -0
  61. voxcity/utils/weather/epw.py +146 -0
  62. voxcity/utils/weather/files.py +36 -0
  63. voxcity/utils/weather/onebuilding.py +486 -0
  64. voxcity/visualizer/__init__.py +24 -0
  65. voxcity/visualizer/builder.py +43 -0
  66. voxcity/visualizer/grids.py +141 -0
  67. voxcity/visualizer/maps.py +187 -0
  68. voxcity/visualizer/palette.py +228 -0
  69. voxcity/visualizer/renderer.py +1145 -0
  70. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/METADATA +162 -48
  71. voxcity-1.0.2.dist-info/RECORD +81 -0
  72. voxcity/generator.py +0 -1302
  73. voxcity/geoprocessor/grid.py +0 -1739
  74. voxcity/geoprocessor/polygon.py +0 -1344
  75. voxcity/simulator/solar.py +0 -2339
  76. voxcity/utils/visualization.py +0 -2849
  77. voxcity/utils/weather.py +0 -1038
  78. voxcity-0.6.26.dist-info/RECORD +0 -38
  79. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
  80. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.6.26.dist-info → voxcity-1.0.2.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