voxcity 1.0.13__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.
@@ -33,6 +33,289 @@ VOXCITY_TREE_CODE = -2
33
33
  VOXCITY_BUILDING_CODE = -3
34
34
 
35
35
 
36
+ # =============================================================================
37
+ # Common Helper Functions (reduces code duplication)
38
+ # =============================================================================
39
+
40
+ def _get_location_from_voxcity(voxcity, default_lat: float = 1.35, default_lon: float = 103.82) -> Tuple[float, float]:
41
+ """
42
+ Extract latitude/longitude from VoxCity object or return defaults.
43
+
44
+ Args:
45
+ voxcity: VoxCity object with extras containing rectangle_vertices
46
+ default_lat: Default latitude if not found (Singapore)
47
+ default_lon: Default longitude if not found (Singapore)
48
+
49
+ Returns:
50
+ Tuple of (origin_lat, origin_lon)
51
+ """
52
+ extras = getattr(voxcity, 'extras', None)
53
+ if isinstance(extras, dict):
54
+ rectangle_vertices = extras.get('rectangle_vertices', None)
55
+ else:
56
+ rectangle_vertices = None
57
+
58
+ if rectangle_vertices is not None and len(rectangle_vertices) > 0:
59
+ lons = [v[0] for v in rectangle_vertices]
60
+ lats = [v[1] for v in rectangle_vertices]
61
+ return np.mean(lats), np.mean(lons)
62
+
63
+ return default_lat, default_lon
64
+
65
+
66
+ def _convert_voxel_data_to_arrays(
67
+ voxel_data: np.ndarray,
68
+ default_lad: float = 1.0
69
+ ) -> Tuple[np.ndarray, np.ndarray]:
70
+ """
71
+ Convert VoxCity voxel codes to is_solid and LAD arrays using vectorized operations.
72
+
73
+ This is 10-100x faster than triple-nested Python loops for large grids.
74
+
75
+ Args:
76
+ voxel_data: 3D array of VoxCity voxel class codes
77
+ default_lad: Default Leaf Area Density for tree voxels (m²/m³)
78
+
79
+ Returns:
80
+ Tuple of (is_solid, lad) numpy arrays with same shape as voxel_data
81
+ """
82
+ # Vectorized solid detection: buildings (-3), ground (-1), or positive land cover codes
83
+ is_solid = (
84
+ (voxel_data == VOXCITY_BUILDING_CODE) |
85
+ (voxel_data == VOXCITY_GROUND_CODE) |
86
+ (voxel_data > 0)
87
+ ).astype(np.int32)
88
+
89
+ # Vectorized LAD assignment: only tree voxels (-2) have LAD
90
+ lad = np.where(voxel_data == VOXCITY_TREE_CODE, default_lad, 0.0).astype(np.float32)
91
+
92
+ return is_solid, lad
93
+
94
+
95
+ def _compute_valid_ground_vectorized(voxel_data: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
96
+ """
97
+ Compute valid ground mask and ground k-levels using vectorized operations.
98
+
99
+ Valid ground cells are those where:
100
+ - The transition from solid to air/tree occurs
101
+ - The solid below is not water (7,8,9) or building/underground (negative codes)
102
+
103
+ Args:
104
+ voxel_data: 3D array of VoxCity voxel class codes (ni, nj, nk)
105
+
106
+ Returns:
107
+ Tuple of (valid_ground 2D bool array, ground_k 2D int array)
108
+ ground_k[i,j] = -1 means no valid ground found
109
+ """
110
+ ni, nj, nk = voxel_data.shape
111
+
112
+ # Water/special class codes to exclude
113
+ WATER_CLASSES = {7, 8, 9}
114
+ AIR_OR_TREE = {0, VOXCITY_TREE_CODE}
115
+
116
+ valid_ground = np.zeros((ni, nj), dtype=bool)
117
+ ground_k = np.full((ni, nj), -1, dtype=np.int32)
118
+
119
+ # Vectorize over k: find first transition from solid to air/tree
120
+ # For each (i,j), scan upward to find the first air/tree cell above a solid cell
121
+ for k in range(1, nk):
122
+ # Current cell is air (0) or tree (-2)
123
+ curr_is_air_or_tree = (voxel_data[:, :, k] == 0) | (voxel_data[:, :, k] == VOXCITY_TREE_CODE)
124
+
125
+ # Cell below is NOT air or tree (i.e., it's solid)
126
+ below_val = voxel_data[:, :, k - 1]
127
+ below_is_solid = (below_val != 0) & (below_val != VOXCITY_TREE_CODE)
128
+
129
+ # This is a transition point
130
+ is_transition = curr_is_air_or_tree & below_is_solid
131
+
132
+ # Only process cells that haven't been assigned yet
133
+ unassigned = (ground_k == -1)
134
+ new_transitions = is_transition & unassigned
135
+
136
+ if not np.any(new_transitions):
137
+ continue
138
+
139
+ # Check validity: below is not water (7,8,9) and not negative (building/underground)
140
+ below_is_water = (below_val == 7) | (below_val == 8) | (below_val == 9)
141
+ below_is_negative = (below_val < 0)
142
+ below_is_invalid = below_is_water | below_is_negative
143
+
144
+ # Valid ground: transition point where below is valid
145
+ valid_new = new_transitions & ~below_is_invalid
146
+ invalid_new = new_transitions & below_is_invalid
147
+
148
+ # Assign ground_k for valid transitions
149
+ ground_k[valid_new] = k
150
+ valid_ground[valid_new] = True
151
+
152
+ # Mark invalid transitions so we don't process them again
153
+ # (set ground_k to -2 temporarily to distinguish from unassigned)
154
+ ground_k[invalid_new] = -2
155
+
156
+ # Reset -2 markers back to -1 (no valid ground)
157
+ ground_k[ground_k == -2] = -1
158
+
159
+ return valid_ground, ground_k
160
+
161
+
162
+ def _filter_df_to_period(df, start_time: str, end_time: str, tz: float):
163
+ """
164
+ Filter weather DataFrame to specified time period and convert to UTC.
165
+
166
+ Args:
167
+ df: pandas DataFrame with datetime index
168
+ start_time: Start time in format 'MM-DD HH:MM:SS'
169
+ end_time: End time in format 'MM-DD HH:MM:SS'
170
+ tz: Timezone offset in hours
171
+
172
+ Returns:
173
+ Tuple of (df_period_utc, df with hour_of_year column)
174
+
175
+ Raises:
176
+ ValueError: If time format is invalid or no data in period
177
+ """
178
+ from datetime import datetime
179
+ import pytz
180
+
181
+ try:
182
+ start_dt = datetime.strptime(start_time, '%m-%d %H:%M:%S')
183
+ end_dt = datetime.strptime(end_time, '%m-%d %H:%M:%S')
184
+ except ValueError as ve:
185
+ raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
186
+
187
+ # Add hour_of_year column
188
+ df = df.copy()
189
+ df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
190
+
191
+ # Calculate start/end hours
192
+ start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
193
+ end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
194
+ start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
195
+ end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
196
+
197
+ # Filter to period
198
+ if start_hour <= end_hour:
199
+ df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
200
+ else:
201
+ df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
202
+
203
+ if df_period.empty:
204
+ raise ValueError("No weather data in the specified period.")
205
+
206
+ # Localize and convert to UTC
207
+ offset_minutes = int(tz * 60)
208
+ local_tz = pytz.FixedOffset(offset_minutes)
209
+ df_period_local = df_period.copy()
210
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
211
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
212
+
213
+ return df_period_utc
214
+
215
+
216
+ def _load_epw_data(
217
+ epw_file_path: Optional[str] = None,
218
+ download_nearest_epw: bool = False,
219
+ voxcity = None,
220
+ **kwargs
221
+ ) -> Tuple:
222
+ """
223
+ Load EPW weather data, optionally downloading the nearest file.
224
+
225
+ Args:
226
+ epw_file_path: Path to EPW file (required if download_nearest_epw=False)
227
+ download_nearest_epw: If True, download nearest EPW based on location
228
+ voxcity: VoxCity object (needed for location when downloading)
229
+ **kwargs: Additional parameters (output_dir, max_distance, rectangle_vertices)
230
+
231
+ Returns:
232
+ Tuple of (df, lon, lat, tz) where df is the weather DataFrame
233
+
234
+ Raises:
235
+ ValueError: If EPW file not provided and download_nearest_epw=False
236
+ ImportError: If required modules not available
237
+ """
238
+ rectangle_vertices = kwargs.get('rectangle_vertices', None)
239
+ if rectangle_vertices is None and voxcity is not None:
240
+ extras = getattr(voxcity, 'extras', None)
241
+ if isinstance(extras, dict):
242
+ rectangle_vertices = extras.get('rectangle_vertices', None)
243
+
244
+ if download_nearest_epw:
245
+ if rectangle_vertices is None:
246
+ raise ValueError("rectangle_vertices required to download nearest EPW file")
247
+
248
+ try:
249
+ from voxcity.utils.weather import get_nearest_epw_from_climate_onebuilding
250
+ lons = [coord[0] for coord in rectangle_vertices]
251
+ lats = [coord[1] for coord in rectangle_vertices]
252
+ center_lon = (min(lons) + max(lons)) / 2
253
+ center_lat = (min(lats) + max(lats)) / 2
254
+ output_dir = kwargs.get('output_dir', 'output')
255
+ max_distance = kwargs.get('max_distance', 100)
256
+
257
+ epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
258
+ longitude=center_lon,
259
+ latitude=center_lat,
260
+ output_dir=output_dir,
261
+ max_distance=max_distance,
262
+ extract_zip=True,
263
+ load_data=True
264
+ )
265
+ except ImportError:
266
+ raise ImportError("VoxCity weather utilities required for EPW download")
267
+
268
+ if not epw_file_path:
269
+ raise ValueError("epw_file_path must be provided when download_nearest_epw is False")
270
+
271
+ # Read EPW file
272
+ try:
273
+ from voxcity.utils.weather import read_epw_for_solar_simulation
274
+ df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
275
+ except ImportError:
276
+ # Fallback to our EPW reader
277
+ from .epw import read_epw_header, read_epw_solar_data
278
+ location = read_epw_header(epw_file_path)
279
+ df = read_epw_solar_data(epw_file_path)
280
+ lon, lat, tz = location.longitude, location.latitude, location.timezone
281
+
282
+ if df.empty:
283
+ raise ValueError("No data in EPW file.")
284
+
285
+ return df, lon, lat, tz
286
+
287
+
288
+ def _compute_sun_direction(azimuth_degrees_ori: float, elevation_degrees: float) -> Tuple[float, float, float, float]:
289
+ """
290
+ Compute sun direction vector from azimuth and elevation angles.
291
+
292
+ Args:
293
+ azimuth_degrees_ori: Solar azimuth in VoxCity convention (0=North, clockwise)
294
+ elevation_degrees: Solar elevation in degrees above horizon
295
+
296
+ Returns:
297
+ Tuple of (sun_dir_x, sun_dir_y, sun_dir_z, cos_zenith)
298
+ """
299
+ # Convert from VoxCity convention to model coordinates
300
+ azimuth_degrees = 180 - azimuth_degrees_ori
301
+ azimuth_radians = np.deg2rad(azimuth_degrees)
302
+ elevation_radians = np.deg2rad(elevation_degrees)
303
+
304
+ cos_elev = np.cos(elevation_radians)
305
+ sin_elev = np.sin(elevation_radians)
306
+
307
+ sun_dir_x = cos_elev * np.cos(azimuth_radians)
308
+ sun_dir_y = cos_elev * np.sin(azimuth_radians)
309
+ sun_dir_z = sin_elev
310
+ cos_zenith = sin_elev # cos(zenith) = sin(elevation)
311
+
312
+ return sun_dir_x, sun_dir_y, sun_dir_z, cos_zenith
313
+
314
+
315
+ # =============================================================================
316
+ # Data Classes
317
+ # =============================================================================
318
+
36
319
  @dataclass
37
320
  class LandCoverAlbedo:
