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.
Files changed (41) hide show
  1. voxcity/downloader/ocean.py +559 -0
  2. voxcity/generator/api.py +6 -0
  3. voxcity/generator/grids.py +45 -32
  4. voxcity/generator/pipeline.py +327 -27
  5. voxcity/geoprocessor/draw.py +14 -8
  6. voxcity/geoprocessor/raster/__init__.py +2 -0
  7. voxcity/geoprocessor/raster/core.py +31 -0
  8. voxcity/geoprocessor/raster/landcover.py +173 -49
  9. voxcity/geoprocessor/raster/raster.py +1 -1
  10. voxcity/models.py +2 -0
  11. voxcity/simulator/solar/__init__.py +13 -0
  12. voxcity/simulator_gpu/__init__.py +90 -0
  13. voxcity/simulator_gpu/core.py +322 -0
  14. voxcity/simulator_gpu/domain.py +36 -0
  15. voxcity/simulator_gpu/init_taichi.py +154 -0
  16. voxcity/simulator_gpu/raytracing.py +776 -0
  17. voxcity/simulator_gpu/solar/__init__.py +222 -0
  18. voxcity/simulator_gpu/solar/core.py +66 -0
  19. voxcity/simulator_gpu/solar/csf.py +1249 -0
  20. voxcity/simulator_gpu/solar/domain.py +618 -0
  21. voxcity/simulator_gpu/solar/epw.py +421 -0
  22. voxcity/simulator_gpu/solar/integration.py +4322 -0
  23. voxcity/simulator_gpu/solar/mask.py +459 -0
  24. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  25. voxcity/simulator_gpu/solar/raytracing.py +182 -0
  26. voxcity/simulator_gpu/solar/reflection.py +533 -0
  27. voxcity/simulator_gpu/solar/sky.py +907 -0
  28. voxcity/simulator_gpu/solar/solar.py +337 -0
  29. voxcity/simulator_gpu/solar/svf.py +446 -0
  30. voxcity/simulator_gpu/solar/volumetric.py +2099 -0
  31. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  32. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  33. voxcity/simulator_gpu/visibility/integration.py +808 -0
  34. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  35. voxcity/simulator_gpu/visibility/view.py +944 -0
  36. voxcity/visualizer/renderer.py +2 -1
  37. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/METADATA +16 -53
  38. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/RECORD +41 -16
  39. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/WHEEL +0 -0
  40. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/AUTHORS.rst +0 -0
  41. {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