38
321
  """
@@ -180,16 +463,8 @@ def _get_or_create_radiation_model(
180
463
  if progress_report:
181
464
  print("Creating new RadiationModel (computing SVF/CSF matrices)...")
182
465
 
183
- # Get location
184
- rectangle_vertices = getattr(voxcity, 'extras', {}).get('rectangle_vertices', None)
185
- if rectangle_vertices is not None:
186
- lons = [v[0] for v in rectangle_vertices]
187
- lats = [v[1] for v in rectangle_vertices]
188
- origin_lat = np.mean(lats)
189
- origin_lon = np.mean(lons)
190
- else:
191
- origin_lat = 1.35
192
- origin_lon = 103.82
466
+ # Get location using helper function
467
+ origin_lat, origin_lon = _get_location_from_voxcity(voxcity)
193
468
 
194
469
  # Create domain
195
470
  domain = Domain(
@@ -199,37 +474,12 @@ def _get_or_create_radiation_model(
199
474
  origin_lon=origin_lon
200
475
  )
201
476
 
202
- # Convert VoxCity voxel data to domain arrays
203
- is_solid_np = np.zeros((ni, nj, nk), dtype=np.int32)
204
- lad_np = np.zeros((ni, nj, nk), dtype=np.float32)
477
+ # Convert VoxCity voxel data to domain arrays using vectorized helper
205
478
  default_lad = kwargs.get('default_lad', 1.0)
479
+ is_solid_np, lad_np = _convert_voxel_data_to_arrays(voxel_data, default_lad)
206
480
 
207
- valid_ground = np.zeros((ni, nj), dtype=bool)
208
-
209
- for i in range(ni):
210
- for j in range(nj):
211
- for k in range(nk):
212
- voxel_val = voxel_data[i, j, k]
213
-
214
- if voxel_val == VOXCITY_BUILDING_CODE:
215
- is_solid_np[i, j, k] = 1
216
- elif voxel_val == VOXCITY_GROUND_CODE:
217
- is_solid_np[i, j, k] = 1
218
- elif voxel_val == VOXCITY_TREE_CODE:
219
- lad_np[i, j, k] = default_lad
220
- elif voxel_val > 0:
221
- is_solid_np[i, j, k] = 1
222
-
223
- # Determine valid ground cells
224
- for k in range(1, nk):
225
- curr_val = voxel_data[i, j, k]
226
- below_val = voxel_data[i, j, k - 1]
227
- if curr_val in (0, VOXCITY_TREE_CODE) and below_val not in (0, VOXCITY_TREE_CODE):
228
- if below_val in (7, 8, 9) or below_val < 0:
229
- valid_ground[i, j] = False
230
- else:
231
- valid_ground[i, j] = True
232
- break
481
+ # Compute valid ground cells using vectorized helper
482
+ valid_ground, _ = _compute_valid_ground_vectorized(voxel_data)
233
483
 
234
484
  # Set domain arrays
235
485
  _set_solid_array(domain, is_solid_np)
@@ -316,6 +566,127 @@ def clear_radiation_model_cache():
316
566
  _radiation_model_cache = None
317
567
 
318
568
 
569
+ def _compute_ground_k_from_voxels(voxel_data: np.ndarray) -> np.ndarray:
570
+ """
571
+ Compute ground surface k-level for each (i,j) cell from voxel data.
572
+
573
+ This finds the terrain top - the highest k where the cell below the first air
574
+ cell is solid ground (not building). This is used for terrain-following
575
+ height extraction in volumetric calculations.
576
+
577
+ Water areas (voxel classes 7, 8, 9) and building/underground cells (negative codes)
578
+ are excluded and marked as -1. This uses the same logic as the with_reflections=True
579
+ path in _get_or_create_radiation_model for consistency.
580
+
581
+ Args:
582
+ voxel_data: 3D array of voxel class codes
583
+
584
+ Returns:
585
+ 2D array of ground k-levels (ni, nj). -1 means no valid ground found.
586
+ """
587
+ # Use the vectorized helper for consistency
588
+ _, ground_k = _compute_valid_ground_vectorized(voxel_data)
589
+ return ground_k
590
+
591
+
592
+ def _extract_terrain_following_slice(
593
+ flux_3d: np.ndarray,
594
+ ground_k: np.ndarray,
595
+ height_offset_k: int,
596
+ is_solid: np.ndarray
597
+ ) -> np.ndarray:
598
+ """
599
+ Extract a terrain-following 2D slice from a 3D flux field (vectorized).
600
+
601
+ For each (i,j), extracts the value at ground_k[i,j] + height_offset_k.
602
+ Cells that are solid at the extraction point, have no valid ground,
603
+ or are above the domain are marked as NaN.
604
+
605
+ Args:
606
+ flux_3d: 3D array of flux values (ni, nj, nk)
607
+ ground_k: 2D array of ground k-levels (ni, nj), -1 means no valid ground
608
+ height_offset_k: Number of cells above ground to extract
609
+ is_solid: 3D array marking solid cells (ni, nj, nk)
610
+
611
+ Returns:
612
+ 2D array of extracted values (ni, nj) with NaN for invalid cells
613
+ """
614
+ ni, nj, nk = flux_3d.shape
615
+ result = np.full((ni, nj), np.nan, dtype=np.float64)
616
+
617
+ # Calculate extraction k-levels
618
+ k_extract = ground_k + height_offset_k
619
+
620
+ # Create valid mask: ground exists, within bounds, not solid
621
+ valid_ground = ground_k >= 0
622
+ within_bounds = k_extract < nk
623
+ valid_mask = valid_ground & within_bounds
624
+
625
+ # Get indices for valid cells
626
+ ii, jj = np.where(valid_mask)
627
+ kk = k_extract[valid_mask]
628
+
629
+ # Check solid cells at extraction points
630
+ not_solid = is_solid[ii, jj, kk] != 1
631
+
632
+ # Extract values for non-solid cells
633
+ ii_valid = ii[not_solid]
634
+ jj_valid = jj[not_solid]
635
+ kk_valid = kk[not_solid]
636
+
637
+ result[ii_valid, jj_valid] = flux_3d[ii_valid, jj_valid, kk_valid]
638
+
639
+ return result
640
+
641
+
642
+ def _accumulate_terrain_following_slice(
643
+ cumulative_map: np.ndarray,
644
+ flux_3d: np.ndarray,
645
+ ground_k: np.ndarray,
646
+ height_offset_k: int,
647
+ is_solid: np.ndarray,
648
+ weight: float = 1.0
649
+ ) -> None:
650
+ """
651
+ Accumulate terrain-following values from a 3D flux field into a 2D map (vectorized, in-place).
652
+
653
+ For each (i,j), adds flux_3d[i,j,k_extract] * weight to cumulative_map[i,j]
654
+ where k_extract = ground_k[i,j] + height_offset_k.
655
+
656
+ Args:
657
+ cumulative_map: 2D array to accumulate into (ni, nj), modified in-place
658
+ flux_3d: 3D array of flux values (ni, nj, nk)
659
+ ground_k: 2D array of ground k-levels (ni, nj), -1 means no valid ground
660
+ height_offset_k: Number of cells above ground to extract
661
+ is_solid: 3D array marking solid cells (ni, nj, nk)
662
+ weight: Multiplier for values before accumulating (e.g., time_step_hours)
663
+ """
664
+ ni, nj, nk = flux_3d.shape
665
+
666
+ # Calculate extraction k-levels
667
+ k_extract = ground_k + height_offset_k
668
+
669
+ # Create valid mask: ground exists, within bounds
670
+ valid_ground = ground_k >= 0
671
+ within_bounds = k_extract < nk
672
+ valid_mask = valid_ground & within_bounds
673
+
674
+ # Get indices for valid cells
675
+ ii, jj = np.where(valid_mask)
676
+ kk = k_extract[valid_mask]
677
+
678
+ # Check solid cells at extraction points
679
+ not_solid = is_solid[ii, jj, kk] != 1
680
+
681
+ # Accumulate for non-solid cells
682
+ ii_valid = ii[not_solid]
683
+ jj_valid = jj[not_solid]
684
+ kk_valid = kk[not_solid]
685
+
686
+ # Use np.add.at for proper in-place accumulation (handles duplicate indices)
687
+ np.add.at(cumulative_map, (ii_valid, jj_valid), flux_3d[ii_valid, jj_valid, kk_valid] * weight)
688
+
689
+
319
690
  def clear_gpu_ray_tracer_cache():
320
691
  """Clear the cached GPU ray tracer fields to free memory or force recomputation."""
321
692
  global _gpu_ray_tracer_cache
@@ -411,16 +782,8 @@ def _get_or_create_building_radiation_model(
411
782
  if progress_report:
412
783
  print("Creating new Building RadiationModel (computing SVF/CSF matrices)...")
413
784
 
414
- # Get location
415
- rectangle_vertices = getattr(voxcity, 'extras', {}).get('rectangle_vertices', None)
416
- if rectangle_vertices is not None:
417
- lons = [v[0] for v in rectangle_vertices]
418
- lats = [v[1] for v in rectangle_vertices]
419
- origin_lat = np.mean(lats)
420
- origin_lon = np.mean(lons)
421
- else:
422
- origin_lat = 1.35
423
- origin_lon = 103.82
785
+ # Get location using helper function
786
+ origin_lat, origin_lon = _get_location_from_voxcity(voxcity)
424
787
 
425
788
  # Create domain - consistent with ground-level model
426
789
  # VoxCity uses [row, col, z] = [i, j, k] convention
@@ -435,25 +798,9 @@ def _get_or_create_building_radiation_model(
435
798
  origin_lon=origin_lon
436
799
  )
437
800
 
438
- # Convert VoxCity voxel data to domain arrays
439
- # Use the same convention as ground-level model: direct indexing without swap
440
- is_solid_np = np.zeros((ni, nj, nk), dtype=np.int32)
441
- lad_np = np.zeros((ni, nj, nk), dtype=np.float32)
801
+ # Convert VoxCity voxel data to domain arrays using vectorized helper
442
802
  default_lad = kwargs.get('default_lad', 2.0)
443
-
444
- for i in range(ni):
445
- for j in range(nj):
446
- for z in range(nk):
447
- voxel_val = voxel_data[i, j, z]
448
-
449
- if voxel_val == VOXCITY_BUILDING_CODE:
450
- is_solid_np[i, j, z] = 1
451
- elif voxel_val == VOXCITY_GROUND_CODE:
452
- is_solid_np[i, j, z] = 1
453
- elif voxel_val == VOXCITY_TREE_CODE:
454
- lad_np[i, j, z] = default_lad
455
- elif voxel_val > 0:
456
- is_solid_np[i, j, z] = 1
803
+ is_solid_np, lad_np = _convert_voxel_data_to_arrays(voxel_data, default_lad)
457
804
 
458
805
  # Set domain arrays
459
806
  _set_solid_array(domain, is_solid_np)
@@ -1199,6 +1546,7 @@ def get_global_solar_irradiance_map(
1199
1546
  reflections. If False (default), use simple ray-tracing/SVF for
1200
1547
  faster but less accurate results.
1201
1548
  **kwargs: Additional parameters (see get_direct_solar_irradiance_map)
1549
+ - computation_mask (np.ndarray): Optional 2D boolean mask for sub-area computation
1202
1550
  - n_reflection_steps (int): Number of reflection bounces when
1203
1551
  with_reflections=True (default: 2)
1204
1552
  - progress_report (bool): Print progress (default: False)
@@ -1206,6 +1554,9 @@ def get_global_solar_irradiance_map(
1206
1554
  Returns:
1207
1555
  2D numpy array of global horizontal irradiance (W/m²)
1208
1556
  """
1557
+ # Extract computation_mask from kwargs
1558
+ computation_mask = kwargs.pop('computation_mask', None)
1559
+
1209
1560
  if with_reflections:
1210
1561
  # Use full RadiationModel with reflections (single call for all components)
1211
1562
  direct_map, diffuse_map, reflected_map = _compute_ground_irradiance_with_reflections(
@@ -1268,6 +1619,18 @@ def get_global_solar_irradiance_map(
1268
1619
  **kwargs
1269
1620
  )
1270
1621
 
1622
+ # Apply computation mask if provided
1623
+ if computation_mask is not None:
1624
+ # Ensure mask shape matches output shape (note: output is flipped)
1625
+ if computation_mask.shape == global_map.shape:
1626
+ global_map = np.where(np.flipud(computation_mask), global_map, np.nan)
1627
+ elif computation_mask.T.shape == global_map.shape:
1628
+ global_map = np.where(np.flipud(computation_mask.T), global_map, np.nan)
1629
+ else:
1630
+ # Try to match without flip
1631
+ if computation_mask.shape == global_map.shape:
1632
+ global_map = np.where(computation_mask, global_map, np.nan)
1633
+
1271
1634
  return global_map
1272
1635
 
1273
1636
 
@@ -1308,6 +1671,7 @@ def get_cumulative_global_solar_irradiance(
1308
1671
  reflections for each timestep/patch. If False (default), use simple
1309
1672
  ray-tracing/SVF for faster computation.
1310
1673
  **kwargs: Additional parameters including:
1674
+ - computation_mask (np.ndarray): Optional 2D boolean mask for sub-area computation
1311
1675
  - start_time (str): Start time 'MM-DD HH:MM:SS' (default: '01-01 05:00:00')
1312
1676
  - end_time (str): End time 'MM-DD HH:MM:SS' (default: '01-01 20:00:00')
1313
1677
  - view_point_height (float): Observer height (default: 1.5)
@@ -1327,6 +1691,7 @@ def get_cumulative_global_solar_irradiance(
1327
1691
 
1328
1692
  # Extract parameters that we pass explicitly (use pop to avoid duplicate kwargs)
1329
1693
  kwargs = kwargs.copy() # Don't modify the original
1694
+ computation_mask = kwargs.pop('computation_mask', None)
1330
1695
  view_point_height = kwargs.pop('view_point_height', 1.5)
1331
1696
  colormap = kwargs.pop('colormap', 'magma')
1332
1697
  start_time = kwargs.pop('start_time', '01-01 05:00:00')
@@ -1587,6 +1952,14 @@ def get_cumulative_global_solar_irradiance(
1587
1952
  # Apply mask for plotting
1588
1953
  cumulative_map = np.where(mask_map, cumulative_map, np.nan)
1589
1954
 
1955
+ # Apply computation mask if provided
1956
+ if computation_mask is not None:
1957
+ # Handle different shape orientations
1958
+ if computation_mask.shape == cumulative_map.shape:
1959
+ cumulative_map = np.where(np.flipud(computation_mask), cumulative_map, np.nan)
1960
+ elif computation_mask.T.shape == cumulative_map.shape:
1961
+ cumulative_map = np.where(np.flipud(computation_mask.T), cumulative_map, np.nan)
1962
+
1590
1963
  if show_plot:
1591
1964
  vmax = kwargs.get('vmax', float(np.nanmax(cumulative_map)) if not np.all(np.isnan(cumulative_map)) else 1.0)
1592
1965
  vmin = kwargs.get('vmin', 0.0)
@@ -1637,6 +2010,9 @@ def get_building_solar_irradiance(
1637
2010
  - n_reflection_steps (int): Number of reflection bounces when with_reflections=True (default: 2)
1638
2011
  - tree_k (float): Tree extinction coefficient (default: 0.6)
1639
2012
  - building_class_id (int): Building voxel class code (default: -3)
2013
+ - computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
2014
+ Faces whose XY centroid falls outside the masked region are set to NaN.
2015
+ Useful for focusing analysis on a sub-region of the domain.
1640
2016
  - progress_report (bool): Print progress (default: False)
1641
2017
  - colormap (str): Colormap name (default: 'magma')
1642
2018
  - obj_export (bool): Export mesh to OBJ (default: False)
@@ -1666,6 +2042,7 @@ def get_building_solar_irradiance(
1666
2042
  n_reflection_steps = kwargs.pop('n_reflection_steps', 2)
1667
2043
  colormap = kwargs.pop('colormap', 'magma')
1668
2044
  with_reflections = kwargs.pop('with_reflections', False) # Default False for speed; set True for multi-bounce reflections
2045
+ computation_mask = kwargs.pop('computation_mask', None) # 2D boolean mask for sub-area filtering
1669
2046
 
1670
2047
  # If with_reflections=False, set n_reflection_steps=0 to skip expensive SVF matrix computation
1671
2048
  if not with_reflections:
@@ -1889,6 +2266,53 @@ def get_building_solar_irradiance(
1889
2266
  n_boundary = np.sum(is_boundary_vertical)
1890
2267
  print(f" Boundary vertical faces set to NaN: {n_boundary}/{n_mesh_faces} ({100*n_boundary/n_mesh_faces:.1f}%)")
1891
2268
 
2269
+ # -------------------------------------------------------------------------
2270
+ # Apply computation_mask: set faces outside masked XY region to NaN
2271
+ # -------------------------------------------------------------------------
2272
+ if computation_mask is not None:
2273
+ # Get mesh face centers (use cached if available)
2274
+ if cache is not None and cache.mesh_face_centers is not None:
2275
+ mesh_face_centers = cache.mesh_face_centers
2276
+ else:
2277
+ mesh_face_centers = building_mesh.triangles_center
2278
+
2279
+ # Convert face XY positions to grid indices
2280
+ face_x = mesh_face_centers[:, 0]
2281
+ face_y = mesh_face_centers[:, 1]
2282
+
2283
+ # Map to grid indices (face coords are in real-world units: 0 to nx*meshsize)
2284
+ grid_i = (face_y / meshsize).astype(int) # y -> i (row)
2285
+ grid_j = (face_x / meshsize).astype(int) # x -> j (col)
2286
+
2287
+ # Handle mask shape orientation
2288
+ if computation_mask.shape == (ny_vc, nx_vc):
2289
+ mask_shape = computation_mask.shape
2290
+ elif computation_mask.T.shape == (ny_vc, nx_vc):
2291
+ computation_mask = computation_mask.T
2292
+ mask_shape = computation_mask.shape
2293
+ else:
2294
+ # Best effort: assume it matches voxel grid
2295
+ mask_shape = computation_mask.shape
2296
+
2297
+ # Clamp indices to valid range
2298
+ grid_i = np.clip(grid_i, 0, mask_shape[0] - 1)
2299
+ grid_j = np.clip(grid_j, 0, mask_shape[1] - 1)
2300
+
2301
+ # Determine which faces are outside the mask
2302
+ # Flip mask to match coordinate system (same as ground-level functions)
2303
+ flipped_mask = np.flipud(computation_mask)
2304
+ outside_mask = ~flipped_mask[grid_i, grid_j]
2305
+
2306
+ # Set values outside mask to NaN
2307
+ sw_in_direct = np.where(outside_mask, np.nan, sw_in_direct)
2308
+ sw_in_diffuse = np.where(outside_mask, np.nan, sw_in_diffuse)
2309
+ sw_in_reflected = np.where(outside_mask, np.nan, sw_in_reflected)
2310
+ total_sw = np.where(outside_mask, np.nan, total_sw)
2311
+
2312
+ if progress_report:
2313
+ n_outside = np.sum(outside_mask)
2314
+ print(f" Faces outside computation_mask set to NaN: {n_outside}/{n_mesh_faces} ({100*n_outside/n_mesh_faces:.1f}%)")
2315
+
1892
2316
  building_mesh.metadata = {
1893
2317
  'irradiance_direct': sw_in_direct,
1894
2318
  'irradiance_diffuse': sw_in_diffuse,
@@ -1951,10 +2375,11 @@ def get_cumulative_building_solar_irradiance(
1951
2375
  - time_step_hours (float): Time step in hours (default: 1.0)
1952
2376
  - use_sky_patches (bool): Use sky patch optimization (default: True)
1953
2377
  - sky_discretization (str): 'tregenza', 'reinhart', etc.
2378
+ - computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
2379
+ Faces whose XY centroid falls outside the masked region are set to NaN.
1954
2380
  - progress_report (bool): Print progress (default: False)
1955
2381
  - with_reflections (bool): Enable multi-bounce surface reflections (default: False).
1956
2382
  Set to True for more accurate results but slower computation.
1957
- - fast_path (bool): Use optimized paths (default: True)
1958
2383
 
1959
2384
  Returns:
1960
2385
  Trimesh object with cumulative irradiance (Wh/m²) in metadata
@@ -1969,6 +2394,7 @@ def get_cumulative_building_solar_irradiance(
1969
2394
  time_step_hours = float(kwargs.pop('time_step_hours', 1.0))
1970
2395
  progress_report = kwargs.pop('progress_report', False)
1971
2396
  use_sky_patches = kwargs.pop('use_sky_patches', False) # Default False for accuracy, True for speed
2397
+ computation_mask = kwargs.pop('computation_mask', None) # 2D boolean mask for sub-area filtering
1972
2398
 
1973
2399
  if weather_df.empty:
1974
2400
  raise ValueError("No data in weather dataframe.")
@@ -2230,8 +2656,48 @@ def get_cumulative_building_solar_irradiance(
2230
2656
  n_boundary = np.sum(is_boundary_vertical)
2231
2657
  print(f" Boundary vertical faces set to NaN: {n_boundary}/{n_faces} ({100*n_boundary/n_faces:.1f}%)")
2232
2658
 
2233
- # Store results in mesh metadata
2234
- result_mesh.metadata = getattr(result_mesh, 'metadata', {})
2659
+ # -------------------------------------------------------------------------
2660
+ # Apply computation_mask: set faces outside masked XY region to NaN
2661
+ # -------------------------------------------------------------------------
2662
+ if computation_mask is not None:
2663
+ # Convert face XY positions to grid indices
2664
+ face_x = mesh_face_centers[:, 0]
2665
+ face_y = mesh_face_centers[:, 1]
2666
+
2667
+ # Map to grid indices (face coords are in real-world units: 0 to nx*meshsize)
2668
+ grid_i = (face_y / meshsize).astype(int) # y -> i (row)
2669
+ grid_j = (face_x / meshsize).astype(int) # x -> j (col)
2670
+
2671
+ # Handle mask shape orientation
2672
+ if computation_mask.shape == (ny_vc, nx_vc):
2673
+ mask_shape = computation_mask.shape
2674
+ elif computation_mask.T.shape == (ny_vc, nx_vc):
2675
+ computation_mask = computation_mask.T
2676
+ mask_shape = computation_mask.shape
2677
+ else:
2678
+ # Best effort: assume it matches voxel grid
2679
+ mask_shape = computation_mask.shape
2680
+
2681
+ # Clamp indices to valid range
2682
+ grid_i = np.clip(grid_i, 0, mask_shape[0] - 1)
2683
+ grid_j = np.clip(grid_j, 0, mask_shape[1] - 1)
2684
+
2685
+ # Determine which faces are outside the mask
2686
+ # Flip mask to match coordinate system (same as ground-level functions)
2687
+ flipped_mask = np.flipud(computation_mask)
2688
+ outside_mask = ~flipped_mask[grid_i, grid_j]
2689
+
2690
+ # Set values outside mask to NaN
2691
+ cumulative_direct[outside_mask] = np.nan
2692
+ cumulative_diffuse[outside_mask] = np.nan
2693
+ cumulative_global[outside_mask] = np.nan
2694
+
2695
+ if progress_report:
2696
+ n_outside = np.sum(outside_mask)
2697
+ print(f" Faces outside computation_mask set to NaN: {n_outside}/{n_faces} ({100*n_outside/n_faces:.1f}%)")
2698
+
2699
+ # Store results in mesh metadata
2700
+ result_mesh.metadata = getattr(result_mesh, 'metadata', {})
2235
2701
  result_mesh.metadata['cumulative_direct'] = cumulative_direct
2236
2702
  result_mesh.metadata['cumulative_diffuse'] = cumulative_diffuse
2237
2703
  result_mesh.metadata['cumulative_global'] = cumulative_global
@@ -2291,6 +2757,9 @@ def get_building_global_solar_irradiance_using_epw(
2291
2757
  - calc_time (str): For instantaneous: 'MM-DD HH:MM:SS'
2292
2758
  - period_start, period_end (str): For cumulative: 'MM-DD HH:MM:SS'
2293
2759
  - rectangle_vertices: Location vertices
2760
+ - computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
2761
+ Faces whose XY centroid falls outside the masked region are set to NaN.
2762
+ Useful for analyzing specific buildings or sub-regions.
2294
2763
  - progress_report (bool): Print progress
2295
2764
  - with_reflections (bool): Enable multi-bounce surface reflections (default: False).
2296
2765
  Set to True for more accurate results but slower computation.
@@ -2307,56 +2776,13 @@ def get_building_global_solar_irradiance_using_epw(
2307
2776
  kwargs = dict(kwargs)
2308
2777
  kwargs.pop('progress_report', None)
2309
2778
 
2310
- # Get EPW file
2311
- epw_file_path = kwargs.get('epw_file_path', None)
2312
- download_nearest_epw = kwargs.get('download_nearest_epw', False)
2313
-
2314
- rectangle_vertices = kwargs.get('rectangle_vertices', None)
2315
- if rectangle_vertices is None:
2316
- extras = getattr(voxcity, 'extras', None)
2317
- if isinstance(extras, dict):
2318
- rectangle_vertices = extras.get('rectangle_vertices', None)
2319
-
2320
- if download_nearest_epw:
2321
- if rectangle_vertices is None:
2322
- raise ValueError("rectangle_vertices required to download nearest EPW file")
2323
-
2324
- try:
2325
- from voxcity.utils.weather import get_nearest_epw_from_climate_onebuilding
2326
- lons = [coord[0] for coord in rectangle_vertices]
2327
- lats = [coord[1] for coord in rectangle_vertices]
2328
- center_lon = (min(lons) + max(lons)) / 2
2329
- center_lat = (min(lats) + max(lats)) / 2
2330
- output_dir = kwargs.get('output_dir', 'output')
2331
- max_distance = kwargs.get('max_distance', 100)
2332
-
2333
- epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
2334
- longitude=center_lon,
2335
- latitude=center_lat,
2336
- output_dir=output_dir,
2337
- max_distance=max_distance,
2338
- extract_zip=True,
2339
- load_data=True
2340
- )
2341
- except ImportError:
2342
- raise ImportError("VoxCity weather utilities required for EPW download")
2343
-
2344
- if not epw_file_path:
2345
- raise ValueError("epw_file_path must be provided when download_nearest_epw is False")
2346
-
2347
- # Read EPW
2348
- try:
2349
- from voxcity.utils.weather import read_epw_for_solar_simulation
2350
- df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
2351
- except ImportError:
2352
- # Fallback to our EPW reader
2353
- from .epw import read_epw_header, read_epw_solar_data
2354
- location = read_epw_header(epw_file_path)
2355
- df = read_epw_solar_data(epw_file_path)
2356
- lon, lat, tz = location.longitude, location.latitude, location.timezone
2357
-
2358
- if df.empty:
2359
- raise ValueError("No data in EPW file.")
2779
+ # Load EPW data using helper function
2780
+ df, lon, lat, tz = _load_epw_data(
2781
+ epw_file_path=kwargs.pop('epw_file_path', None),
2782
+ download_nearest_epw=kwargs.pop('download_nearest_epw', False),
2783
+ voxcity=voxcity,
2784
+ **kwargs
2785
+ )
2360
2786
 
2361
2787
  # Create building mesh for output (just geometry, no SVF computation)
2362
2788
  # The RadiationModel computes SVF internally for voxel surfaces, so we don't need
@@ -2449,143 +2875,1105 @@ def get_building_global_solar_irradiance_using_epw(
2449
2875
  raise ValueError(f"Unknown calc_type: {calc_type}. Use 'instantaneous' or 'cumulative'.")
2450
2876
 
2451
2877
 
2452
- def get_global_solar_irradiance_using_epw(
2878
+ # =============================================================================
2879
+ # Volumetric Solar Irradiance Functions
2880
+ # =============================================================================
2881
+ # Module-level cache for VolumetricFluxCalculator
2882
+ _volumetric_flux_cache: Optional[Dict] = None
2883
+
2884
+
2885
+ def clear_volumetric_flux_cache():
2886
+ """Clear the cached VolumetricFluxCalculator to free memory or force recomputation."""
2887
+ global _volumetric_flux_cache
2888
+ _volumetric_flux_cache = None
2889
+
2890
+
2891
+ def _get_or_create_volumetric_calculator(
2453
2892
  voxcity,
2454
- calc_type: str = 'instantaneous',
2455
- direct_normal_irradiance_scaling: float = 1.0,
2456
- diffuse_irradiance_scaling: float = 1.0,
2893
+ n_azimuth: int = 36,
2894
+ n_zenith: int = 9,
2895
+ progress_report: bool = False,
2896
+ **kwargs
2897
+ ):
2898
+ """
2899
+ Get cached VolumetricFluxCalculator or create a new one if cache is invalid.
2900
+
2901
+ Args:
2902
+ voxcity: VoxCity object
2903
+ n_azimuth: Number of azimuthal directions
2904
+ n_zenith: Number of zenith angle divisions
2905
+ progress_report: Print progress messages
2906
+ **kwargs: Additional parameters
2907
+
2908
+ Returns:
2909
+ Tuple of (VolumetricFluxCalculator, Domain)
2910
+ """
2911
+ global _volumetric_flux_cache
2912
+
2913
+ from .volumetric import VolumetricFluxCalculator
2914
+ from .domain import Domain
2915
+
2916
+ voxel_data = voxcity.voxels.classes
2917
+ meshsize = voxcity.voxels.meta.meshsize
2918
+ ni, nj, nk = voxel_data.shape
2919
+
2920
+ # Check if cache is valid
2921
+ cache_valid = False
2922
+ if _volumetric_flux_cache is not None:
2923
+ cache = _volumetric_flux_cache
2924
+ if (cache.get('voxcity_shape') == voxel_data.shape and
2925
+ cache.get('meshsize') == meshsize and
2926
+ cache.get('n_azimuth') == n_azimuth):
2927
+ cache_valid = True
2928
+ if progress_report:
2929
+ print("Using cached VolumetricFluxCalculator (SVF already computed)")
2930
+
2931
+ if cache_valid:
2932
+ return _volumetric_flux_cache['calculator'], _volumetric_flux_cache['domain']
2933
+
2934
+ # Need to create new calculator
2935
+ if progress_report:
2936
+ print("Creating new VolumetricFluxCalculator...")
2937
+
2938
+ # Get location using helper function
2939
+ origin_lat, origin_lon = _get_location_from_voxcity(voxcity)
2940
+
2941
+ # Create domain
2942
+ domain = Domain(
2943
+ nx=ni, ny=nj, nz=nk,
2944
+ dx=meshsize, dy=meshsize, dz=meshsize,
2945
+ origin_lat=origin_lat,
2946
+ origin_lon=origin_lon
2947
+ )
2948
+
2949
+ # Convert VoxCity voxel data to domain arrays using vectorized helper
2950
+ default_lad = kwargs.get('default_lad', 1.0)
2951
+ is_solid_np, lad_np = _convert_voxel_data_to_arrays(voxel_data, default_lad)
2952
+
2953
+ # Set domain arrays
2954
+ _set_solid_array(domain, is_solid_np)
2955
+ domain.set_lad_from_array(lad_np)
2956
+ _update_topo_from_solid(domain)
2957
+
2958
+ # Create VolumetricFluxCalculator
2959
+ calculator = VolumetricFluxCalculator(
2960
+ domain,
2961
+ n_azimuth=n_azimuth,
2962
+ min_opaque_lad=kwargs.get('min_opaque_lad', 0.5)
2963
+ )
2964
+
2965
+ # Compute volumetric sky view factors (expensive, do once)
2966
+ if progress_report:
2967
+ print("Computing volumetric sky view factors...")
2968
+ calculator.compute_skyvf_vol(n_zenith=n_zenith)
2969
+
2970
+ # Cache the calculator
2971
+ _volumetric_flux_cache = {
2972
+ 'calculator': calculator,
2973
+ 'domain': domain,
2974
+ 'voxcity_shape': voxel_data.shape,
2975
+ 'meshsize': meshsize,
2976
+ 'n_azimuth': n_azimuth
2977
+ }
2978
+
2979
+ if progress_report:
2980
+ print(f"VolumetricFluxCalculator cached.")
2981
+
2982
+ return calculator, domain
2983
+
2984
+
2985
+ def get_volumetric_solar_irradiance_map(
2986
+ voxcity,
2987
+ azimuth_degrees_ori: float,
2988
+ elevation_degrees: float,
2989
+ direct_normal_irradiance: float,
2990
+ diffuse_irradiance: float,
2991
+ volumetric_height: float = 1.5,
2992
+ with_reflections: bool = False,
2457
2993
  show_plot: bool = False,
2458
2994
  **kwargs
2459
2995
  ) -> np.ndarray:
2460
2996
  """
2461
- GPU-accelerated global irradiance from EPW file.
2997
+ GPU-accelerated volumetric solar irradiance map at a specified height.
2462
2998
 
2463
- This function matches the signature of voxcity.simulator.solar.get_global_solar_irradiance_using_epw
2464
- using Taichi GPU acceleration.
2999
+ Computes the 3D radiation field at each grid cell and extracts a 2D horizontal
3000
+ slice at the specified height. This is useful for:
3001
+ - Mean Radiant Temperature (MRT) calculations
3002
+ - Pedestrian thermal comfort analysis
3003
+ - Light availability assessment
2465
3004
 
2466
3005
  Args:
2467
3006
  voxcity: VoxCity object
2468
- calc_type: 'instantaneous' or 'cumulative'
2469
- direct_normal_irradiance_scaling: Scaling factor for DNI
2470
- diffuse_irradiance_scaling: Scaling factor for DHI
3007
+ azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
3008
+ elevation_degrees: Solar elevation in degrees above horizon
3009
+ direct_normal_irradiance: DNI in W/m²
3010
+ diffuse_irradiance: DHI in W/m²
3011
+ volumetric_height: Height above ground for irradiance extraction (meters)
3012
+ with_reflections: If True, include reflected radiation from surfaces.
3013
+ If False (default), only direct + diffuse sky radiation.
2471
3014
  show_plot: Whether to display a matplotlib plot
2472
- **kwargs: Additional parameters including:
2473
- - epw_file_path (str): Path to EPW file
2474
- - download_nearest_epw (bool): Download nearest EPW (default: False)
2475
- - calc_time (str): For instantaneous: 'MM-DD HH:MM:SS'
2476
- - start_time, end_time (str): For cumulative: 'MM-DD HH:MM:SS'
2477
- - rectangle_vertices: Location vertices (for EPW download)
3015
+ **kwargs: Additional parameters:
3016
+ - n_azimuth (int): Number of azimuthal directions for SVF (default: 36)
3017
+ - n_zenith (int): Number of zenith angles for SVF (default: 9)
3018
+ - computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
3019
+ Grid cells outside the masked region are set to NaN.
3020
+ - progress_report (bool): Print progress (default: False)
3021
+ - colormap (str): Colormap for plot (default: 'magma')
3022
+ - vmin, vmax (float): Colormap bounds
3023
+ - n_reflection_steps (int): Reflection bounces when with_reflections=True (default: 2)
2478
3024
 
2479
3025
  Returns:
2480
- 2D numpy array of irradiance (W/m² or Wh/m²)
3026
+ 2D numpy array of volumetric irradiance at the specified height (W/m²)
2481
3027
  """
2482
- from datetime import datetime
2483
- import pytz
3028
+ import math
2484
3029
 
2485
- # Get EPW file
2486
- epw_file_path = kwargs.get('epw_file_path', None)
2487
- download_nearest_epw = kwargs.get('download_nearest_epw', False)
3030
+ kwargs = kwargs.copy() # Don't modify caller's kwargs
3031
+ progress_report = kwargs.pop('progress_report', False)
3032
+ n_azimuth = kwargs.pop('n_azimuth', 36)
3033
+ n_zenith = kwargs.pop('n_zenith', 9)
3034
+ computation_mask = kwargs.pop('computation_mask', None)
2488
3035
 
2489
- rectangle_vertices = kwargs.get('rectangle_vertices', None)
2490
- if rectangle_vertices is None:
2491
- extras = getattr(voxcity, 'extras', None)
2492
- if isinstance(extras, dict):
2493
- rectangle_vertices = extras.get('rectangle_vertices', None)
3036
+ # Get or create cached calculator
3037
+ calculator, domain = _get_or_create_volumetric_calculator(
3038
+ voxcity,
3039
+ n_azimuth=n_azimuth,
3040
+ n_zenith=n_zenith,
3041
+ progress_report=progress_report,
3042
+ **kwargs
3043
+ )
2494
3044
 
2495
- if download_nearest_epw:
2496
- if rectangle_vertices is None:
2497
- raise ValueError("rectangle_vertices required to download nearest EPW file")
2498
-
2499
- try:
2500
- from voxcity.utils.weather import get_nearest_epw_from_climate_onebuilding
2501
- lons = [coord[0] for coord in rectangle_vertices]
2502
- lats = [coord[1] for coord in rectangle_vertices]
2503
- center_lon = (min(lons) + max(lons)) / 2
2504
- center_lat = (min(lats) + max(lats)) / 2
2505
- output_dir = kwargs.get('output_dir', 'output')
2506
- max_distance = kwargs.get('max_distance', 100)
2507
-
2508
- epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
2509
- longitude=center_lon,
2510
- latitude=center_lat,
2511
- output_dir=output_dir,
2512
- max_distance=max_distance,
2513
- extract_zip=True,
2514
- load_data=True
2515
- )
2516
- except ImportError:
2517
- raise ImportError("VoxCity weather utilities required for EPW download")
3045
+ voxel_data = voxcity.voxels.classes
3046
+ meshsize = voxcity.voxels.meta.meshsize
3047
+ ni, nj, nk = voxel_data.shape
2518
3048
 
2519
- if not epw_file_path:
2520
- raise ValueError("epw_file_path must be provided when download_nearest_epw is False")
3049
+ # Convert solar angles to direction vector
3050
+ # Match the coordinate system used in ground-level functions:
3051
+ # azimuth_degrees_ori: 0=North, 90=East (clockwise from North)
3052
+ # Transform to model coordinates: 180 - azimuth_degrees_ori
3053
+ azimuth_degrees = 180 - azimuth_degrees_ori
3054
+ azimuth_rad = math.radians(azimuth_degrees)
3055
+ elevation_rad = math.radians(elevation_degrees)
2521
3056
 
2522
- # Read EPW
2523
- try:
2524
- from voxcity.utils.weather import read_epw_for_solar_simulation
2525
- df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
2526
- except ImportError:
2527
- # Fallback to our EPW reader
2528
- from .epw import read_epw_header, read_epw_solar_data
2529
- location = read_epw_header(epw_file_path)
2530
- df = read_epw_solar_data(epw_file_path)
2531
- lon, lat, tz = location.longitude, location.latitude, location.timezone
3057
+ cos_elev = math.cos(elevation_rad)
3058
+ sin_elev = math.sin(elevation_rad)
2532
3059
 
2533
- if df.empty:
2534
- raise ValueError("No data in EPW file.")
3060
+ # Direction toward sun (matching ground-level function convention)
3061
+ sun_dir_x = cos_elev * math.cos(azimuth_rad)
3062
+ sun_dir_y = cos_elev * math.sin(azimuth_rad)
3063
+ sun_dir_z = sin_elev
3064
+ sun_direction = (sun_dir_x, sun_dir_y, sun_dir_z)
2535
3065
 
2536
- if calc_type == 'instantaneous':
2537
- calc_time = kwargs.get('calc_time', '01-01 12:00:00')
2538
- try:
2539
- calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%S')
2540
- except ValueError:
2541
- raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'")
3066
+ cos_zenith = sin_elev # cos(zenith) = sin(elevation)
3067
+
3068
+ # Compute volumetric flux
3069
+ if with_reflections:
3070
+ # Use full reflection model
3071
+ if progress_report:
3072
+ print("Computing volumetric flux with reflections...")
2542
3073
 
2543
- df_period = df[
2544
- (df.index.month == calc_dt.month) &
2545
- (df.index.day == calc_dt.day) &
2546
- (df.index.hour == calc_dt.hour)
2547
- ]
2548
- if df_period.empty:
2549
- raise ValueError("No EPW data at the specified time.")
3074
+ # Get radiation model for surface reflections
3075
+ n_reflection_steps = kwargs.pop('n_reflection_steps', 2)
3076
+ model, valid_ground, ground_k = _get_or_create_radiation_model(
3077
+ voxcity,
3078
+ n_reflection_steps=n_reflection_steps,
3079
+ progress_report=progress_report,
3080
+ **kwargs
3081
+ )
2550
3082
 
2551
- # Get solar position
2552
- offset_minutes = int(tz * 60)
2553
- local_tz = pytz.FixedOffset(offset_minutes)
2554
- df_local = df_period.copy()
2555
- df_local.index = df_local.index.tz_localize(local_tz)
2556
- df_utc = df_local.tz_convert(pytz.UTC)
3083
+ # Manually set solar position on the model's solar_calc
3084
+ model.solar_calc.cos_zenith[None] = cos_zenith
3085
+ model.solar_calc.sun_direction[None] = [sun_direction[0], sun_direction[1], sun_direction[2]]
3086
+ model.solar_calc.sun_up[None] = 1 if cos_zenith > 0 else 0
2557
3087
 
2558
- solar_positions = _get_solar_positions_astral(df_utc.index, lon, lat)
2559
- DNI = float(df_utc.iloc[0]['DNI']) * direct_normal_irradiance_scaling
2560
- DHI = float(df_utc.iloc[0]['DHI']) * diffuse_irradiance_scaling
2561
- azimuth_degrees = float(solar_positions.iloc[0]['azimuth'])
2562
- elevation_degrees = float(solar_positions.iloc[0]['elevation'])
3088
+ # Compute shortwave radiation with the set solar position
3089
+ model.compute_shortwave_radiation(
3090
+ sw_direct=direct_normal_irradiance,
3091
+ sw_diffuse=diffuse_irradiance
3092
+ )
2563
3093
 
2564
- return get_global_solar_irradiance_map(
2565
- voxcity,
2566
- azimuth_degrees,
2567
- elevation_degrees,
2568
- DNI,
2569
- DHI,
2570
- show_plot=show_plot,
2571
- **kwargs
3094
+ # Get surface outgoing radiation
3095
+ n_surfaces = model.surfaces.count
3096
+ surf_outgoing = model.surfaces.sw_out.to_numpy()[:n_surfaces]
3097
+
3098
+ # Compute volumetric flux including reflections
3099
+ # Use cached C2S-VF matrix if available, otherwise compute dynamically
3100
+ if calculator.c2s_matrix_cached:
3101
+ calculator.compute_swflux_vol_with_reflections_cached(
3102
+ sw_direct=direct_normal_irradiance,
3103
+ sw_diffuse=diffuse_irradiance,
3104
+ cos_zenith=cos_zenith,
3105
+ sun_direction=sun_direction,
3106
+ surf_outgoing=surf_outgoing,
3107
+ lad=domain.lad
3108
+ )
3109
+ else:
3110
+ calculator.compute_swflux_vol_with_reflections(
3111
+ sw_direct=direct_normal_irradiance,
3112
+ sw_diffuse=diffuse_irradiance,
3113
+ cos_zenith=cos_zenith,
3114
+ sun_direction=sun_direction,
3115
+ surfaces=model.surfaces,
3116
+ surf_outgoing=surf_outgoing,
3117
+ lad=domain.lad
3118
+ )
3119
+ else:
3120
+ # Simple direct + diffuse only
3121
+ if progress_report:
3122
+ print("Computing volumetric flux (direct + diffuse)...")
3123
+
3124
+ calculator.compute_swflux_vol(
3125
+ sw_direct=direct_normal_irradiance,
3126
+ sw_diffuse=diffuse_irradiance,
3127
+ cos_zenith=cos_zenith,
3128
+ sun_direction=sun_direction,
3129
+ lad=domain.lad
2572
3130
  )
3131
+
3132
+ # Compute ground_k for terrain-following extraction
3133
+ ground_k = _compute_ground_k_from_voxels(voxel_data)
2573
3134
 
2574
- elif calc_type == 'cumulative':
2575
- return get_cumulative_global_solar_irradiance(
2576
- voxcity,
2577
- df,
2578
- lon,
2579
- lat,
2580
- tz,
2581
- direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
2582
- diffuse_irradiance_scaling=diffuse_irradiance_scaling,
2583
- show_plot=show_plot,
2584
- **kwargs
2585
- )
3135
+ # Extract terrain-following horizontal slice at specified height above ground
3136
+ # For each (i,j), extract at ground_k[i,j] + height_offset_k
3137
+ height_offset_k = max(1, int(round(volumetric_height / meshsize)))
3138
+ if progress_report:
3139
+ print(f"Extracting volumetric irradiance at {volumetric_height}m above terrain (offset={height_offset_k} cells)")
2586
3140
 
2587
- else:
2588
- raise ValueError(f"Unknown calc_type: {calc_type}. Use 'instantaneous' or 'cumulative'.")
3141
+ # Get full 3D volumetric flux and is_solid arrays
3142
+ swflux_3d = calculator.get_swflux_vol()
3143
+ is_solid = domain.is_solid.to_numpy()
3144
+
3145
+ # Create output array
3146
+ volumetric_map = np.full((ni, nj), np.nan, dtype=np.float64)
3147
+
3148
+ # Extract terrain-following values
3149
+ for i in range(ni):
3150
+ for j in range(nj):
3151
+ gk = ground_k[i, j]
3152
+ if gk < 0:
3153
+ # No valid ground - keep NaN
3154
+ continue
3155
+ k_extract = gk + height_offset_k
3156
+ if k_extract >= nk:
3157
+ # Above domain - keep NaN
3158
+ continue
3159
+ # Check if extraction point is in a solid cell
3160
+ if is_solid[i, j, k_extract] == 1:
3161
+ # Inside solid (building) - keep NaN
3162
+ continue
3163
+ volumetric_map[i, j] = swflux_3d[i, j, k_extract]
3164
+
3165
+ # Flip to match VoxCity coordinate system
3166
+ volumetric_map = np.flipud(volumetric_map)
3167
+
3168
+ # Apply computation_mask if provided
3169
+ if computation_mask is not None:
3170
+ # Handle mask shape orientation
3171
+ if computation_mask.shape == volumetric_map.shape:
3172
+ flipped_mask = np.flipud(computation_mask)
3173
+ volumetric_map = np.where(flipped_mask, volumetric_map, np.nan)
3174
+ elif computation_mask.T.shape == volumetric_map.shape:
3175
+ flipped_mask = np.flipud(computation_mask.T)
3176
+ volumetric_map = np.where(flipped_mask, volumetric_map, np.nan)
3177
+ else:
3178
+ # Best effort - try direct application
3179
+ if computation_mask.shape == volumetric_map.shape:
3180
+ volumetric_map = np.where(computation_mask, volumetric_map, np.nan)
3181
+
3182
+ if progress_report:
3183
+ n_masked = np.sum(np.isnan(volumetric_map))
3184
+ total = volumetric_map.size
3185
+ print(f" Cells outside computation_mask set to NaN: {n_masked}/{total} ({100*n_masked/total:.1f}%)")
3186
+
3187
+ if show_plot:
3188
+ colormap = kwargs.get('colormap', 'magma')
3189
+ vmin = kwargs.get('vmin', 0.0)
3190
+ vmax = kwargs.get('vmax', max(float(np.nanmax(volumetric_map)), 1.0))
3191
+ try:
3192
+ import matplotlib.pyplot as plt
3193
+ cmap = plt.cm.get_cmap(colormap).copy()
3194
+ cmap.set_bad(color='lightgray')
3195
+ plt.figure(figsize=(10, 8))
3196
+ plt.imshow(volumetric_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
3197
+ plt.colorbar(label=f'Volumetric Solar Irradiance at {volumetric_height}m (W/m²)')
3198
+ plt.title(f'Volumetric Irradiance (reflections={"on" if with_reflections else "off"})')
3199
+ plt.axis('off')
3200
+ plt.show()
3201
+ except ImportError:
3202
+ pass
3203
+
3204
+ return volumetric_map
3205
+
3206
+
3207
+ def get_cumulative_volumetric_solar_irradiance(
3208
+ voxcity,
3209
+ df,
3210
+ lon: float,
3211
+ lat: float,
3212
+ tz: float,
3213
+ direct_normal_irradiance_scaling: float = 1.0,
3214
+ diffuse_irradiance_scaling: float = 1.0,
3215
+ volumetric_height: float = 1.5,
3216
+ with_reflections: bool = False,
3217
+ show_plot: bool = False,
3218
+ **kwargs
3219
+ ) -> np.ndarray:
3220
+ """
3221
+ GPU-accelerated cumulative volumetric solar irradiance over a period.
3222
+
3223
+ Integrates the 3D radiation field over time and extracts a 2D horizontal
3224
+ slice at the specified height.
3225
+
3226
+ Args:
3227
+ voxcity: VoxCity object
3228
+ df: pandas DataFrame with 'DNI' and 'DHI' columns, datetime-indexed
3229
+ lon: Longitude in degrees
3230
+ lat: Latitude in degrees
3231
+ tz: Timezone offset in hours
3232
+ direct_normal_irradiance_scaling: Scaling factor for DNI
3233
+ diffuse_irradiance_scaling: Scaling factor for DHI
3234
+ volumetric_height: Height above ground for irradiance extraction (meters)
3235
+ with_reflections: If True, include reflected radiation from buildings,
3236
+ ground, and tree canopy surfaces. If False (default), only direct
3237
+ + diffuse sky radiation.
3238
+ show_plot: Whether to display a matplotlib plot
3239
+ **kwargs: Additional parameters:
3240
+ - start_time (str): Start time 'MM-DD HH:MM:SS' (default: '01-01 05:00:00')
3241
+ - end_time (str): End time 'MM-DD HH:MM:SS' (default: '01-01 20:00:00')
3242
+ - use_sky_patches (bool): Use sky patch optimization (default: True)
3243
+ - sky_discretization (str): 'tregenza', 'reinhart', 'uniform', 'fibonacci'
3244
+ - computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
3245
+ Grid cells outside the masked region are set to NaN.
3246
+ - progress_report (bool): Print progress (default: False)
3247
+ - n_reflection_steps (int): Reflection bounces when with_reflections=True (default: 2)
3248
+
3249
+ Returns:
3250
+ 2D numpy array of cumulative volumetric irradiance at the specified height (Wh/m²)
3251
+ """
3252
+ import time
3253
+ from datetime import datetime
3254
+ import pytz
3255
+ import math
3256
+
3257
+ # Extract parameters
3258
+ kwargs = kwargs.copy()
3259
+ progress_report = kwargs.pop('progress_report', False)
3260
+ start_time = kwargs.pop('start_time', '01-01 05:00:00')
3261
+ end_time = kwargs.pop('end_time', '01-01 20:00:00')
3262
+ use_sky_patches = kwargs.pop('use_sky_patches', True)
3263
+ sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
3264
+ n_azimuth = kwargs.pop('n_azimuth', 36)
3265
+ n_zenith = kwargs.pop('n_zenith', 9)
3266
+ computation_mask = kwargs.pop('computation_mask', None)
3267
+
3268
+ if df.empty:
3269
+ raise ValueError("No data in EPW dataframe.")
3270
+
3271
+ # Parse time range
3272
+ try:
3273
+ start_dt = datetime.strptime(start_time, '%m-%d %H:%M:%S')
3274
+ end_dt = datetime.strptime(end_time, '%m-%d %H:%M:%S')
3275
+ except ValueError as ve:
3276
+ raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
3277
+
3278
+ # Filter dataframe to period
3279
+ df = df.copy()
3280
+ df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
3281
+ start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
3282
+ end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
3283
+ start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
3284
+ end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
3285
+
3286
+ if start_hour <= end_hour:
3287
+ df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
3288
+ else:
3289
+ df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
3290
+
3291
+ if df_period.empty:
3292
+ raise ValueError("No EPW data in the specified period.")
3293
+
3294
+ # Localize and convert to UTC
3295
+ offset_minutes = int(tz * 60)
3296
+ local_tz = pytz.FixedOffset(offset_minutes)
3297
+ df_period_local = df_period.copy()
3298
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
3299
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
3300
+
3301
+ # Get solar positions
3302
+ solar_positions = _get_solar_positions_astral(df_period_utc.index, lon, lat)
3303
+
3304
+ # Get or create cached calculator
3305
+ calculator, domain = _get_or_create_volumetric_calculator(
3306
+ voxcity,
3307
+ n_azimuth=n_azimuth,
3308
+ n_zenith=n_zenith,
3309
+ progress_report=progress_report,
3310
+ **kwargs
3311
+ )
3312
+
3313
+ voxel_data = voxcity.voxels.classes
3314
+ meshsize = voxcity.voxels.meta.meshsize
3315
+ ni, nj, nk = voxel_data.shape
3316
+
3317
+ # Compute terrain-following extraction parameters
3318
+ height_offset_k = max(1, int(round(volumetric_height / meshsize)))
3319
+ if progress_report:
3320
+ print(f"Extracting volumetric irradiance at {volumetric_height}m above terrain (offset={height_offset_k} cells)")
3321
+
3322
+ # Get is_solid array for masking
3323
+ is_solid = domain.is_solid.to_numpy()
3324
+
3325
+ # Initialize cumulative map (will be NaN-masked at the end)
3326
+ cumulative_map = np.zeros((ni, nj), dtype=np.float64)
3327
+ time_step_hours = kwargs.get('time_step_hours', 1.0)
3328
+
3329
+ # Get radiation model for reflections if needed
3330
+ model = None
3331
+ ground_k = None
3332
+ if with_reflections:
3333
+ n_reflection_steps = kwargs.pop('n_reflection_steps', 2)
3334
+ model, valid_ground, ground_k = _get_or_create_radiation_model(
3335
+ voxcity,
3336
+ n_reflection_steps=n_reflection_steps,
3337
+ progress_report=progress_report,
3338
+ **kwargs
3339
+ )
3340
+ else:
3341
+ # Compute ground_k for terrain-following extraction
3342
+ ground_k = _compute_ground_k_from_voxels(voxel_data)
3343
+
3344
+ # OPTIMIZATION: Initialize GPU-side cumulative accumulation
3345
+ # This avoids transferring full 3D arrays for each patch/timestep
3346
+ calculator.init_cumulative_accumulation(
3347
+ ground_k=ground_k,
3348
+ height_offset_k=height_offset_k,
3349
+ is_solid=is_solid
3350
+ )
3351
+
3352
+ # OPTIMIZATION: Pre-compute Terrain-to-Surface VF matrix for cached reflections
3353
+ # This makes reflection computation O(nnz) instead of O(N_cells * N_surfaces)
3354
+ # for each sky patch, providing massive speedup for cumulative calculations.
3355
+ if with_reflections and model is not None:
3356
+ t2s_start = time.perf_counter() if progress_report else 0
3357
+ calculator.compute_t2s_matrix(
3358
+ surfaces=model.surfaces,
3359
+ min_vf_threshold=1e-6,
3360
+ progress_report=progress_report
3361
+ )
3362
+ if progress_report:
3363
+ t2s_elapsed = time.perf_counter() - t2s_start
3364
+ print(f" T2S matrix pre-computation: {t2s_elapsed:.2f}s")
3365
+
3366
+ # Extract arrays
3367
+ azimuth_arr = solar_positions['azimuth'].to_numpy()
3368
+ elevation_arr = solar_positions['elevation'].to_numpy()
3369
+ dni_arr = df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling
3370
+ dhi_arr = df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling
3371
+
3372
+ n_timesteps = len(azimuth_arr)
3373
+
3374
+ if progress_report:
3375
+ print(f"Computing cumulative volumetric irradiance for {n_timesteps} timesteps...")
3376
+ print(f" Height: {volumetric_height}m, Reflections: {'on' if with_reflections else 'off'}")
3377
+ print(f" Using GPU-optimized terrain-following accumulation")
3378
+
3379
+ t0 = time.perf_counter() if progress_report else 0
3380
+
3381
+ if use_sky_patches:
3382
+ # Use sky patch aggregation for efficiency
3383
+ from .sky import (
3384
+ generate_tregenza_patches,
3385
+ generate_reinhart_patches,
3386
+ generate_uniform_grid_patches,
3387
+ generate_fibonacci_patches,
3388
+ get_tregenza_patch_index
3389
+ )
3390
+
3391
+ # Generate sky patches
3392
+ if sky_discretization.lower() == 'tregenza':
3393
+ patches, directions, solid_angles = generate_tregenza_patches()
3394
+ elif sky_discretization.lower() == 'reinhart':
3395
+ mf = kwargs.get('reinhart_mf', kwargs.get('mf', 4))
3396
+ patches, directions, solid_angles = generate_reinhart_patches(mf=mf)
3397
+ elif sky_discretization.lower() == 'uniform':
3398
+ n_az = kwargs.get('sky_n_azimuth', 36)
3399
+ n_el = kwargs.get('sky_n_elevation', 9)
3400
+ patches, directions, solid_angles = generate_uniform_grid_patches(n_az, n_el)
3401
+ elif sky_discretization.lower() == 'fibonacci':
3402
+ n_patches = kwargs.get('sky_n_patches', 145)
3403
+ patches, directions, solid_angles = generate_fibonacci_patches(n_patches=n_patches)
3404
+ else:
3405
+ raise ValueError(f"Unknown sky discretization method: {sky_discretization}")
3406
+
3407
+ n_patches = len(patches)
3408
+ cumulative_dni = np.zeros(n_patches, dtype=np.float64)
3409
+ total_dhi = 0.0
3410
+
3411
+ # Bin sun positions to patches
3412
+ for i in range(n_timesteps):
3413
+ elev = elevation_arr[i]
3414
+ if elev <= 0:
3415
+ continue
3416
+
3417
+ az = azimuth_arr[i]
3418
+ dni = dni_arr[i]
3419
+ dhi = dhi_arr[i]
3420
+
3421
+ if dni > 0:
3422
+ patch_idx = get_tregenza_patch_index(az, elev)
3423
+ if 0 <= patch_idx < n_patches:
3424
+ cumulative_dni[patch_idx] += dni * time_step_hours
3425
+
3426
+ if dhi > 0:
3427
+ total_dhi += dhi * time_step_hours
3428
+
3429
+ # Process each patch with accumulated DNI
3430
+ patches_with_dni = np.where(cumulative_dni > 0)[0]
3431
+
3432
+ if progress_report:
3433
+ print(f" Processing {len(patches_with_dni)} sky patches with accumulated DNI...")
3434
+
3435
+ for idx, patch_idx in enumerate(patches_with_dni):
3436
+ patch_dni = cumulative_dni[patch_idx]
3437
+ patch_dir = directions[patch_idx]
3438
+
3439
+ # Convert patch direction to azimuth/elevation
3440
+ patch_azimuth_ori = math.degrees(math.atan2(patch_dir[0], patch_dir[1]))
3441
+ if patch_azimuth_ori < 0:
3442
+ patch_azimuth_ori += 360
3443
+ patch_elevation = math.degrees(math.asin(patch_dir[2]))
3444
+
3445
+ # Apply same coordinate transform as ground-level functions
3446
+ patch_azimuth = 180 - patch_azimuth_ori
3447
+ azimuth_rad = math.radians(patch_azimuth)
3448
+ elevation_rad = math.radians(patch_elevation)
3449
+ cos_elev = math.cos(elevation_rad)
3450
+ sin_elev = math.sin(elevation_rad)
3451
+
3452
+ cos_zenith = sin_elev
3453
+ sun_dir_x = cos_elev * math.cos(azimuth_rad)
3454
+ sun_dir_y = cos_elev * math.sin(azimuth_rad)
3455
+ sun_dir_z = sin_elev
3456
+ sun_direction = (sun_dir_x, sun_dir_y, sun_dir_z)
3457
+
3458
+ if with_reflections and model is not None:
3459
+ # Set solar position on the model
3460
+ model.solar_calc.cos_zenith[None] = cos_zenith
3461
+ model.solar_calc.sun_direction[None] = [sun_direction[0], sun_direction[1], sun_direction[2]]
3462
+ model.solar_calc.sun_up[None] = 1 if cos_zenith > 0 else 0
3463
+
3464
+ # Compute surface irradiance with reflections (uses cached SVF matrix)
3465
+ model.compute_shortwave_radiation(
3466
+ sw_direct=patch_dni / time_step_hours, # Instantaneous for reflection calc
3467
+ sw_diffuse=0.0 # DHI handled separately
3468
+ )
3469
+
3470
+ n_surfaces = model.surfaces.count
3471
+ surf_outgoing = model.surfaces.sw_out.to_numpy()[:n_surfaces]
3472
+
3473
+ # OPTIMIZED: Compute direct+diffuse for full volume first
3474
+ calculator.compute_swflux_vol(
3475
+ sw_direct=patch_dni / time_step_hours,
3476
+ sw_diffuse=0.0,
3477
+ cos_zenith=cos_zenith,
3478
+ sun_direction=sun_direction,
3479
+ lad=domain.lad
3480
+ )
3481
+
3482
+ # OPTIMIZED: Compute reflections using pre-computed T2S-VF matrix
3483
+ # This is O(nnz) instead of O(N_terrain_cells * N_surfaces) per patch
3484
+ calculator.compute_reflected_flux_terrain_cached(surf_outgoing=surf_outgoing)
3485
+
3486
+ # Add reflections to swflux_vol at extraction level
3487
+ calculator._add_reflected_to_total()
3488
+ else:
3489
+ calculator.compute_swflux_vol(
3490
+ sw_direct=patch_dni / time_step_hours,
3491
+ sw_diffuse=0.0,
3492
+ cos_zenith=cos_zenith,
3493
+ sun_direction=sun_direction,
3494
+ lad=domain.lad
3495
+ )
3496
+
3497
+ # OPTIMIZATION: Accumulate terrain-following slice directly on GPU
3498
+ # This avoids transferring the full 3D array for each patch
3499
+ calculator.accumulate_terrain_following_slice_gpu(weight=time_step_hours)
3500
+
3501
+ if progress_report and (idx + 1) % 10 == 0:
3502
+ elapsed = time.perf_counter() - t0
3503
+ print(f" Processed {idx + 1}/{len(patches_with_dni)} patches ({elapsed:.1f}s)")
3504
+
3505
+ # Add diffuse contribution using GPU-optimized SVF accumulation
3506
+ if total_dhi > 0:
3507
+ calculator.accumulate_svf_diffuse_gpu(total_dhi=total_dhi)
3508
+
3509
+ else:
3510
+ # Process each timestep individually
3511
+ for i in range(n_timesteps):
3512
+ elev = elevation_arr[i]
3513
+ if elev <= 0:
3514
+ continue
3515
+
3516
+ az = azimuth_arr[i]
3517
+ dni = dni_arr[i]
3518
+ dhi = dhi_arr[i]
3519
+
3520
+ if dni <= 0 and dhi <= 0:
3521
+ continue
3522
+
3523
+ # Convert to direction vector
3524
+ # Match the coordinate system used in ground-level functions
3525
+ azimuth_degrees = 180 - az
3526
+ azimuth_rad = math.radians(azimuth_degrees)
3527
+ elevation_rad = math.radians(elev)
3528
+ cos_elev = math.cos(elevation_rad)
3529
+ sin_elev = math.sin(elevation_rad)
3530
+
3531
+ sun_dir_x = cos_elev * math.cos(azimuth_rad)
3532
+ sun_dir_y = cos_elev * math.sin(azimuth_rad)
3533
+ sun_dir_z = sin_elev
3534
+ sun_direction = (sun_dir_x, sun_dir_y, sun_dir_z)
3535
+ cos_zenith = sin_elev
3536
+
3537
+ if with_reflections and model is not None:
3538
+ # Set solar position on the model
3539
+ model.solar_calc.cos_zenith[None] = cos_zenith
3540
+ model.solar_calc.sun_direction[None] = [sun_direction[0], sun_direction[1], sun_direction[2]]
3541
+ model.solar_calc.sun_up[None] = 1 if cos_zenith > 0 else 0
3542
+
3543
+ model.compute_shortwave_radiation(
3544
+ sw_direct=dni,
3545
+ sw_diffuse=dhi
3546
+ )
3547
+
3548
+ n_surfaces = model.surfaces.count
3549
+ surf_outgoing = model.surfaces.sw_out.to_numpy()[:n_surfaces]
3550
+
3551
+ # OPTIMIZED: Compute direct+diffuse for full volume first
3552
+ calculator.compute_swflux_vol(
3553
+ sw_direct=dni,
3554
+ sw_diffuse=dhi,
3555
+ cos_zenith=cos_zenith,
3556
+ sun_direction=sun_direction,
3557
+ lad=domain.lad
3558
+ )
3559
+
3560
+ # OPTIMIZED: Compute reflections using pre-computed T2S-VF matrix
3561
+ # This is O(nnz) instead of O(N_terrain_cells * N_surfaces) per timestep
3562
+ calculator.compute_reflected_flux_terrain_cached(surf_outgoing=surf_outgoing)
3563
+
3564
+ # Add reflections to swflux_vol at extraction level
3565
+ calculator._add_reflected_to_total()
3566
+ else:
3567
+ calculator.compute_swflux_vol(
3568
+ sw_direct=dni,
3569
+ sw_diffuse=dhi,
3570
+ cos_zenith=cos_zenith,
3571
+ sun_direction=sun_direction,
3572
+ lad=domain.lad
3573
+ )
3574
+
3575
+ # OPTIMIZATION: Accumulate terrain-following slice directly on GPU
3576
+ calculator.accumulate_terrain_following_slice_gpu(weight=time_step_hours)
3577
+
3578
+ if progress_report and (i + 1) % 100 == 0:
3579
+ elapsed = time.perf_counter() - t0
3580
+ print(f" Processed {i + 1}/{n_timesteps} timesteps ({elapsed:.1f}s)")
3581
+
3582
+ if progress_report:
3583
+ elapsed = time.perf_counter() - t0
3584
+ print(f"Cumulative volumetric irradiance complete in {elapsed:.2f}s")
3585
+
3586
+ # Get final cumulative map from GPU with NaN masking
3587
+ cumulative_map = calculator.finalize_cumulative_map(apply_nan_mask=True)
3588
+
3589
+ # Flip to match VoxCity coordinate system
3590
+ cumulative_map = np.flipud(cumulative_map)
3591
+
3592
+ # Apply computation_mask if provided
3593
+ if computation_mask is not None:
3594
+ # Handle mask shape orientation
3595
+ if computation_mask.shape == cumulative_map.shape:
3596
+ flipped_mask = np.flipud(computation_mask)
3597
+ cumulative_map = np.where(flipped_mask, cumulative_map, np.nan)
3598
+ elif computation_mask.T.shape == cumulative_map.shape:
3599
+ flipped_mask = np.flipud(computation_mask.T)
3600
+ cumulative_map = np.where(flipped_mask, cumulative_map, np.nan)
3601
+ else:
3602
+ # Best effort - try direct application
3603
+ if computation_mask.shape == cumulative_map.shape:
3604
+ cumulative_map = np.where(computation_mask, cumulative_map, np.nan)
3605
+
3606
+ if progress_report:
3607
+ n_masked = np.sum(np.isnan(cumulative_map))
3608
+ total = cumulative_map.size
3609
+ print(f" Cells outside computation_mask set to NaN: {n_masked}/{total} ({100*n_masked/total:.1f}%)")
3610
+
3611
+ if show_plot:
3612
+ colormap = kwargs.get('colormap', 'magma')
3613
+ vmin = kwargs.get('vmin', 0.0)
3614
+ vmax = kwargs.get('vmax', max(float(np.nanmax(cumulative_map)), 1.0))
3615
+ try:
3616
+ import matplotlib.pyplot as plt
3617
+ cmap = plt.cm.get_cmap(colormap).copy()
3618
+ cmap.set_bad(color='lightgray')
3619
+ plt.figure(figsize=(10, 8))
3620
+ plt.imshow(cumulative_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
3621
+ plt.colorbar(label=f'Cumulative Volumetric Irradiance at {volumetric_height}m (Wh/m²)')
3622
+ plt.title(f'Cumulative Volumetric Irradiance (reflections={"on" if with_reflections else "off"})')
3623
+ plt.axis('off')
3624
+ plt.show()
3625
+ except ImportError:
3626
+ pass
3627
+
3628
+ return cumulative_map
3629
+
3630
+
3631
+ def get_volumetric_solar_irradiance_using_epw(
3632
+ voxcity,
3633
+ calc_type: str = 'instantaneous',
3634
+ direct_normal_irradiance_scaling: float = 1.0,
3635
+ diffuse_irradiance_scaling: float = 1.0,
3636
+ volumetric_height: float = 1.5,
3637
+ with_reflections: bool = False,
3638
+ show_plot: bool = False,
3639
+ **kwargs
3640
+ ) -> np.ndarray:
3641
+ """
3642
+ GPU-accelerated volumetric solar irradiance from EPW file.
3643
+
3644
+ Computes 3D radiation fields and extracts a 2D horizontal slice at the
3645
+ specified height above ground. This is useful for:
3646
+ - Mean Radiant Temperature (MRT) calculations
3647
+ - Pedestrian thermal comfort analysis
3648
+ - Light availability assessment
3649
+
3650
+ Args:
3651
+ voxcity: VoxCity object
3652
+ calc_type: 'instantaneous' or 'cumulative'
3653
+ direct_normal_irradiance_scaling: Scaling factor for DNI
3654
+ diffuse_irradiance_scaling: Scaling factor for DHI
3655
+ volumetric_height: Height above ground for irradiance extraction (meters)
3656
+ with_reflections: If True, include reflected radiation from buildings,
3657
+ ground, and tree canopy surfaces. If False (default), only direct
3658
+ + diffuse sky radiation.
3659
+ show_plot: Whether to display a matplotlib plot
3660
+ **kwargs: Additional parameters including:
3661
+ - epw_file_path (str): Path to EPW file
3662
+ - download_nearest_epw (bool): Download nearest EPW (default: False)
3663
+ - calc_time (str): For instantaneous: 'MM-DD HH:MM:SS'
3664
+ - start_time, end_time (str): For cumulative: 'MM-DD HH:MM:SS'
3665
+ - rectangle_vertices: Location vertices (for EPW download)
3666
+ - computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
3667
+ Grid cells outside the masked region are set to NaN.
3668
+ - n_reflection_steps (int): Reflection bounces when with_reflections=True (default: 2)
3669
+
3670
+ Returns:
3671
+ 2D numpy array of volumetric irradiance at the specified height (W/m² or Wh/m²)
3672
+ """
3673
+ from datetime import datetime
3674
+ import pytz
3675
+
3676
+ # Load EPW data using helper function
3677
+ kwargs_copy = dict(kwargs)
3678
+ df, lon, lat, tz = _load_epw_data(
3679
+ epw_file_path=kwargs_copy.pop('epw_file_path', None),
3680
+ download_nearest_epw=kwargs_copy.pop('download_nearest_epw', False),
3681
+ voxcity=voxcity,
3682
+ **kwargs_copy
3683
+ )
3684
+
3685
+ if calc_type == 'instantaneous':
3686
+ calc_time = kwargs.get('calc_time', '01-01 12:00:00')
3687
+ try:
3688
+ calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%SS')
3689
+ except ValueError:
3690
+ try:
3691
+ calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%S')
3692
+ except ValueError:
3693
+ raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'")
3694
+
3695
+ df_period = df[
3696
+ (df.index.month == calc_dt.month) &
3697
+ (df.index.day == calc_dt.day) &
3698
+ (df.index.hour == calc_dt.hour)
3699
+ ]
3700
+ if df_period.empty:
3701
+ raise ValueError("No EPW data at the specified time.")
3702
+
3703
+ # Get solar position
3704
+ offset_minutes = int(tz * 60)
3705
+ local_tz = pytz.FixedOffset(offset_minutes)
3706
+ df_local = df_period.copy()
3707
+ df_local.index = df_local.index.tz_localize(local_tz)
3708
+ df_utc = df_local.tz_convert(pytz.UTC)
3709
+
3710
+ solar_positions = _get_solar_positions_astral(df_utc.index, lon, lat)
3711
+ DNI = float(df_utc.iloc[0]['DNI']) * direct_normal_irradiance_scaling
3712
+ DHI = float(df_utc.iloc[0]['DHI']) * diffuse_irradiance_scaling
3713
+ azimuth_degrees = float(solar_positions.iloc[0]['azimuth'])
3714
+ elevation_degrees = float(solar_positions.iloc[0]['elevation'])
3715
+
3716
+ return get_volumetric_solar_irradiance_map(
3717
+ voxcity,
3718
+ azimuth_degrees,
3719
+ elevation_degrees,
3720
+ DNI,
3721
+ DHI,
3722
+ volumetric_height=volumetric_height,
3723
+ with_reflections=with_reflections,
3724
+ show_plot=show_plot,
3725
+ **kwargs
3726
+ )
3727
+
3728
+ elif calc_type == 'cumulative':
3729
+ return get_cumulative_volumetric_solar_irradiance(
3730
+ voxcity,
3731
+ df,
3732
+ lon,
3733
+ lat,
3734
+ tz,
3735
+ direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
3736
+ diffuse_irradiance_scaling=diffuse_irradiance_scaling,
3737
+ volumetric_height=volumetric_height,
3738
+ with_reflections=with_reflections,
3739
+ show_plot=show_plot,
3740
+ **kwargs
3741
+ )
3742
+
3743
+ else:
3744
+ raise ValueError(f"Unknown calc_type: {calc_type}. Use 'instantaneous' or 'cumulative'.")
3745
+
3746
+
3747
+ def get_global_solar_irradiance_using_epw(
3748
+ voxcity,
3749
+ temporal_mode: str = 'instantaneous',
3750
+ spatial_mode: str = 'horizontal',
3751
+ direct_normal_irradiance_scaling: float = 1.0,
3752
+ diffuse_irradiance_scaling: float = 1.0,
3753
+ show_plot: bool = False,
3754
+ calc_type: str = None, # Deprecated, for backward compatibility
3755
+ computation_mask: np.ndarray = None,
3756
+ **kwargs
3757
+ ) -> np.ndarray:
3758
+ """
3759
+ GPU-accelerated global irradiance from EPW file.
3760
+
3761
+ This function matches the signature of voxcity.simulator.solar.get_global_solar_irradiance_using_epw
3762
+ using Taichi GPU acceleration.
3763
+
3764
+ Args:
3765
+ voxcity: VoxCity object
3766
+ temporal_mode: Time integration mode:
3767
+ - 'instantaneous': Single time point (requires calc_time)
3768
+ - 'cumulative': Integrate over time range (requires start_time, end_time)
3769
+ spatial_mode: Spatial computation mode:
3770
+ - 'horizontal': 2D ground-level irradiance at view_point_height
3771
+ - 'volumetric': 3D radiation field extracted at volumetric_height above terrain
3772
+ direct_normal_irradiance_scaling: Scaling factor for DNI
3773
+ diffuse_irradiance_scaling: Scaling factor for DHI
3774
+ show_plot: Whether to display a matplotlib plot
3775
+ calc_type: DEPRECATED. Use temporal_mode and spatial_mode instead.
3776
+ Legacy values 'instantaneous', 'cumulative', 'volumetric' are still supported.
3777
+ computation_mask: Optional 2D boolean numpy array of shape (nx, ny).
3778
+ If provided, only cells where mask is True will be computed.
3779
+ Cells where mask is False will be set to NaN in the output.
3780
+ Use create_computation_mask() to create masks easily.
3781
+ **kwargs: Additional parameters including:
3782
+ - epw_file_path (str): Path to EPW file
3783
+ - download_nearest_epw (bool): Download nearest EPW (default: False)
3784
+ - calc_time (str): For instantaneous: 'MM-DD HH:MM:SS'
3785
+ - start_time, end_time (str): For cumulative: 'MM-DD HH:MM:SS'
3786
+ - rectangle_vertices: Location vertices (for EPW download)
3787
+ - view_point_height (float): Height for horizontal mode (default: 1.5)
3788
+ - volumetric_height (float): Height for volumetric mode (default: 1.5)
3789
+ - with_reflections (bool): Include reflections (default: False)
3790
+ - n_reflection_steps (int): Reflection bounces (default: 2)
3791
+
3792
+ Returns:
3793
+ 2D numpy array of irradiance (W/m² for instantaneous, Wh/m² for cumulative)
3794
+
3795
+ Examples:
3796
+ # Instantaneous ground-level irradiance
3797
+ grid = get_global_solar_irradiance_using_epw(
3798
+ voxcity,
3799
+ temporal_mode='instantaneous',
3800
+ spatial_mode='horizontal',
3801
+ calc_time='08-03 10:00:00',
3802
+ epw_file_path='weather.epw'
3803
+ )
3804
+
3805
+ # Cumulative volumetric irradiance with reflections
3806
+ grid = get_global_solar_irradiance_using_epw(
3807
+ voxcity,
3808
+ temporal_mode='cumulative',
3809
+ spatial_mode='volumetric',
3810
+ start_time='01-01 09:00:00',
3811
+ end_time='01-31 19:00:00',
3812
+ volumetric_height=1.5,
3813
+ with_reflections=True,
3814
+ epw_file_path='weather.epw'
3815
+ )
3816
+ """
3817
+ from datetime import datetime
3818
+ import pytz
3819
+ import warnings
3820
+
3821
+ # Handle backward compatibility with calc_type parameter
3822
+ if calc_type is not None:
3823
+ warnings.warn(
3824
+ "calc_type parameter is deprecated. Use temporal_mode and spatial_mode instead. "
3825
+ "Example: temporal_mode='cumulative', spatial_mode='volumetric'",
3826
+ DeprecationWarning,
3827
+ stacklevel=2
3828
+ )
3829
+ if calc_type == 'instantaneous':
3830
+ temporal_mode = 'instantaneous'
3831
+ spatial_mode = 'horizontal'
3832
+ elif calc_type == 'cumulative':
3833
+ temporal_mode = 'cumulative'
3834
+ spatial_mode = 'horizontal'
3835
+ elif calc_type == 'volumetric':
3836
+ # Legacy volumetric: determine temporal mode from time parameters
3837
+ spatial_mode = 'volumetric'
3838
+ calc_time = kwargs.get('calc_time', None)
3839
+ start_time = kwargs.get('start_time', None)
3840
+ if calc_time is not None and start_time is None:
3841
+ temporal_mode = 'instantaneous'
3842
+ else:
3843
+ temporal_mode = 'cumulative'
3844
+ else:
3845
+ raise ValueError(f"Unknown calc_type: {calc_type}. Use temporal_mode/spatial_mode instead.")
3846
+
3847
+ # Validate parameters
3848
+ if temporal_mode not in ('instantaneous', 'cumulative'):
3849
+ raise ValueError(f"temporal_mode must be 'instantaneous' or 'cumulative', got '{temporal_mode}'")
3850
+ if spatial_mode not in ('horizontal', 'volumetric'):
3851
+ raise ValueError(f"spatial_mode must be 'horizontal' or 'volumetric', got '{spatial_mode}'")
3852
+
3853
+ # Load EPW data using helper function
3854
+ kwargs_copy = dict(kwargs)
3855
+ df, lon, lat, tz = _load_epw_data(
3856
+ epw_file_path=kwargs_copy.pop('epw_file_path', None),
3857
+ download_nearest_epw=kwargs_copy.pop('download_nearest_epw', False),
3858
+ voxcity=voxcity,
3859
+ **kwargs_copy
3860
+ )
3861
+
3862
+ # Add computation_mask to kwargs for passing to underlying functions
3863
+ if computation_mask is not None:
3864
+ kwargs['computation_mask'] = computation_mask
3865
+
3866
+ # Route to appropriate function based on temporal_mode × spatial_mode
3867
+ if spatial_mode == 'horizontal':
3868
+ # Ground-level horizontal irradiance
3869
+ if temporal_mode == 'instantaneous':
3870
+ calc_time = kwargs.get('calc_time', '01-01 12:00:00')
3871
+ try:
3872
+ calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%S')
3873
+ except ValueError:
3874
+ raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'")
3875
+
3876
+ df_period = df[
3877
+ (df.index.month == calc_dt.month) &
3878
+ (df.index.day == calc_dt.day) &
3879
+ (df.index.hour == calc_dt.hour)
3880
+ ]
3881
+ if df_period.empty:
3882
+ raise ValueError("No EPW data at the specified time.")
3883
+
3884
+ offset_minutes = int(tz * 60)
3885
+ local_tz = pytz.FixedOffset(offset_minutes)
3886
+ df_local = df_period.copy()
3887
+ df_local.index = df_local.index.tz_localize(local_tz)
3888
+ df_utc = df_local.tz_convert(pytz.UTC)
3889
+
3890
+ solar_positions = _get_solar_positions_astral(df_utc.index, lon, lat)
3891
+ DNI = float(df_utc.iloc[0]['DNI']) * direct_normal_irradiance_scaling
3892
+ DHI = float(df_utc.iloc[0]['DHI']) * diffuse_irradiance_scaling
3893
+ azimuth_degrees = float(solar_positions.iloc[0]['azimuth'])
3894
+ elevation_degrees = float(solar_positions.iloc[0]['elevation'])
3895
+
3896
+ return get_global_solar_irradiance_map(
3897
+ voxcity,
3898
+ azimuth_degrees,
3899
+ elevation_degrees,
3900
+ DNI,
3901
+ DHI,
3902
+ show_plot=show_plot,
3903
+ **kwargs
3904
+ )
3905
+
3906
+ else: # cumulative
3907
+ return get_cumulative_global_solar_irradiance(
3908
+ voxcity,
3909
+ df,
3910
+ lon,
3911
+ lat,
3912
+ tz,
3913
+ direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
3914
+ diffuse_irradiance_scaling=diffuse_irradiance_scaling,
3915
+ show_plot=show_plot,
3916
+ **kwargs
3917
+ )
3918
+
3919
+ else: # volumetric
3920
+ # 3D volumetric radiation field
3921
+ volumetric_height = kwargs.pop('volumetric_height', kwargs.pop('view_point_height', 1.5))
3922
+ with_reflections = kwargs.pop('with_reflections', False)
3923
+
3924
+ if temporal_mode == 'instantaneous':
3925
+ calc_time = kwargs.get('calc_time', '01-01 12:00:00')
3926
+ try:
3927
+ calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%S')
3928
+ except ValueError:
3929
+ raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'")
3930
+
3931
+ df_period = df[
3932
+ (df.index.month == calc_dt.month) &
3933
+ (df.index.day == calc_dt.day) &
3934
+ (df.index.hour == calc_dt.hour)
3935
+ ]
3936
+ if df_period.empty:
3937
+ raise ValueError("No EPW data at the specified time.")
3938
+
3939
+ offset_minutes = int(tz * 60)
3940
+ local_tz = pytz.FixedOffset(offset_minutes)
3941
+ df_local = df_period.copy()
3942
+ df_local.index = df_local.index.tz_localize(local_tz)
3943
+ df_utc = df_local.tz_convert(pytz.UTC)
3944
+
3945
+ solar_positions = _get_solar_positions_astral(df_utc.index, lon, lat)
3946
+ DNI = float(df_utc.iloc[0]['DNI']) * direct_normal_irradiance_scaling
3947
+ DHI = float(df_utc.iloc[0]['DHI']) * diffuse_irradiance_scaling
3948
+ azimuth_degrees = float(solar_positions.iloc[0]['azimuth'])
3949
+ elevation_degrees = float(solar_positions.iloc[0]['elevation'])
3950
+
3951
+ return get_volumetric_solar_irradiance_map(
3952
+ voxcity,
3953
+ azimuth_degrees,
3954
+ elevation_degrees,
3955
+ DNI,
3956
+ DHI,
3957
+ volumetric_height=volumetric_height,
3958
+ with_reflections=with_reflections,
3959
+ show_plot=show_plot,
3960
+ **kwargs
3961
+ )
3962
+
3963
+ else: # cumulative
3964
+ return get_cumulative_volumetric_solar_irradiance(
3965
+ voxcity,
3966
+ df,
3967
+ lon,
3968
+ lat,
3969
+ tz,
3970
+ direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
3971
+ diffuse_irradiance_scaling=diffuse_irradiance_scaling,
3972
+ volumetric_height=volumetric_height,
3973
+ with_reflections=with_reflections,
3974
+ show_plot=show_plot,
3975
+ **kwargs
3976
+ )
2589
3977
 
2590
3978
 
2591
3979
  def save_irradiance_mesh(mesh, filepath: str) -> None:
@@ -2777,17 +4165,8 @@ def _get_or_create_gpu_ray_tracer(
2777
4165
  return cache
2778
4166
 
2779
4167
  # Data changed, need to re-upload (but keep fields)
2780
- is_solid = np.zeros((nx, ny, nz), dtype=np.int32)
2781
- lad_array = np.zeros((nx, ny, nz), dtype=np.float32)
2782
-
2783
- for i in range(nx):
2784
- for j in range(ny):
2785
- for k in range(nz):
2786
- val = voxel_data[i, j, k]
2787
- if val == VOXCITY_BUILDING_CODE or val == VOXCITY_GROUND_CODE or val > 0:
2788
- is_solid[i, j, k] = 1
2789
- elif val == VOXCITY_TREE_CODE:
2790
- lad_array[i, j, k] = tree_lad
4168
+ # Use vectorized helper
4169
+ is_solid, lad_array = _convert_voxel_data_to_arrays(voxel_data, tree_lad)
2791
4170
 
2792
4171
  cache.is_solid_field.from_numpy(is_solid)
2793
4172
  cache.lad_field.from_numpy(lad_array)
@@ -2797,18 +4176,8 @@ def _get_or_create_gpu_ray_tracer(
2797
4176
  _compute_topo_gpu(cache.is_solid_field, cache.topo_top_field, nz)
2798
4177
  return cache
2799
4178
 
2800
- # Need to create new cache
2801
- is_solid = np.zeros((nx, ny, nz), dtype=np.int32)
2802
- lad_array = np.zeros((nx, ny, nz), dtype=np.float32)
2803
-
2804
- for i in range(nx):
2805
- for j in range(ny):
2806
- for k in range(nz):
2807
- val = voxel_data[i, j, k]
2808
- if val == VOXCITY_BUILDING_CODE or val == VOXCITY_GROUND_CODE or val > 0:
2809
- is_solid[i, j, k] = 1
2810
- elif val == VOXCITY_TREE_CODE:
2811
- lad_array[i, j, k] = tree_lad
4179
+ # Need to create new cache - use vectorized helper
4180
+ is_solid, lad_array = _convert_voxel_data_to_arrays(voxel_data, tree_lad)
2812
4181
 
2813
4182
  # Create Taichi fields
2814
4183
  is_solid_field = ti.field(dtype=ti.i32, shape=(nx, ny, nz))