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,4322 @@
1
+ """
2
+ VoxCity Integration Module for palm_solar
3
+
4
+ This module provides utilities for loading VoxCity models and converting them
5
+ to palm_solar Domain objects with proper material-specific albedo values.
6
+
7
+ VoxCity models contain:
8
+ - 3D voxel grids with building, tree, and ground information
9
+ - Land cover classification codes
10
+ - DEM (Digital Elevation Model) for terrain
11
+ - Building heights and IDs
12
+ - Tree canopy data
13
+
14
+ This module handles:
15
+ - Loading VoxCity pickle files
16
+ - Converting voxel grids to palm_solar Domain
17
+ - Mapping land cover classes to surface albedo values
18
+ - Creating surface material types for accurate radiation simulation
19
+ """
20
+
21
+ import numpy as np
22
+ from typing import Dict, Optional, Tuple, Union
23
+ from dataclasses import dataclass, field
24
+ from pathlib import Path
25
+
26
+ from .domain import Domain
27
+ from .radiation import RadiationConfig
28
+
29
+
30
+ # VoxCity voxel class codes (from voxcity/generator/voxelizer.py)
31
+ VOXCITY_GROUND_CODE = -1
32
+ VOXCITY_TREE_CODE = -2
33
+ VOXCITY_BUILDING_CODE = -3
34
+
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
+
319
+ @dataclass
320
+ class LandCoverAlbedo:
321
+ """
322
+ Mapping of land cover classes to albedo values.
323
+
324
+ Default values are based on literature values for typical urban materials.
325
+ References:
326
+ - Oke, T.R. (1987) Boundary Layer Climates
327
+ - Sailor, D.J. (1995) Simulated urban climate response to modifications
328
+ """
329
+ # OpenStreetMap / Standard land cover classes (0-indexed after +1 in voxelizer)
330
+ # These map to land_cover_grid values in VoxCity
331
+ bareland: float = 0.20 # Class 0: Bare soil/dirt
332
+ rangeland: float = 0.25 # Class 1: Grassland/rangeland
333
+ shrub: float = 0.20 # Class 2: Shrubland
334
+ agriculture: float = 0.20 # Class 3: Agricultural land
335
+ tree: float = 0.15 # Class 4: Tree cover (ground under canopy)
336
+ wetland: float = 0.12 # Class 5: Wetland
337
+ mangrove: float = 0.12 # Class 6: Mangrove
338
+ water: float = 0.06 # Class 7: Water bodies
339
+ snow_ice: float = 0.80 # Class 8: Snow and ice
340
+ developed: float = 0.20 # Class 9: Developed/paved areas
341
+ road: float = 0.12 # Class 10: Roads (asphalt)
342
+ building_ground: float = 0.20 # Class 11: Building footprint area
343
+
344
+ # Building surfaces (walls and roofs)
345
+ building_wall: float = 0.30 # Vertical building surfaces
346
+ building_roof: float = 0.25 # Building rooftops
347
+
348
+ # Vegetation
349
+ leaf: float = 0.15 # Plant canopy (PALM default)
350
+
351
+ def get_land_cover_albedo(self, class_code: int) -> float:
352
+ """
353
+ Get albedo value for a land cover class code.
354
+
355
+ Args:
356
+ class_code: Land cover class code (0-11 for standard classes)
357
+
358
+ Returns:
359
+ Albedo value for the class
360
+ """
361
+ albedo_map = {
362
+ 0: self.bareland,
363
+ 1: self.rangeland,
364
+ 2: self.shrub,
365
+ 3: self.agriculture,
366
+ 4: self.tree,
367
+ 5: self.wetland,
368
+ 6: self.mangrove,
369
+ 7: self.water,
370
+ 8: self.snow_ice,
371
+ 9: self.developed,
372
+ 10: self.road,
373
+ 11: self.building_ground,
374
+ }
375
+ return albedo_map.get(class_code, self.developed) # Default to developed
376
+
377
+
378
+ @dataclass
379
+ class VoxCityDomainResult:
380
+ """Result of VoxCity to palm_solar conversion."""
381
+ domain: Domain
382
+ surface_land_cover: Optional[np.ndarray] = None # Land cover code per surface
383
+ surface_material_type: Optional[np.ndarray] = None # 0=ground, 1=wall, 2=roof
384
+
385
+
386
+ # =============================================================================
387
+ # RadiationModel Caching for Cumulative Calculations
388
+ # =============================================================================
389
+ # The SVF and CSF matrices are geometry-dependent and expensive to compute.
390
+ # We cache the RadiationModel so it can be reused across multiple solar positions.
391
+
392
+ @dataclass
393
+ class _CachedRadiationModel:
394
+ """Cached RadiationModel with associated metadata."""
395
+ model: object # RadiationModel instance
396
+ valid_ground: np.ndarray # Valid ground mask
397
+ ground_k: np.ndarray # Ground level k indices
398
+ voxcity_shape: Tuple[int, int, int] # Shape of voxel data for cache validation
399
+ meshsize: float # Meshsize for cache validation
400
+ n_reflection_steps: int # Number of reflection steps used
401
+ # Performance optimization: pre-computed surface-to-grid mapping
402
+ grid_indices: Optional[np.ndarray] = None # (N, 2) array of (i, j) grid coords for valid ground surfaces
403
+ surface_indices: Optional[np.ndarray] = None # (N,) array of surface indices matching grid_indices
404
+ # Cached numpy arrays (positions/directions don't change)
405
+ positions_np: Optional[np.ndarray] = None # Cached positions array
406
+ directions_np: Optional[np.ndarray] = None # Cached directions array
407
+
408
+
409
+ # Module-level cache for RadiationModel
410
+ _radiation_model_cache: Optional[_CachedRadiationModel] = None
411
+
412
+ # Module-level cache for GPU ray tracer (forward declaration, actual class defined later)
413
+ _gpu_ray_tracer_cache = None
414
+
415
+
416
+ def _get_or_create_radiation_model(
417
+ voxcity,
418
+ n_reflection_steps: int = 2,
419
+ progress_report: bool = False,
420
+ **kwargs
421
+ ) -> Tuple[object, np.ndarray, np.ndarray]:
422
+ """
423
+ Get cached RadiationModel or create a new one if cache is invalid.
424
+
425
+ The SVF and CSF matrices are O(n²) to compute and only depend on geometry,
426
+ not solar position. This function caches the model for reuse.
427
+
428
+ Args:
429
+ voxcity: VoxCity object
430
+ n_reflection_steps: Number of reflection bounces
431
+ progress_report: Print progress messages
432
+ **kwargs: Additional RadiationConfig parameters
433
+
434
+ Returns:
435
+ Tuple of (RadiationModel, valid_ground array, ground_k array)
436
+ """
437
+ global _radiation_model_cache
438
+
439
+ from .radiation import RadiationModel, RadiationConfig
440
+ from .domain import IUP
441
+
442
+ voxel_data = voxcity.voxels.classes
443
+ meshsize = voxcity.voxels.meta.meshsize
444
+ ni, nj, nk = voxel_data.shape
445
+
446
+ # Check if cache is valid
447
+ cache_valid = False
448
+ if _radiation_model_cache is not None:
449
+ cache = _radiation_model_cache
450
+ if (cache.voxcity_shape == voxel_data.shape and
451
+ cache.meshsize == meshsize and
452
+ cache.n_reflection_steps == n_reflection_steps):
453
+ cache_valid = True
454
+ if progress_report:
455
+ print("Using cached RadiationModel (SVF/CSF already computed)")
456
+
457
+ if cache_valid:
458
+ return (_radiation_model_cache.model,
459
+ _radiation_model_cache.valid_ground,
460
+ _radiation_model_cache.ground_k)
461
+
462
+ # Need to create new model
463
+ if progress_report:
464
+ print("Creating new RadiationModel (computing SVF/CSF matrices)...")
465
+
466
+ # Get location using helper function
467
+ origin_lat, origin_lon = _get_location_from_voxcity(voxcity)
468
+
469
+ # Create domain
470
+ domain = Domain(
471
+ nx=ni, ny=nj, nz=nk,
472
+ dx=meshsize, dy=meshsize, dz=meshsize,
473
+ origin_lat=origin_lat,
474
+ origin_lon=origin_lon
475
+ )
476
+
477
+ # Convert VoxCity voxel data to domain arrays using vectorized helper
478
+ default_lad = kwargs.get('default_lad', 1.0)
479
+ is_solid_np, lad_np = _convert_voxel_data_to_arrays(voxel_data, default_lad)
480
+
481
+ # Compute valid ground cells using vectorized helper
482
+ valid_ground, _ = _compute_valid_ground_vectorized(voxel_data)
483
+
484
+ # Set domain arrays
485
+ _set_solid_array(domain, is_solid_np)
486
+ domain.set_lad_from_array(lad_np)
487
+ _update_topo_from_solid(domain)
488
+
489
+ # Create RadiationModel
490
+ config = RadiationConfig(
491
+ n_reflection_steps=n_reflection_steps,
492
+ n_azimuth=kwargs.get('n_azimuth', 40),
493
+ n_elevation=kwargs.get('n_elevation', 10)
494
+ )
495
+
496
+ model = RadiationModel(domain, config)
497
+
498
+ # Compute SVF (this is the expensive part)
499
+ if progress_report:
500
+ print("Computing Sky View Factors...")
501
+ model.compute_svf()
502
+
503
+ # Pre-compute ground_k for surface mapping
504
+ n_surfaces = model.surfaces.count
505
+ positions = model.surfaces.position.to_numpy()[:n_surfaces]
506
+ directions = model.surfaces.direction.to_numpy()[:n_surfaces]
507
+
508
+ ground_k = np.full((ni, nj), -1, dtype=np.int32)
509
+ for idx in range(n_surfaces):
510
+ pos_i, pos_j, k = positions[idx]
511
+ direction = directions[idx]
512
+ if direction == IUP:
513
+ ii, jj = int(pos_i), int(pos_j)
514
+ if 0 <= ii < ni and 0 <= jj < nj:
515
+ if not valid_ground[ii, jj]:
516
+ continue
517
+ if ground_k[ii, jj] < 0 or k < ground_k[ii, jj]:
518
+ ground_k[ii, jj] = int(k)
519
+
520
+ # Pre-compute surface-to-grid mapping for fast vectorized extraction
521
+ # This maps which surface indices correspond to which grid cells
522
+ if progress_report:
523
+ print("Pre-computing surface-to-grid mapping...")
524
+ surface_to_grid_map = {} # (i, j) -> surface_idx
525
+ for idx in range(n_surfaces):
526
+ direction = directions[idx]
527
+ if direction == IUP:
528
+ pi = int(positions[idx, 0])
529
+ pj = int(positions[idx, 1])
530
+ pk = int(positions[idx, 2])
531
+ if 0 <= pi < ni and 0 <= pj < nj:
532
+ if valid_ground[pi, pj] and pk == ground_k[pi, pj]:
533
+ surface_to_grid_map[(pi, pj)] = idx
534
+
535
+ # Convert to arrays for vectorized access
536
+ if surface_to_grid_map:
537
+ grid_indices = np.array(list(surface_to_grid_map.keys()), dtype=np.int32)
538
+ surface_indices = np.array(list(surface_to_grid_map.values()), dtype=np.int32)
539
+ else:
540
+ grid_indices = np.empty((0, 2), dtype=np.int32)
541
+ surface_indices = np.empty((0,), dtype=np.int32)
542
+
543
+ # Cache the model with pre-computed mappings
544
+ _radiation_model_cache = _CachedRadiationModel(
545
+ model=model,
546
+ valid_ground=valid_ground,
547
+ ground_k=ground_k,
548
+ voxcity_shape=voxel_data.shape,
549
+ meshsize=meshsize,
550
+ n_reflection_steps=n_reflection_steps,
551
+ grid_indices=grid_indices,
552
+ surface_indices=surface_indices,
553
+ positions_np=positions,
554
+ directions_np=directions
555
+ )
556
+
557
+ if progress_report:
558
+ print(f"RadiationModel cached. Valid ground cells: {np.sum(valid_ground)}, mapped surfaces: {len(surface_indices)}")
559
+
560
+ return model, valid_ground, ground_k
561
+
562
+
563
+ def clear_radiation_model_cache():
564
+ """Clear the cached RadiationModel to free memory or force recomputation."""
565
+ global _radiation_model_cache
566
+ _radiation_model_cache = None
567
+
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
+
690
+ def clear_gpu_ray_tracer_cache():
691
+ """Clear the cached GPU ray tracer fields to free memory or force recomputation."""
692
+ global _gpu_ray_tracer_cache
693
+ _gpu_ray_tracer_cache = None
694
+
695
+
696
+ def clear_all_caches():
697
+ """Clear all GPU caches (RadiationModel, Building RadiationModel, GPU ray tracer)."""
698
+ global _radiation_model_cache, _building_radiation_model_cache, _gpu_ray_tracer_cache
699
+ _radiation_model_cache = None
700
+ _building_radiation_model_cache = None
701
+ _gpu_ray_tracer_cache = None
702
+
703
+
704
+ # =============================================================================
705
+ # Building RadiationModel Caching
706
+ # =============================================================================
707
+ # Separate cache for building solar irradiance calculations
708
+
709
+ @dataclass
710
+ class _CachedBuildingRadiationModel:
711
+ """Cached RadiationModel for building surface calculations."""
712
+ model: object # RadiationModel instance
713
+ voxcity_shape: Tuple[int, int, int] # Shape of voxel data for cache validation
714
+ meshsize: float # Meshsize for cache validation
715
+ n_reflection_steps: int # Number of reflection steps used
716
+ is_building_surf: np.ndarray # Boolean mask for building surfaces
717
+ building_svf_mesh: object # Building mesh (can be None)
718
+ # Performance optimization: pre-computed mesh face to surface mapping
719
+ bldg_indices: Optional[np.ndarray] = None # Indices of building surfaces
720
+ mesh_to_surface_idx: Optional[np.ndarray] = None # Direct mapping: mesh face -> surface index
721
+ # Cached mesh geometry to avoid recomputing each call
722
+ mesh_face_centers: Optional[np.ndarray] = None # Pre-computed triangles_center
723
+ mesh_face_normals: Optional[np.ndarray] = None # Pre-computed face_normals
724
+ boundary_mask: Optional[np.ndarray] = None # Pre-computed boundary vertical face mask
725
+ # Cached building mesh (expensive to create, ~2.4s)
726
+ cached_building_mesh: object = None # Pre-computed building mesh from create_voxel_mesh
727
+
728
+
729
+ # Module-level cache for Building RadiationModel
730
+ _building_radiation_model_cache: Optional[_CachedBuildingRadiationModel] = None
731
+
732
+
733
+ def _get_or_create_building_radiation_model(
734
+ voxcity,
735
+ n_reflection_steps: int = 2,
736
+ progress_report: bool = False,
737
+ building_class_id: int = -3,
738
+ **kwargs
739
+ ) -> Tuple[object, np.ndarray]:
740
+ """
741
+ Get cached RadiationModel for building surfaces or create a new one.
742
+
743
+ Args:
744
+ voxcity: VoxCity object
745
+ n_reflection_steps: Number of reflection bounces
746
+ progress_report: Print progress messages
747
+ building_class_id: Building voxel class code
748
+ **kwargs: Additional RadiationConfig parameters
749
+
750
+ Returns:
751
+ Tuple of (RadiationModel, is_building_surf boolean array)
752
+ """
753
+ global _building_radiation_model_cache
754
+
755
+ from .radiation import RadiationModel, RadiationConfig
756
+
757
+ voxel_data = voxcity.voxels.classes
758
+ meshsize = voxcity.voxels.meta.meshsize
759
+ ny_vc, nx_vc, nz = voxel_data.shape
760
+
761
+ # Check if cache is valid
762
+ # A cached model with reflections (n_reflection_steps > 0) can be reused for non-reflection calls
763
+ # But a cached model without reflections cannot be used for reflection calls
764
+ cache_valid = False
765
+ if _building_radiation_model_cache is not None:
766
+ cache = _building_radiation_model_cache
767
+ if (cache.voxcity_shape == voxel_data.shape and
768
+ cache.meshsize == meshsize):
769
+ # Cache is valid if:
770
+ # 1. We don't need reflections (n_reflection_steps=0), OR
771
+ # 2. Cached model has reflections enabled (can handle any n_reflection_steps)
772
+ if n_reflection_steps == 0 or cache.n_reflection_steps > 0:
773
+ cache_valid = True
774
+ if progress_report:
775
+ print("Using cached Building RadiationModel (SVF/CSF already computed)")
776
+
777
+ if cache_valid:
778
+ return (_building_radiation_model_cache.model,
779
+ _building_radiation_model_cache.is_building_surf)
780
+
781
+ # Need to create new model
782
+ if progress_report:
783
+ print("Creating new Building RadiationModel (computing SVF/CSF matrices)...")
784
+
785
+ # Get location using helper function
786
+ origin_lat, origin_lon = _get_location_from_voxcity(voxcity)
787
+
788
+ # Create domain - consistent with ground-level model
789
+ # VoxCity uses [row, col, z] = [i, j, k] convention
790
+ # We create domain with nx=ny_vc, ny=nx_vc to match the palm_solar convention
791
+ # but keep the same indexing as the ground model for consistency
792
+ ni, nj, nk = ny_vc, nx_vc, nz # Rename for clarity (matches ground model naming)
793
+
794
+ domain = Domain(
795
+ nx=ni, ny=nj, nz=nk,
796
+ dx=meshsize, dy=meshsize, dz=meshsize,
797
+ origin_lat=origin_lat,
798
+ origin_lon=origin_lon
799
+ )
800
+
801
+ # Convert VoxCity voxel data to domain arrays using vectorized helper
802
+ default_lad = kwargs.get('default_lad', 2.0)
803
+ is_solid_np, lad_np = _convert_voxel_data_to_arrays(voxel_data, default_lad)
804
+
805
+ # Set domain arrays
806
+ _set_solid_array(domain, is_solid_np)
807
+ domain.set_lad_from_array(lad_np)
808
+ _update_topo_from_solid(domain)
809
+
810
+ # When n_reflection_steps=0, disable surface reflections to skip expensive SVF matrix computation
811
+ surface_reflections = n_reflection_steps > 0
812
+
813
+ config = RadiationConfig(
814
+ n_reflection_steps=n_reflection_steps,
815
+ n_azimuth=40,
816
+ n_elevation=10,
817
+ surface_reflections=surface_reflections, # Disable when no reflections needed
818
+ cache_svf_matrix=surface_reflections, # Skip SVF matrix when reflections disabled
819
+ )
820
+
821
+ model = RadiationModel(domain, config)
822
+
823
+ # Compute SVF (expensive! but only for sky view, not surface-to-surface when disabled)
824
+ if progress_report:
825
+ print("Computing Sky View Factors...")
826
+ model.compute_svf()
827
+
828
+ # Pre-compute building surface mask
829
+ n_surfaces = model.surfaces.count
830
+ surf_positions_all = model.surfaces.position.to_numpy()[:n_surfaces]
831
+
832
+ is_building_surf = np.zeros(n_surfaces, dtype=bool)
833
+ for s_idx in range(n_surfaces):
834
+ i_idx, j_idx, z_idx = surf_positions_all[s_idx]
835
+ i, j, z = int(i_idx), int(j_idx), int(z_idx)
836
+ if 0 <= i < ni and 0 <= j < nj and 0 <= z < nk:
837
+ if voxel_data[i, j, z] == building_class_id:
838
+ is_building_surf[s_idx] = True
839
+
840
+ if progress_report:
841
+ print(f"Building RadiationModel cached. Building surfaces: {np.sum(is_building_surf)}/{n_surfaces}")
842
+
843
+ # Pre-compute bldg_indices for caching
844
+ bldg_indices = np.where(is_building_surf)[0]
845
+
846
+ # Cache the model
847
+ _building_radiation_model_cache = _CachedBuildingRadiationModel(
848
+ model=model,
849
+ voxcity_shape=voxel_data.shape,
850
+ meshsize=meshsize,
851
+ n_reflection_steps=n_reflection_steps,
852
+ is_building_surf=is_building_surf,
853
+ building_svf_mesh=None,
854
+ bldg_indices=bldg_indices,
855
+ mesh_to_surface_idx=None # Will be computed on first use with a specific mesh
856
+ )
857
+
858
+ return model, is_building_surf
859
+
860
+
861
+ def clear_building_radiation_model_cache():
862
+ """Clear the cached Building RadiationModel to free memory."""
863
+ global _building_radiation_model_cache
864
+ _building_radiation_model_cache = None
865
+
866
+
867
+ def clear_all_radiation_caches():
868
+ """Clear all cached RadiationModels to free GPU memory."""
869
+ clear_radiation_model_cache()
870
+ clear_building_radiation_model_cache()
871
+ land_cover_albedo: Optional[LandCoverAlbedo] = None
872
+
873
+
874
+ def load_voxcity(filepath: Union[str, Path]):
875
+ """
876
+ Load VoxCity data from pickle file.
877
+
878
+ Attempts to use the voxcity package if available, otherwise
879
+ loads as raw pickle with fallback handling.
880
+
881
+ Args:
882
+ filepath: Path to the VoxCity pickle file
883
+
884
+ Returns:
885
+ VoxCity object or dict containing the model data
886
+ """
887
+ import pickle
888
+
889
+ filepath = Path(filepath)
890
+
891
+ try:
892
+ # Try using voxcity package loader
893
+ from voxcity.generator.io import load_voxcity as voxcity_load
894
+ return voxcity_load(str(filepath))
895
+ except ImportError:
896
+ # Fallback: load as raw pickle
897
+ with open(filepath, 'rb') as f:
898
+ data = pickle.load(f)
899
+
900
+ # Handle wrapper dict format (has 'voxcity' key)
901
+ if isinstance(data, dict) and 'voxcity' in data:
902
+ return data['voxcity']
903
+
904
+ return data
905
+
906
+
907
+ def convert_voxcity_to_domain(
908
+ voxcity_data,
909
+ default_lad: float = 2.0,
910
+ land_cover_albedo: Optional[LandCoverAlbedo] = None,
911
+ origin_lat: Optional[float] = None,
912
+ origin_lon: Optional[float] = None
913
+ ) -> VoxCityDomainResult:
914
+ """
915
+ Convert VoxCity voxel grid to palm_solar Domain with material properties.
916
+
917
+ This function:
918
+ 1. Extracts voxel grid, dimensions, and location from VoxCity data
919
+ 2. Creates a palm_solar Domain with solid cells and LAD
920
+ 3. Tracks land cover information for surface albedo assignment
921
+
922
+ Args:
923
+ voxcity_data: VoxCity object or dict from load_voxcity()
924
+ default_lad: Default Leaf Area Density for tree voxels (m²/m³)
925
+ land_cover_albedo: Custom land cover to albedo mapping
926
+ origin_lat: Override latitude (degrees)
927
+ origin_lon: Override longitude (degrees)
928
+
929
+ Returns:
930
+ VoxCityDomainResult with Domain and material information
931
+ """
932
+ if land_cover_albedo is None:
933
+ land_cover_albedo = LandCoverAlbedo()
934
+
935
+ # Extract data from VoxCity object or dict
936
+ if hasattr(voxcity_data, 'voxels'):
937
+ # New VoxCity dataclass format
938
+ voxel_grid = voxcity_data.voxels.classes
939
+ meshsize = voxcity_data.voxels.meta.meshsize
940
+ land_cover_grid = voxcity_data.land_cover.classes
941
+ dem_grid = voxcity_data.dem.elevation
942
+ extras = getattr(voxcity_data, 'extras', {})
943
+ rectangle_vertices = extras.get('rectangle_vertices', None)
944
+ else:
945
+ # Legacy dict format
946
+ voxel_grid = voxcity_data['voxcity_grid']
947
+ meshsize = voxcity_data['meshsize']
948
+ land_cover_grid = voxcity_data.get('land_cover_grid', None)
949
+ dem_grid = voxcity_data.get('dem_grid', None)
950
+ rectangle_vertices = voxcity_data.get('rectangle_vertices', None)
951
+
952
+ # Get grid dimensions (VoxCity is [row, col, z] = [y, x, z])
953
+ ny, nx, nz = voxel_grid.shape
954
+
955
+ # Use meshsize as voxel size
956
+ dx = dy = dz = float(meshsize)
957
+
958
+ # Determine location
959
+ if origin_lat is None or origin_lon is None:
960
+ if rectangle_vertices is not None and len(rectangle_vertices) > 0:
961
+ lons = [v[0] for v in rectangle_vertices]
962
+ lats = [v[1] for v in rectangle_vertices]
963
+ if origin_lon is None:
964
+ origin_lon = np.mean(lons)
965
+ if origin_lat is None:
966
+ origin_lat = np.mean(lats)
967
+ else:
968
+ # Default to Singapore
969
+ if origin_lat is None:
970
+ origin_lat = 1.35
971
+ if origin_lon is None:
972
+ origin_lon = 103.82
973
+
974
+ print(f"VoxCity grid shape: ({ny}, {nx}, {nz})")
975
+ print(f"Voxel size: {dx} m")
976
+ print(f"Domain size: {nx*dx:.1f} x {ny*dy:.1f} x {nz*dz:.1f} m")
977
+ print(f"Location: lat={origin_lat:.4f}, lon={origin_lon:.4f}")
978
+
979
+ # Create palm_solar Domain
980
+ domain = Domain(
981
+ nx=nx, ny=ny, nz=nz,
982
+ dx=dx, dy=dy, dz=dz,
983
+ origin=(0.0, 0.0, 0.0),
984
+ origin_lat=origin_lat,
985
+ origin_lon=origin_lon
986
+ )
987
+
988
+ # Create arrays for conversion
989
+ is_solid_np = np.zeros((nx, ny, nz), dtype=np.int32)
990
+ lad_np = np.zeros((nx, ny, nz), dtype=np.float32)
991
+
992
+ # Surface land cover tracking (indexed by grid position)
993
+ # This will store the land cover code for ground-level surfaces
994
+ surface_land_cover_grid = np.full((nx, ny), -1, dtype=np.int32)
995
+
996
+ # Convert from VoxCity [row, col, z] to palm_solar [x, y, z]
997
+ for row in range(ny):
998
+ for col in range(nx):
999
+ x_idx = col
1000
+ y_idx = row
1001
+
1002
+ # Get land cover for this column (from ground surface)
1003
+ if land_cover_grid is not None:
1004
+ # Land cover grid is [row, col], values are class codes
1005
+ lc_val = land_cover_grid[row, col]
1006
+ if lc_val > 0:
1007
+ # VoxCity adds +1 to land cover codes, so subtract 1
1008
+ surface_land_cover_grid[x_idx, y_idx] = int(lc_val) - 1
1009
+ else:
1010
+ surface_land_cover_grid[x_idx, y_idx] = 9 # Default: developed
1011
+
1012
+ for z in range(nz):
1013
+ voxel_val = voxel_grid[row, col, z]
1014
+
1015
+ if voxel_val == VOXCITY_BUILDING_CODE:
1016
+ is_solid_np[x_idx, y_idx, z] = 1
1017
+ elif voxel_val == VOXCITY_GROUND_CODE:
1018
+ is_solid_np[x_idx, y_idx, z] = 1
1019
+ elif voxel_val == VOXCITY_TREE_CODE:
1020
+ lad_np[x_idx, y_idx, z] = default_lad
1021
+ elif voxel_val > 0:
1022
+ # Positive values are land cover codes on ground
1023
+ is_solid_np[x_idx, y_idx, z] = 1
1024
+
1025
+ # Set domain arrays
1026
+ _set_solid_array(domain, is_solid_np)
1027
+ domain.set_lad_from_array(lad_np)
1028
+ _update_topo_from_solid(domain)
1029
+
1030
+ # Count statistics
1031
+ solid_count = is_solid_np.sum()
1032
+ lad_count = (lad_np > 0).sum()
1033
+ print(f"Solid voxels: {solid_count:,}")
1034
+ print(f"Vegetation voxels (LAD > 0): {lad_count:,}")
1035
+
1036
+ return VoxCityDomainResult(
1037
+ domain=domain,
1038
+ surface_land_cover=surface_land_cover_grid,
1039
+ land_cover_albedo=land_cover_albedo
1040
+ )
1041
+
1042
+
1043
+ def apply_voxcity_albedo(
1044
+ model,
1045
+ voxcity_result: VoxCityDomainResult
1046
+ ) -> None:
1047
+ """
1048
+ Apply VoxCity land cover-based albedo values to radiation model surfaces.
1049
+
1050
+ This function sets surface albedo values based on:
1051
+ - Land cover class for ground surfaces
1052
+ - Building wall/roof albedo for building surfaces
1053
+
1054
+ Args:
1055
+ model: RadiationModel instance (after surface extraction)
1056
+ voxcity_result: Result from convert_voxcity_to_domain()
1057
+ """
1058
+ import taichi as ti
1059
+ from ..init_taichi import ensure_initialized
1060
+ ensure_initialized()
1061
+
1062
+ if voxcity_result.surface_land_cover is None:
1063
+ print("Warning: No land cover data available, using default albedos")
1064
+ return
1065
+
1066
+ domain = voxcity_result.domain
1067
+ lc_grid = voxcity_result.surface_land_cover
1068
+ lc_albedo = voxcity_result.land_cover_albedo
1069
+
1070
+ # Get surface data
1071
+ n_surfaces = model.surfaces.n_surfaces[None]
1072
+ max_surfaces = model.surfaces.max_surfaces
1073
+ positions = model.surfaces.position.to_numpy()[:n_surfaces]
1074
+ directions = model.surfaces.direction.to_numpy()[:n_surfaces]
1075
+
1076
+ # Create albedo array with full size (must match Taichi field shape)
1077
+ albedo_values = np.zeros(max_surfaces, dtype=np.float32)
1078
+
1079
+ # Direction codes
1080
+ IUP = 0
1081
+ IDOWN = 1
1082
+
1083
+ for idx in range(n_surfaces):
1084
+ i, j, k = positions[idx]
1085
+ direction = directions[idx]
1086
+
1087
+ if direction == IUP: # Upward facing
1088
+ if k == 0 or k == 1:
1089
+ # Ground level - use land cover albedo
1090
+ lc_code = lc_grid[i, j]
1091
+ if lc_code >= 0:
1092
+ albedo_values[idx] = lc_albedo.get_land_cover_albedo(lc_code)
1093
+ else:
1094
+ albedo_values[idx] = lc_albedo.developed
1095
+ else:
1096
+ # Roof
1097
+ albedo_values[idx] = lc_albedo.building_roof
1098
+ elif direction == IDOWN: # Downward facing
1099
+ albedo_values[idx] = lc_albedo.building_wall
1100
+ else: # Walls (N, S, E, W)
1101
+ albedo_values[idx] = lc_albedo.building_wall
1102
+
1103
+ # Apply albedo values to surfaces
1104
+ model.surfaces.albedo.from_numpy(albedo_values)
1105
+
1106
+ # Print summary
1107
+ unique_albedos = np.unique(albedo_values[:n_surfaces])
1108
+ print(f"Applied {len(unique_albedos)} unique albedo values to {n_surfaces} surfaces")
1109
+
1110
+
1111
+ def _set_solid_array(domain: Domain, solid_array: np.ndarray) -> None:
1112
+ """Set domain solid cells from numpy array."""
1113
+ import taichi as ti
1114
+ from ..init_taichi import ensure_initialized
1115
+ ensure_initialized()
1116
+
1117
+ @ti.kernel
1118
+ def _set_solid_kernel(domain: ti.template(), solid: ti.types.ndarray()):
1119
+ for i, j, k in domain.is_solid:
1120
+ domain.is_solid[i, j, k] = solid[i, j, k]
1121
+
1122
+ _set_solid_kernel(domain, solid_array)
1123
+
1124
+
1125
+ def _update_topo_from_solid(domain: Domain) -> None:
1126
+ """Update topography field from solid array."""
1127
+ import taichi as ti
1128
+ from ..init_taichi import ensure_initialized
1129
+ ensure_initialized()
1130
+
1131
+ @ti.kernel
1132
+ def _update_topo_kernel(domain: ti.template()):
1133
+ for i, j in domain.topo_top:
1134
+ max_k = 0
1135
+ for k in range(domain.nz):
1136
+ if domain.is_solid[i, j, k] == 1:
1137
+ max_k = k
1138
+ domain.topo_top[i, j] = max_k
1139
+
1140
+ _update_topo_kernel(domain)
1141
+
1142
+
1143
+ def create_radiation_config_for_voxcity(
1144
+ land_cover_albedo: Optional[LandCoverAlbedo] = None,
1145
+ **kwargs
1146
+ ) -> RadiationConfig:
1147
+ """
1148
+ Create a RadiationConfig suitable for VoxCity simulations.
1149
+
1150
+ This sets appropriate default values for urban environments.
1151
+
1152
+ Args:
1153
+ land_cover_albedo: Land cover albedo mapping (for reference)
1154
+ **kwargs: Additional RadiationConfig parameters
1155
+
1156
+ Returns:
1157
+ RadiationConfig instance
1158
+ """
1159
+ if land_cover_albedo is None:
1160
+ land_cover_albedo = LandCoverAlbedo()
1161
+
1162
+ # Set defaults suitable for urban environments
1163
+ defaults = {
1164
+ 'albedo_ground': land_cover_albedo.developed,
1165
+ 'albedo_wall': land_cover_albedo.building_wall,
1166
+ 'albedo_roof': land_cover_albedo.building_roof,
1167
+ 'albedo_leaf': land_cover_albedo.leaf,
1168
+ 'n_azimuth': 40, # Reduced for faster computation
1169
+ 'n_elevation': 10,
1170
+ 'n_reflection_steps': 2,
1171
+ }
1172
+
1173
+ # Override with user-provided values
1174
+ defaults.update(kwargs)
1175
+
1176
+ return RadiationConfig(**defaults)
1177
+
1178
+
1179
+ def _compute_ground_irradiance_with_reflections(
1180
+ voxcity,
1181
+ azimuth_degrees_ori: float,
1182
+ elevation_degrees: float,
1183
+ direct_normal_irradiance: float,
1184
+ diffuse_irradiance: float,
1185
+ view_point_height: float = 1.5,
1186
+ n_reflection_steps: int = 2,
1187
+ progress_report: bool = False,
1188
+ **kwargs
1189
+ ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
1190
+ """
1191
+ Compute ground-level irradiance using full RadiationModel with reflections.
1192
+
1193
+ Uses a cached RadiationModel to avoid recomputing SVF/CSF matrices for each
1194
+ solar position. The geometry-dependent matrices are computed once and reused.
1195
+
1196
+ Note: The diffuse component includes sky diffuse + multi-bounce surface reflections +
1197
+ canopy scattering, as computed by the RadiationModel.
1198
+
1199
+ Args:
1200
+ voxcity: VoxCity object
1201
+ azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
1202
+ elevation_degrees: Solar elevation in degrees above horizon
1203
+ direct_normal_irradiance: DNI in W/m²
1204
+ diffuse_irradiance: DHI in W/m²
1205
+ view_point_height: Observer height above ground (default: 1.5)
1206
+ n_reflection_steps: Number of reflection bounces (default: 2)
1207
+ progress_report: Print progress (default: False)
1208
+ **kwargs: Additional parameters
1209
+
1210
+ Returns:
1211
+ Tuple of (direct_map, diffuse_map, reflected_map) as 2D numpy arrays
1212
+ """
1213
+ from .domain import IUP
1214
+
1215
+ voxel_data = voxcity.voxels.classes
1216
+ ni, nj, nk = voxel_data.shape
1217
+
1218
+ # Remove parameters that we pass explicitly to avoid duplicates
1219
+ filtered_kwargs = {k: v for k, v in kwargs.items()
1220
+ if k not in ('n_reflection_steps', 'progress_report', 'view_point_height')}
1221
+
1222
+ # Get or create cached RadiationModel (SVF/CSF only computed once)
1223
+ model, valid_ground, ground_k = _get_or_create_radiation_model(
1224
+ voxcity,
1225
+ n_reflection_steps=n_reflection_steps,
1226
+ progress_report=progress_report,
1227
+ **filtered_kwargs
1228
+ )
1229
+
1230
+ # Set solar position for this timestep
1231
+ azimuth_degrees = 180 - azimuth_degrees_ori
1232
+ azimuth_radians = np.deg2rad(azimuth_degrees)
1233
+ elevation_radians = np.deg2rad(elevation_degrees)
1234
+
1235
+ sun_dir_x = np.cos(elevation_radians) * np.cos(azimuth_radians)
1236
+ sun_dir_y = np.cos(elevation_radians) * np.sin(azimuth_radians)
1237
+ sun_dir_z = np.sin(elevation_radians)
1238
+
1239
+ # Set sun direction and cos_zenith directly on the SolarCalculator fields
1240
+ model.solar_calc.sun_direction[None] = (sun_dir_x, sun_dir_y, sun_dir_z)
1241
+ model.solar_calc.cos_zenith[None] = np.sin(elevation_radians) # cos(zenith) = sin(elevation)
1242
+ model.solar_calc.sun_up[None] = 1 if elevation_degrees > 0 else 0
1243
+
1244
+ # Compute shortwave radiation (uses cached SVF/CSF matrices)
1245
+ model.compute_shortwave_radiation(
1246
+ sw_direct=direct_normal_irradiance,
1247
+ sw_diffuse=diffuse_irradiance
1248
+ )
1249
+
1250
+ # Extract surface irradiance using cached mapping for vectorized extraction
1251
+ # This is much faster than iterating through all surfaces
1252
+ n_surfaces = model.surfaces.count
1253
+
1254
+ # Initialize output arrays
1255
+ direct_map = np.full((ni, nj), np.nan, dtype=np.float32)
1256
+ diffuse_map = np.full((ni, nj), np.nan, dtype=np.float32)
1257
+ reflected_map = np.zeros((ni, nj), dtype=np.float32)
1258
+
1259
+ # Use pre-computed surface-to-grid mapping if available (from cache)
1260
+ if (_radiation_model_cache is not None and
1261
+ _radiation_model_cache.grid_indices is not None and
1262
+ len(_radiation_model_cache.grid_indices) > 0):
1263
+
1264
+ grid_indices = _radiation_model_cache.grid_indices
1265
+ surface_indices = _radiation_model_cache.surface_indices
1266
+
1267
+ # Extract only the irradiance values we need (vectorized)
1268
+ sw_in_direct = model.surfaces.sw_in_direct.to_numpy()
1269
+ sw_in_diffuse = model.surfaces.sw_in_diffuse.to_numpy()
1270
+
1271
+ # Vectorized assignment using pre-computed indices
1272
+ direct_map[grid_indices[:, 0], grid_indices[:, 1]] = sw_in_direct[surface_indices]
1273
+ diffuse_map[grid_indices[:, 0], grid_indices[:, 1]] = sw_in_diffuse[surface_indices]
1274
+ else:
1275
+ # Fallback to original loop if no cached mapping
1276
+ from .domain import IUP
1277
+ positions = model.surfaces.position.to_numpy()[:n_surfaces]
1278
+ directions = model.surfaces.direction.to_numpy()[:n_surfaces]
1279
+ sw_in_direct = model.surfaces.sw_in_direct.to_numpy()[:n_surfaces]
1280
+ sw_in_diffuse = model.surfaces.sw_in_diffuse.to_numpy()[:n_surfaces]
1281
+
1282
+ for idx in range(n_surfaces):
1283
+ pos_i, pos_j, k = positions[idx]
1284
+ direction = directions[idx]
1285
+
1286
+ if direction == IUP:
1287
+ ii, jj = int(pos_i), int(pos_j)
1288
+ if 0 <= ii < ni and 0 <= jj < nj:
1289
+ if not valid_ground[ii, jj]:
1290
+ continue
1291
+ if int(k) == ground_k[ii, jj]:
1292
+ if np.isnan(direct_map[ii, jj]):
1293
+ direct_map[ii, jj] = sw_in_direct[idx]
1294
+ diffuse_map[ii, jj] = sw_in_diffuse[idx]
1295
+
1296
+ # Flip to match VoxCity coordinate system
1297
+ direct_map = np.flipud(direct_map)
1298
+ diffuse_map = np.flipud(diffuse_map)
1299
+ reflected_map = np.flipud(reflected_map)
1300
+
1301
+ return direct_map, diffuse_map, reflected_map
1302
+
1303
+
1304
+ # =============================================================================
1305
+ # VoxCity API-Compatible Solar Irradiance Functions
1306
+ # =============================================================================
1307
+ # These functions match the voxcity.simulator.solar API signatures for
1308
+ # drop-in replacement with GPU acceleration.
1309
+
1310
+ def get_direct_solar_irradiance_map(
1311
+ voxcity,
1312
+ azimuth_degrees_ori: float,
1313
+ elevation_degrees: float,
1314
+ direct_normal_irradiance: float,
1315
+ show_plot: bool = False,
1316
+ with_reflections: bool = False,
1317
+ **kwargs
1318
+ ) -> np.ndarray:
1319
+ """
1320
+ GPU-accelerated direct horizontal irradiance map computation.
1321
+
1322
+ This function matches the signature of voxcity.simulator.solar.get_direct_solar_irradiance_map
1323
+ using Taichi GPU acceleration.
1324
+
1325
+ Args:
1326
+ voxcity: VoxCity object
1327
+ azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
1328
+ elevation_degrees: Solar elevation in degrees above horizon
1329
+ direct_normal_irradiance: DNI in W/m²
1330
+ show_plot: Whether to display a matplotlib plot
1331
+ with_reflections: If True, use full RadiationModel with multi-bounce
1332
+ reflections. If False (default), use simple ray-tracing for
1333
+ faster but less accurate results.
1334
+ **kwargs: Additional parameters including:
1335
+ - view_point_height (float): Observer height above ground (default: 1.5)
1336
+ - tree_k (float): Tree extinction coefficient (default: 0.6)
1337
+ - tree_lad (float): Leaf area density (default: 1.0)
1338
+ - colormap (str): Matplotlib colormap name (default: 'magma')
1339
+ - vmin, vmax (float): Colormap limits
1340
+ - obj_export (bool): Export to OBJ file (default: False)
1341
+ - n_reflection_steps (int): Number of reflection bounces when
1342
+ with_reflections=True (default: 2)
1343
+ - progress_report (bool): Print progress (default: False)
1344
+
1345
+ Returns:
1346
+ 2D numpy array of direct horizontal irradiance (W/m²)
1347
+ """
1348
+ import taichi as ti
1349
+ from ..init_taichi import ensure_initialized
1350
+ ensure_initialized()
1351
+
1352
+ colormap = kwargs.get('colormap', 'magma')
1353
+ vmin = kwargs.get('vmin', 0.0)
1354
+ vmax = kwargs.get('vmax', direct_normal_irradiance)
1355
+
1356
+ if with_reflections:
1357
+ # Use full RadiationModel with reflections
1358
+ direct_map, _, _ = _compute_ground_irradiance_with_reflections(
1359
+ voxcity=voxcity,
1360
+ azimuth_degrees_ori=azimuth_degrees_ori,
1361
+ elevation_degrees=elevation_degrees,
1362
+ direct_normal_irradiance=direct_normal_irradiance,
1363
+ diffuse_irradiance=0.0, # Only compute direct component
1364
+ **kwargs
1365
+ )
1366
+ else:
1367
+ # Use simple ray-tracing (faster but no reflections)
1368
+ voxel_data = voxcity.voxels.classes
1369
+ meshsize = voxcity.voxels.meta.meshsize
1370
+
1371
+ view_point_height = kwargs.get('view_point_height', 1.5)
1372
+ tree_k = kwargs.get('tree_k', 0.6)
1373
+ tree_lad = kwargs.get('tree_lad', 1.0)
1374
+
1375
+ # Convert to sun direction vector
1376
+ # VoxCity convention: azimuth 0=North, clockwise
1377
+ # Convert to standard: 180 - azimuth
1378
+ azimuth_degrees = 180 - azimuth_degrees_ori
1379
+ azimuth_radians = np.deg2rad(azimuth_degrees)
1380
+ elevation_radians = np.deg2rad(elevation_degrees)
1381
+
1382
+ dx_dir = np.cos(elevation_radians) * np.cos(azimuth_radians)
1383
+ dy_dir = np.cos(elevation_radians) * np.sin(azimuth_radians)
1384
+ dz_dir = np.sin(elevation_radians)
1385
+
1386
+ # Compute transmittance map using ray tracing
1387
+ transmittance_map = _compute_direct_transmittance_map_gpu(
1388
+ voxel_data=voxel_data,
1389
+ sun_direction=(dx_dir, dy_dir, dz_dir),
1390
+ view_point_height=view_point_height,
1391
+ meshsize=meshsize,
1392
+ tree_k=tree_k,
1393
+ tree_lad=tree_lad
1394
+ )
1395
+
1396
+ # Convert to horizontal irradiance
1397
+ sin_elev = np.sin(elevation_radians)
1398
+ direct_map = transmittance_map * direct_normal_irradiance * sin_elev
1399
+
1400
+ # Flip to match VoxCity coordinate system
1401
+ direct_map = np.flipud(direct_map)
1402
+
1403
+ if show_plot:
1404
+ try:
1405
+ import matplotlib.pyplot as plt
1406
+ cmap = plt.cm.get_cmap(colormap).copy()
1407
+ cmap.set_bad(color='lightgray')
1408
+ plt.figure(figsize=(10, 8))
1409
+ plt.imshow(direct_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
1410
+ plt.colorbar(label='Direct Solar Irradiance (W/m²)')
1411
+ plt.axis('off')
1412
+ plt.show()
1413
+ except ImportError:
1414
+ pass
1415
+
1416
+ if kwargs.get('obj_export', False):
1417
+ _export_irradiance_to_obj(
1418
+ voxcity, direct_map,
1419
+ output_name=kwargs.get('output_file_name', 'direct_solar_irradiance'),
1420
+ **kwargs
1421
+ )
1422
+
1423
+ return direct_map
1424
+
1425
+
1426
+ def get_diffuse_solar_irradiance_map(
1427
+ voxcity,
1428
+ diffuse_irradiance: float = 1.0,
1429
+ show_plot: bool = False,
1430
+ with_reflections: bool = False,
1431
+ azimuth_degrees_ori: float = 180.0,
1432
+ elevation_degrees: float = 45.0,
1433
+ **kwargs
1434
+ ) -> np.ndarray:
1435
+ """
1436
+ GPU-accelerated diffuse horizontal irradiance map computation using SVF.
1437
+
1438
+ This function matches the signature of voxcity.simulator.solar.get_diffuse_solar_irradiance_map
1439
+ using Taichi GPU acceleration.
1440
+
1441
+ Args:
1442
+ voxcity: VoxCity object
1443
+ diffuse_irradiance: Diffuse horizontal irradiance in W/m²
1444
+ show_plot: Whether to display a matplotlib plot
1445
+ with_reflections: If True, use full RadiationModel with multi-bounce
1446
+ reflections (requires azimuth_degrees_ori and elevation_degrees).
1447
+ If False (default), use simple SVF-based computation.
1448
+ azimuth_degrees_ori: Solar azimuth in degrees (only used when with_reflections=True)
1449
+ elevation_degrees: Solar elevation in degrees (only used when with_reflections=True)
1450
+ **kwargs: Additional parameters including:
1451
+ - view_point_height (float): Observer height above ground (default: 1.5)
1452
+ - N_azimuth (int): Number of azimuthal divisions (default: 120)
1453
+ - N_elevation (int): Number of elevation divisions (default: 20)
1454
+ - tree_k (float): Tree extinction coefficient (default: 0.6)
1455
+ - tree_lad (float): Leaf area density (default: 1.0)
1456
+ - colormap (str): Matplotlib colormap name (default: 'magma')
1457
+ - vmin, vmax (float): Colormap limits
1458
+ - obj_export (bool): Export to OBJ file (default: False)
1459
+ - n_reflection_steps (int): Number of reflection bounces when
1460
+ with_reflections=True (default: 2)
1461
+ - progress_report (bool): Print progress (default: False)
1462
+
1463
+ Returns:
1464
+ 2D numpy array of diffuse horizontal irradiance (W/m²)
1465
+ """
1466
+ colormap = kwargs.get('colormap', 'magma')
1467
+ vmin = kwargs.get('vmin', 0.0)
1468
+ vmax = kwargs.get('vmax', diffuse_irradiance)
1469
+
1470
+ if with_reflections:
1471
+ # Use full RadiationModel with reflections
1472
+ # Remove parameters we explicitly set to avoid conflicts
1473
+ refl_kwargs = {k: v for k, v in kwargs.items()
1474
+ if k not in ('direct_normal_irradiance', 'diffuse_irradiance')}
1475
+ _, diffuse_map, reflected_map = _compute_ground_irradiance_with_reflections(
1476
+ voxcity=voxcity,
1477
+ azimuth_degrees_ori=azimuth_degrees_ori,
1478
+ elevation_degrees=elevation_degrees,
1479
+ direct_normal_irradiance=kwargs.get('direct_normal_irradiance', 0.0),
1480
+ diffuse_irradiance=diffuse_irradiance,
1481
+ **refl_kwargs
1482
+ )
1483
+ # Include reflected component in diffuse when using reflection model
1484
+ diffuse_map = np.where(np.isnan(diffuse_map), np.nan, diffuse_map + reflected_map)
1485
+ else:
1486
+ # Use simple SVF-based computation (faster but no reflections)
1487
+ # Import the visibility SVF function
1488
+ from ..visibility.integration import get_sky_view_factor_map as get_svf_map
1489
+
1490
+ # Get SVF map using GPU-accelerated visibility module
1491
+ svf_kwargs = kwargs.copy()
1492
+ svf_kwargs['colormap'] = 'BuPu_r'
1493
+ svf_kwargs['vmin'] = 0
1494
+ svf_kwargs['vmax'] = 1
1495
+
1496
+ SVF_map = get_svf_map(voxcity, show_plot=False, **svf_kwargs)
1497
+ diffuse_map = SVF_map * diffuse_irradiance
1498
+
1499
+ if show_plot:
1500
+ try:
1501
+ import matplotlib.pyplot as plt
1502
+ cmap = plt.cm.get_cmap(colormap).copy()
1503
+ cmap.set_bad(color='lightgray')
1504
+ plt.figure(figsize=(10, 8))
1505
+ plt.imshow(diffuse_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
1506
+ plt.colorbar(label='Diffuse Solar Irradiance (W/m²)')
1507
+ plt.axis('off')
1508
+ plt.show()
1509
+ except ImportError:
1510
+ pass
1511
+
1512
+ if kwargs.get('obj_export', False):
1513
+ _export_irradiance_to_obj(
1514
+ voxcity, diffuse_map,
1515
+ output_name=kwargs.get('output_file_name', 'diffuse_solar_irradiance'),
1516
+ **kwargs
1517
+ )
1518
+
1519
+ return diffuse_map
1520
+
1521
+
1522
+ def get_global_solar_irradiance_map(
1523
+ voxcity,
1524
+ azimuth_degrees_ori: float,
1525
+ elevation_degrees: float,
1526
+ direct_normal_irradiance: float,
1527
+ diffuse_irradiance: float,
1528
+ show_plot: bool = False,
1529
+ with_reflections: bool = False,
1530
+ **kwargs
1531
+ ) -> np.ndarray:
1532
+ """
1533
+ GPU-accelerated global (direct + diffuse) horizontal irradiance map.
1534
+
1535
+ This function matches the signature of voxcity.simulator.solar.get_global_solar_irradiance_map
1536
+ using Taichi GPU acceleration.
1537
+
1538
+ Args:
1539
+ voxcity: VoxCity object
1540
+ azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
1541
+ elevation_degrees: Solar elevation in degrees above horizon
1542
+ direct_normal_irradiance: DNI in W/m²
1543
+ diffuse_irradiance: DHI in W/m²
1544
+ show_plot: Whether to display a matplotlib plot
1545
+ with_reflections: If True, use full RadiationModel with multi-bounce
1546
+ reflections. If False (default), use simple ray-tracing/SVF for
1547
+ faster but less accurate results.
1548
+ **kwargs: Additional parameters (see get_direct_solar_irradiance_map)
1549
+ - computation_mask (np.ndarray): Optional 2D boolean mask for sub-area computation
1550
+ - n_reflection_steps (int): Number of reflection bounces when
1551
+ with_reflections=True (default: 2)
1552
+ - progress_report (bool): Print progress (default: False)
1553
+
1554
+ Returns:
1555
+ 2D numpy array of global horizontal irradiance (W/m²)
1556
+ """
1557
+ # Extract computation_mask from kwargs
1558
+ computation_mask = kwargs.pop('computation_mask', None)
1559
+
1560
+ if with_reflections:
1561
+ # Use full RadiationModel with reflections (single call for all components)
1562
+ direct_map, diffuse_map, reflected_map = _compute_ground_irradiance_with_reflections(
1563
+ voxcity=voxcity,
1564
+ azimuth_degrees_ori=azimuth_degrees_ori,
1565
+ elevation_degrees=elevation_degrees,
1566
+ direct_normal_irradiance=direct_normal_irradiance,
1567
+ diffuse_irradiance=diffuse_irradiance,
1568
+ **kwargs
1569
+ )
1570
+ # Combine all components: direct + diffuse + reflected
1571
+ global_map = np.where(
1572
+ np.isnan(direct_map),
1573
+ np.nan,
1574
+ direct_map + diffuse_map + reflected_map
1575
+ )
1576
+ else:
1577
+ # Compute direct and diffuse components separately (no reflections)
1578
+ direct_map = get_direct_solar_irradiance_map(
1579
+ voxcity,
1580
+ azimuth_degrees_ori,
1581
+ elevation_degrees,
1582
+ direct_normal_irradiance,
1583
+ show_plot=False,
1584
+ with_reflections=False,
1585
+ **kwargs
1586
+ )
1587
+
1588
+ diffuse_map = get_diffuse_solar_irradiance_map(
1589
+ voxcity,
1590
+ diffuse_irradiance=diffuse_irradiance,
1591
+ show_plot=False,
1592
+ with_reflections=False,
1593
+ **kwargs
1594
+ )
1595
+
1596
+ # Combine: where direct is NaN, use only diffuse
1597
+ global_map = np.where(np.isnan(direct_map), diffuse_map, direct_map + diffuse_map)
1598
+
1599
+ if show_plot:
1600
+ colormap = kwargs.get('colormap', 'magma')
1601
+ vmin = kwargs.get('vmin', 0.0)
1602
+ vmax = kwargs.get('vmax', max(float(np.nanmax(global_map)), 1.0))
1603
+ try:
1604
+ import matplotlib.pyplot as plt
1605
+ cmap = plt.cm.get_cmap(colormap).copy()
1606
+ cmap.set_bad(color='lightgray')
1607
+ plt.figure(figsize=(10, 8))
1608
+ plt.imshow(global_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
1609
+ plt.colorbar(label='Global Solar Irradiance (W/m²)')
1610
+ plt.axis('off')
1611
+ plt.show()
1612
+ except ImportError:
1613
+ pass
1614
+
1615
+ if kwargs.get('obj_export', False):
1616
+ _export_irradiance_to_obj(
1617
+ voxcity, global_map,
1618
+ output_name=kwargs.get('output_file_name', 'global_solar_irradiance'),
1619
+ **kwargs
1620
+ )
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
+
1634
+ return global_map
1635
+
1636
+
1637
+ def get_cumulative_global_solar_irradiance(
1638
+ voxcity,
1639
+ df,
1640
+ lon: float,
1641
+ lat: float,
1642
+ tz: float,
1643
+ direct_normal_irradiance_scaling: float = 1.0,
1644
+ diffuse_irradiance_scaling: float = 1.0,
1645
+ show_plot: bool = False,
1646
+ with_reflections: bool = False,
1647
+ **kwargs
1648
+ ) -> np.ndarray:
1649
+ """
1650
+ GPU-accelerated cumulative global solar irradiance over a period.
1651
+
1652
+ This function matches the signature of voxcity.simulator.solar.get_cumulative_global_solar_irradiance
1653
+ using Taichi GPU acceleration with sky patch optimization.
1654
+
1655
+ OPTIMIZATIONS IMPLEMENTED:
1656
+ 1. Vectorized sun position binning using bin_sun_positions_to_tregenza_fast
1657
+ 2. Pre-allocated output arrays for patch loop
1658
+ 3. Cached model reuse across patches (SVF/CSF computed only once)
1659
+ 4. Efficient array extraction with pre-computed surface-to-grid mapping
1660
+
1661
+ Args:
1662
+ voxcity: VoxCity object
1663
+ df: pandas DataFrame with 'DNI' and 'DHI' columns, datetime-indexed
1664
+ lon: Longitude in degrees
1665
+ lat: Latitude in degrees
1666
+ tz: Timezone offset in hours
1667
+ direct_normal_irradiance_scaling: Scaling factor for DNI
1668
+ diffuse_irradiance_scaling: Scaling factor for DHI
1669
+ show_plot: Whether to display a matplotlib plot
1670
+ with_reflections: If True, use full RadiationModel with multi-bounce
1671
+ reflections for each timestep/patch. If False (default), use simple
1672
+ ray-tracing/SVF for faster computation.
1673
+ **kwargs: Additional parameters including:
1674
+ - computation_mask (np.ndarray): Optional 2D boolean mask for sub-area computation
1675
+ - start_time (str): Start time 'MM-DD HH:MM:SS' (default: '01-01 05:00:00')
1676
+ - end_time (str): End time 'MM-DD HH:MM:SS' (default: '01-01 20:00:00')
1677
+ - view_point_height (float): Observer height (default: 1.5)
1678
+ - use_sky_patches (bool): Use sky patch optimization (default: True)
1679
+ - sky_discretization (str): 'tregenza', 'reinhart', 'uniform', 'fibonacci'
1680
+ - progress_report (bool): Print progress (default: False)
1681
+ - colormap (str): Colormap name (default: 'magma')
1682
+ - n_reflection_steps (int): Number of reflection bounces when
1683
+ with_reflections=True (default: 2)
1684
+
1685
+ Returns:
1686
+ 2D numpy array of cumulative irradiance (Wh/m²)
1687
+ """
1688
+ import time
1689
+ from datetime import datetime
1690
+ import pytz
1691
+
1692
+ # Extract parameters that we pass explicitly (use pop to avoid duplicate kwargs)
1693
+ kwargs = kwargs.copy() # Don't modify the original
1694
+ computation_mask = kwargs.pop('computation_mask', None)
1695
+ view_point_height = kwargs.pop('view_point_height', 1.5)
1696
+ colormap = kwargs.pop('colormap', 'magma')
1697
+ start_time = kwargs.pop('start_time', '01-01 05:00:00')
1698
+ end_time = kwargs.pop('end_time', '01-01 20:00:00')
1699
+ progress_report = kwargs.pop('progress_report', False)
1700
+ use_sky_patches = kwargs.pop('use_sky_patches', True)
1701
+ sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
1702
+
1703
+ if df.empty:
1704
+ raise ValueError("No data in EPW dataframe.")
1705
+
1706
+ # Parse time range
1707
+ try:
1708
+ start_dt = datetime.strptime(start_time, '%m-%d %H:%M:%S')
1709
+ end_dt = datetime.strptime(end_time, '%m-%d %H:%M:%S')
1710
+ except ValueError as ve:
1711
+ raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
1712
+
1713
+ # Filter dataframe to period
1714
+ df = df.copy()
1715
+ df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
1716
+ start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
1717
+ end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
1718
+ start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
1719
+ end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
1720
+
1721
+ if start_hour <= end_hour:
1722
+ df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
1723
+ else:
1724
+ df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
1725
+
1726
+ if df_period.empty:
1727
+ raise ValueError("No EPW data in the specified period.")
1728
+
1729
+ # Localize and convert to UTC
1730
+ offset_minutes = int(tz * 60)
1731
+ local_tz = pytz.FixedOffset(offset_minutes)
1732
+ df_period_local = df_period.copy()
1733
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
1734
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
1735
+
1736
+ # Get solar positions
1737
+ solar_positions = _get_solar_positions_astral(df_period_utc.index, lon, lat)
1738
+
1739
+ # Compute base diffuse map (SVF-based for efficiency, or with reflections if requested)
1740
+ # Note: For cumulative with_reflections, we still use SVF-based base for diffuse sky contribution
1741
+ # The reflection component is computed per timestep when with_reflections=True
1742
+ diffuse_kwargs = kwargs.copy()
1743
+ diffuse_kwargs.update({'show_plot': False, 'obj_export': False})
1744
+ base_diffuse_map = get_diffuse_solar_irradiance_map(
1745
+ voxcity,
1746
+ diffuse_irradiance=1.0,
1747
+ with_reflections=False, # Always use SVF for base diffuse in cumulative mode
1748
+ **diffuse_kwargs
1749
+ )
1750
+
1751
+ voxel_data = voxcity.voxels.classes
1752
+ nx, ny, _ = voxel_data.shape
1753
+ cumulative_map = np.zeros((nx, ny))
1754
+ mask_map = np.ones((nx, ny), dtype=bool)
1755
+
1756
+ direct_kwargs = kwargs.copy()
1757
+ direct_kwargs.update({
1758
+ 'show_plot': False,
1759
+ 'view_point_height': view_point_height,
1760
+ 'obj_export': False,
1761
+ 'with_reflections': with_reflections # Pass through to direct/global map calls
1762
+ })
1763
+
1764
+ if use_sky_patches:
1765
+ # Use sky patch aggregation for efficiency
1766
+ from .sky import (
1767
+ generate_tregenza_patches,
1768
+ generate_reinhart_patches,
1769
+ generate_uniform_grid_patches,
1770
+ generate_fibonacci_patches,
1771
+ get_tregenza_patch_index
1772
+ )
1773
+
1774
+ t0 = time.perf_counter() if progress_report else 0
1775
+
1776
+ # Extract arrays
1777
+ azimuth_arr = solar_positions['azimuth'].to_numpy()
1778
+ elevation_arr = solar_positions['elevation'].to_numpy()
1779
+ dni_arr = df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling
1780
+ dhi_arr = df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling
1781
+ time_step_hours = kwargs.get('time_step_hours', 1.0)
1782
+
1783
+ # Generate sky patches
1784
+ if sky_discretization.lower() == 'tregenza':
1785
+ patches, directions, solid_angles = generate_tregenza_patches()
1786
+ elif sky_discretization.lower() == 'reinhart':
1787
+ mf = kwargs.get('reinhart_mf', kwargs.get('mf', 4))
1788
+ patches, directions, solid_angles = generate_reinhart_patches(mf=mf)
1789
+ elif sky_discretization.lower() == 'uniform':
1790
+ n_az = kwargs.get('sky_n_azimuth', kwargs.get('n_azimuth', 36))
1791
+ n_el = kwargs.get('sky_n_elevation', kwargs.get('n_elevation', 9))
1792
+ patches, directions, solid_angles = generate_uniform_grid_patches(n_az, n_el)
1793
+ elif sky_discretization.lower() == 'fibonacci':
1794
+ n_patches = kwargs.get('sky_n_patches', kwargs.get('n_patches', 145))
1795
+ patches, directions, solid_angles = generate_fibonacci_patches(n_patches=n_patches)
1796
+ else:
1797
+ raise ValueError(f"Unknown sky discretization method: {sky_discretization}")
1798
+
1799
+ n_patches = len(patches)
1800
+ n_timesteps = len(azimuth_arr)
1801
+ cumulative_dni = np.zeros(n_patches, dtype=np.float64)
1802
+
1803
+ # OPTIMIZATION: Vectorized DHI accumulation (only for positive values)
1804
+ # This replaces the loop-based accumulation
1805
+ valid_dhi_mask = dhi_arr > 0
1806
+ total_cumulative_dhi = np.sum(dhi_arr[valid_dhi_mask]) * time_step_hours
1807
+
1808
+ # DNI binning - loop is already fast (~7ms for 731 timesteps)
1809
+ # The loop is necessary because patch assignment depends on sun position
1810
+ for i in range(n_timesteps):
1811
+ elev = elevation_arr[i]
1812
+ if elev <= 0:
1813
+ continue
1814
+
1815
+ az = azimuth_arr[i]
1816
+ dni = dni_arr[i]
1817
+
1818
+ if dni <= 0:
1819
+ continue
1820
+
1821
+ patch_idx = int(get_tregenza_patch_index(float(az), float(elev)))
1822
+ if patch_idx >= 0 and patch_idx < n_patches:
1823
+ cumulative_dni[patch_idx] += dni * time_step_hours
1824
+
1825
+ active_mask = cumulative_dni > 0
1826
+ n_active = int(np.sum(active_mask))
1827
+
1828
+ if progress_report:
1829
+ bin_time = time.perf_counter() - t0
1830
+ print(f"Sky patch optimization: {n_timesteps} timesteps -> {n_active} active patches ({sky_discretization})")
1831
+ print(f" Sun position binning: {bin_time:.3f}s")
1832
+ print(f" Total cumulative DHI: {total_cumulative_dhi:.1f} Wh/m²")
1833
+ if with_reflections:
1834
+ print(" Using RadiationModel with multi-bounce reflections")
1835
+
1836
+ # Diffuse component
1837
+ cumulative_diffuse = base_diffuse_map * total_cumulative_dhi
1838
+ cumulative_map += np.nan_to_num(cumulative_diffuse, nan=0.0)
1839
+ mask_map &= ~np.isnan(cumulative_diffuse)
1840
+
1841
+ # Direct component - loop over active patches
1842
+ # When with_reflections=True, use get_global_solar_irradiance_map to include
1843
+ # reflections for each patch direction
1844
+ active_indices = np.where(active_mask)[0]
1845
+
1846
+ # OPTIMIZATION: Pre-warm the model (ensures JIT compilation is done)
1847
+ if with_reflections and len(active_indices) > 0:
1848
+ # Ensure model is created and cached before timing
1849
+ n_reflection_steps = kwargs.get('n_reflection_steps', 2)
1850
+ _ = _get_or_create_radiation_model(
1851
+ voxcity,
1852
+ n_reflection_steps=n_reflection_steps,
1853
+ progress_report=progress_report
1854
+ )
1855
+
1856
+ if progress_report:
1857
+ t_patch_start = time.perf_counter()
1858
+
1859
+ for i, patch_idx in enumerate(active_indices):
1860
+ az_deg = patches[patch_idx, 0]
1861
+ el_deg = patches[patch_idx, 1]
1862
+ cumulative_dni_patch = cumulative_dni[patch_idx]
1863
+
1864
+ if with_reflections:
1865
+ # Use full RadiationModel: compute direct + reflected for this direction
1866
+ # We set diffuse_irradiance=0 since we handle diffuse separately
1867
+ direct_map, _, reflected_map = _compute_ground_irradiance_with_reflections(
1868
+ voxcity=voxcity,
1869
+ azimuth_degrees_ori=az_deg,
1870
+ elevation_degrees=el_deg,
1871
+ direct_normal_irradiance=1.0,
1872
+ diffuse_irradiance=0.0,
1873
+ view_point_height=view_point_height,
1874
+ **kwargs
1875
+ )
1876
+ # Include reflections in patch contribution
1877
+ patch_contribution = (direct_map + np.nan_to_num(reflected_map, nan=0.0)) * cumulative_dni_patch
1878
+ else:
1879
+ # Simple ray tracing (no reflections)
1880
+ direct_map = get_direct_solar_irradiance_map(
1881
+ voxcity,
1882
+ az_deg,
1883
+ el_deg,
1884
+ direct_normal_irradiance=1.0,
1885
+ **direct_kwargs
1886
+ )
1887
+ patch_contribution = direct_map * cumulative_dni_patch
1888
+
1889
+ mask_map &= ~np.isnan(patch_contribution)
1890
+ cumulative_map += np.nan_to_num(patch_contribution, nan=0.0)
1891
+
1892
+ if progress_report and ((i + 1) % max(1, len(active_indices) // 10) == 0 or i == len(active_indices) - 1):
1893
+ elapsed = time.perf_counter() - t_patch_start
1894
+ pct = (i + 1) * 100.0 / len(active_indices)
1895
+ avg_per_patch = elapsed / (i + 1)
1896
+ eta = avg_per_patch * (len(active_indices) - i - 1)
1897
+ print(f" Patch {i+1}/{len(active_indices)} ({pct:.1f}%) - elapsed: {elapsed:.1f}s, ETA: {eta:.1f}s, avg: {avg_per_patch*1000:.1f}ms/patch")
1898
+
1899
+ if progress_report:
1900
+ total_patch_time = time.perf_counter() - t_patch_start
1901
+ print(f" Total patch processing: {total_patch_time:.2f}s ({n_active} patches)")
1902
+
1903
+ else:
1904
+ # Per-timestep path
1905
+ if progress_report and with_reflections:
1906
+ print(" Using RadiationModel with multi-bounce reflections (per-timestep)")
1907
+
1908
+ for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
1909
+ DNI = float(row['DNI']) * direct_normal_irradiance_scaling
1910
+ DHI = float(row['DHI']) * diffuse_irradiance_scaling
1911
+
1912
+ solpos = solar_positions.loc[time_utc]
1913
+ azimuth_degrees = float(solpos['azimuth'])
1914
+ elevation_degrees_val = float(solpos['elevation'])
1915
+
1916
+ if with_reflections:
1917
+ # Use full RadiationModel for this timestep
1918
+ direct_map, diffuse_map_ts, reflected_map = _compute_ground_irradiance_with_reflections(
1919
+ voxcity=voxcity,
1920
+ azimuth_degrees_ori=azimuth_degrees,
1921
+ elevation_degrees=elevation_degrees_val,
1922
+ direct_normal_irradiance=DNI,
1923
+ diffuse_irradiance=DHI,
1924
+ view_point_height=view_point_height,
1925
+ **kwargs
1926
+ )
1927
+ # Combine all components
1928
+ combined = (np.nan_to_num(direct_map, nan=0.0) +
1929
+ np.nan_to_num(diffuse_map_ts, nan=0.0) +
1930
+ np.nan_to_num(reflected_map, nan=0.0))
1931
+ mask_map &= ~np.isnan(direct_map)
1932
+ else:
1933
+ # Simple ray tracing (no reflections)
1934
+ direct_map = get_direct_solar_irradiance_map(
1935
+ voxcity,
1936
+ azimuth_degrees,
1937
+ elevation_degrees_val,
1938
+ direct_normal_irradiance=DNI,
1939
+ **direct_kwargs # with_reflections already in direct_kwargs
1940
+ )
1941
+
1942
+ diffuse_contrib = base_diffuse_map * DHI
1943
+ combined = np.nan_to_num(direct_map, nan=0.0) + np.nan_to_num(diffuse_contrib, nan=0.0)
1944
+ mask_map &= ~np.isnan(direct_map) & ~np.isnan(diffuse_contrib)
1945
+
1946
+ cumulative_map += combined
1947
+
1948
+ if progress_report and (idx + 1) % max(1, len(df_period_utc) // 10) == 0:
1949
+ pct = (idx + 1) * 100.0 / len(df_period_utc)
1950
+ print(f" Timestep {idx+1}/{len(df_period_utc)} ({pct:.1f}%)")
1951
+
1952
+ # Apply mask for plotting
1953
+ cumulative_map = np.where(mask_map, cumulative_map, np.nan)
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
+
1963
+ if show_plot:
1964
+ vmax = kwargs.get('vmax', float(np.nanmax(cumulative_map)) if not np.all(np.isnan(cumulative_map)) else 1.0)
1965
+ vmin = kwargs.get('vmin', 0.0)
1966
+ try:
1967
+ import matplotlib.pyplot as plt
1968
+ cmap = plt.cm.get_cmap(colormap).copy()
1969
+ cmap.set_bad(color='lightgray')
1970
+ plt.figure(figsize=(10, 8))
1971
+ plt.imshow(cumulative_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
1972
+ plt.colorbar(label='Cumulative Global Solar Irradiance (Wh/m²)')
1973
+ plt.axis('off')
1974
+ plt.show()
1975
+ except ImportError:
1976
+ pass
1977
+
1978
+ return cumulative_map
1979
+
1980
+
1981
+ def get_building_solar_irradiance(
1982
+ voxcity,
1983
+ building_svf_mesh=None,
1984
+ azimuth_degrees_ori: float = None,
1985
+ elevation_degrees: float = None,
1986
+ direct_normal_irradiance: float = None,
1987
+ diffuse_irradiance: float = None,
1988
+ **kwargs
1989
+ ):
1990
+ """
1991
+ GPU-accelerated building surface solar irradiance computation.
1992
+
1993
+ This function matches the signature of voxcity.simulator.solar.get_building_solar_irradiance
1994
+ using Taichi GPU acceleration with multi-bounce reflections.
1995
+
1996
+ Uses cached RadiationModel to avoid recomputing SVF/CSF matrices for each timestep.
1997
+
1998
+ Args:
1999
+ voxcity: VoxCity object
2000
+ building_svf_mesh: Pre-computed mesh with SVF values (optional, for VoxCity API compatibility)
2001
+ If provided, SVF values from mesh metadata will be used.
2002
+ If None, SVF will be computed internally.
2003
+ azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
2004
+ elevation_degrees: Solar elevation in degrees above horizon
2005
+ direct_normal_irradiance: DNI in W/m²
2006
+ diffuse_irradiance: DHI in W/m²
2007
+ **kwargs: Additional parameters including:
2008
+ - with_reflections (bool): Enable multi-bounce surface reflections (default: False).
2009
+ Set to True for more accurate results but slower computation.
2010
+ - n_reflection_steps (int): Number of reflection bounces when with_reflections=True (default: 2)
2011
+ - tree_k (float): Tree extinction coefficient (default: 0.6)
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.
2016
+ - progress_report (bool): Print progress (default: False)
2017
+ - colormap (str): Colormap name (default: 'magma')
2018
+ - obj_export (bool): Export mesh to OBJ (default: False)
2019
+
2020
+ Returns:
2021
+ Trimesh object with irradiance values in metadata
2022
+ """
2023
+ # Handle positional argument order from VoxCity API:
2024
+ # VoxCity: get_building_solar_irradiance(voxcity, building_svf_mesh, azimuth, elevation, dni, dhi, **kwargs)
2025
+ # If building_svf_mesh is a number, assume old GPU-only API call where second arg is azimuth
2026
+ if isinstance(building_svf_mesh, (int, float)):
2027
+ # Old API: get_building_solar_irradiance(voxcity, azimuth, elevation, dni, dhi, ...)
2028
+ diffuse_irradiance = direct_normal_irradiance
2029
+ direct_normal_irradiance = elevation_degrees
2030
+ elevation_degrees = azimuth_degrees_ori
2031
+ azimuth_degrees_ori = building_svf_mesh
2032
+ building_svf_mesh = None
2033
+
2034
+ voxel_data = voxcity.voxels.classes
2035
+ meshsize = voxcity.voxels.meta.meshsize
2036
+ building_id_grid = voxcity.buildings.ids
2037
+ ny_vc, nx_vc, nz = voxel_data.shape
2038
+
2039
+ # Extract parameters that we pass explicitly (to avoid duplicate kwargs error)
2040
+ progress_report = kwargs.pop('progress_report', False)
2041
+ building_class_id = kwargs.pop('building_class_id', -3)
2042
+ n_reflection_steps = kwargs.pop('n_reflection_steps', 2)
2043
+ colormap = kwargs.pop('colormap', 'magma')
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
2046
+
2047
+ # If with_reflections=False, set n_reflection_steps=0 to skip expensive SVF matrix computation
2048
+ if not with_reflections:
2049
+ n_reflection_steps = 0
2050
+
2051
+ # Get cached or create new RadiationModel (SVF/CSF computed only once)
2052
+ model, is_building_surf = _get_or_create_building_radiation_model(
2053
+ voxcity,
2054
+ n_reflection_steps=n_reflection_steps,
2055
+ progress_report=progress_report,
2056
+ building_class_id=building_class_id,
2057
+ **kwargs
2058
+ )
2059
+
2060
+ # Set solar position for this timestep
2061
+ azimuth_degrees = 180 - azimuth_degrees_ori
2062
+ azimuth_radians = np.deg2rad(azimuth_degrees)
2063
+ elevation_radians = np.deg2rad(elevation_degrees)
2064
+
2065
+ sun_dir_x = np.cos(elevation_radians) * np.cos(azimuth_radians)
2066
+ sun_dir_y = np.cos(elevation_radians) * np.sin(azimuth_radians)
2067
+ sun_dir_z = np.sin(elevation_radians)
2068
+
2069
+ # Set sun direction and cos_zenith directly on the SolarCalculator fields
2070
+ model.solar_calc.sun_direction[None] = (sun_dir_x, sun_dir_y, sun_dir_z)
2071
+ model.solar_calc.cos_zenith[None] = np.sin(elevation_radians)
2072
+ model.solar_calc.sun_up[None] = 1 if elevation_degrees > 0 else 0
2073
+
2074
+ # Compute shortwave radiation (uses cached SVF/CSF matrices)
2075
+ model.compute_shortwave_radiation(
2076
+ sw_direct=direct_normal_irradiance,
2077
+ sw_diffuse=diffuse_irradiance
2078
+ )
2079
+
2080
+ # Extract surface irradiance from palm_solar model
2081
+ # Note: Use to_numpy() without slicing - slicing is VERY slow on Taichi arrays
2082
+ # The mesh_to_surface_idx will handle extracting only the values we need
2083
+ n_surfaces = model.surfaces.count
2084
+ sw_in_direct_all = model.surfaces.sw_in_direct.to_numpy()
2085
+ sw_in_diffuse_all = model.surfaces.sw_in_diffuse.to_numpy()
2086
+
2087
+ if hasattr(model.surfaces, 'sw_in_reflected'):
2088
+ sw_in_reflected_all = model.surfaces.sw_in_reflected.to_numpy()
2089
+ else:
2090
+ sw_in_reflected_all = np.zeros_like(sw_in_direct_all)
2091
+
2092
+ total_sw_all = sw_in_direct_all + sw_in_diffuse_all + sw_in_reflected_all
2093
+
2094
+ # Get building indices from cache (avoids np.where every time)
2095
+ bldg_indices = _building_radiation_model_cache.bldg_indices if _building_radiation_model_cache else np.where(is_building_surf)[0]
2096
+ if progress_report:
2097
+ print(f" palm_solar surfaces: {n_surfaces}, building surfaces: {len(bldg_indices)}")
2098
+
2099
+ # Get or create building mesh - use cached mesh if available (expensive: ~2.4s)
2100
+ cache = _building_radiation_model_cache
2101
+ if building_svf_mesh is not None:
2102
+ # Use provided mesh directly (no copy needed - we only update metadata)
2103
+ building_mesh = building_svf_mesh
2104
+ # Extract SVF from mesh metadata if available
2105
+ if hasattr(building_mesh, 'metadata') and 'svf' in building_mesh.metadata:
2106
+ face_svf = building_mesh.metadata['svf']
2107
+ else:
2108
+ face_svf = None
2109
+ # Cache mesh geometry on first use (avoids recomputing triangles_center/face_normals)
2110
+ if cache is not None and cache.mesh_face_centers is None:
2111
+ cache.mesh_face_centers = building_mesh.triangles_center.copy()
2112
+ cache.mesh_face_normals = building_mesh.face_normals.copy()
2113
+ elif cache is not None and cache.cached_building_mesh is not None:
2114
+ # Use cached mesh (avoids expensive ~2.4s mesh creation each call)
2115
+ building_mesh = cache.cached_building_mesh
2116
+ face_svf = None
2117
+ else:
2118
+ # Create mesh for building surfaces (expensive, ~2.4s)
2119
+ try:
2120
+ from voxcity.geoprocessor.mesh import create_voxel_mesh
2121
+ if progress_report:
2122
+ print(" Creating building mesh (first call, will be cached)...")
2123
+ building_mesh = create_voxel_mesh(
2124
+ voxel_data,
2125
+ building_class_id,
2126
+ meshsize,
2127
+ building_id_grid=building_id_grid,
2128
+ mesh_type='open_air'
2129
+ )
2130
+ if building_mesh is None or len(building_mesh.faces) == 0:
2131
+ print("No building surfaces found.")
2132
+ return None
2133
+ # Cache the mesh for future calls
2134
+ if cache is not None:
2135
+ cache.cached_building_mesh = building_mesh
2136
+ if progress_report:
2137
+ print(f" Cached building mesh with {len(building_mesh.faces)} faces")
2138
+ except ImportError:
2139
+ print("VoxCity geoprocessor.mesh required for mesh creation")
2140
+ return None
2141
+ face_svf = None
2142
+
2143
+ n_mesh_faces = len(building_mesh.faces)
2144
+
2145
+ # Map palm_solar building surface values to building mesh faces.
2146
+ # Use cached mapping if available (avoids expensive KDTree query every call)
2147
+ if len(bldg_indices) > 0:
2148
+ # Check if we have cached mesh_to_surface_idx mapping
2149
+ if (cache is not None and
2150
+ cache.mesh_to_surface_idx is not None and
2151
+ len(cache.mesh_to_surface_idx) == n_mesh_faces):
2152
+ # Use cached direct mapping: mesh face -> surface index
2153
+ mesh_to_surface_idx = cache.mesh_to_surface_idx
2154
+
2155
+ # Fast vectorized indexing using pre-computed mapping
2156
+ sw_in_direct = sw_in_direct_all[mesh_to_surface_idx]
2157
+ sw_in_diffuse = sw_in_diffuse_all[mesh_to_surface_idx]
2158
+ sw_in_reflected = sw_in_reflected_all[mesh_to_surface_idx]
2159
+ total_sw = total_sw_all[mesh_to_surface_idx]
2160
+ else:
2161
+ # Need to compute mapping (first call with this mesh)
2162
+ from scipy.spatial import cKDTree
2163
+
2164
+ # Get surface centers (only needed for KDTree building)
2165
+ surf_centers_all = model.surfaces.center.to_numpy()[:n_surfaces]
2166
+ bldg_centers = surf_centers_all[bldg_indices]
2167
+
2168
+ # Use cached geometry if available, otherwise compute from mesh
2169
+ if cache is not None and cache.mesh_face_centers is not None:
2170
+ mesh_face_centers = cache.mesh_face_centers
2171
+ else:
2172
+ mesh_face_centers = building_mesh.triangles_center
2173
+ if cache is not None:
2174
+ cache.mesh_face_centers = mesh_face_centers.copy()
2175
+ cache.mesh_face_normals = building_mesh.face_normals.copy()
2176
+
2177
+ if progress_report:
2178
+ print(f" Computing mesh-to-surface mapping (first call)...")
2179
+ print(f" palm_solar bldg centers: x=[{bldg_centers[:,0].min():.1f}, {bldg_centers[:,0].max():.1f}], "
2180
+ f"y=[{bldg_centers[:,1].min():.1f}, {bldg_centers[:,1].max():.1f}], "
2181
+ f"z=[{bldg_centers[:,2].min():.1f}, {bldg_centers[:,2].max():.1f}]")
2182
+ print(f" mesh face centers: x=[{mesh_face_centers[:,0].min():.1f}, {mesh_face_centers[:,0].max():.1f}], "
2183
+ f"y=[{mesh_face_centers[:,1].min():.1f}, {mesh_face_centers[:,1].max():.1f}], "
2184
+ f"z=[{mesh_face_centers[:,2].min():.1f}, {mesh_face_centers[:,2].max():.1f}]")
2185
+
2186
+ tree = cKDTree(bldg_centers)
2187
+ distances, nearest_idx = tree.query(mesh_face_centers, k=1)
2188
+
2189
+ if progress_report:
2190
+ print(f" KDTree match distances: min={distances.min():.2f}, mean={distances.mean():.2f}, max={distances.max():.2f}")
2191
+
2192
+ # Create direct mapping: mesh face -> surface index
2193
+ # This combines bldg_indices[nearest_idx] into a single array
2194
+ mesh_to_surface_idx = bldg_indices[nearest_idx]
2195
+
2196
+ # Cache the mapping for subsequent calls
2197
+ if cache is not None:
2198
+ cache.mesh_to_surface_idx = mesh_to_surface_idx
2199
+ cache.bldg_indices = bldg_indices
2200
+ if progress_report:
2201
+ print(f" Cached mesh-to-surface mapping for {n_mesh_faces} faces")
2202
+
2203
+ # Map irradiance arrays
2204
+ sw_in_direct = sw_in_direct_all[mesh_to_surface_idx]
2205
+ sw_in_diffuse = sw_in_diffuse_all[mesh_to_surface_idx]
2206
+ sw_in_reflected = sw_in_reflected_all[mesh_to_surface_idx]
2207
+ total_sw = total_sw_all[mesh_to_surface_idx]
2208
+ else:
2209
+ # Fallback: no building surfaces in palm_solar model (edge case)
2210
+ sw_in_direct = np.zeros(n_mesh_faces, dtype=np.float32)
2211
+ sw_in_diffuse = np.zeros(n_mesh_faces, dtype=np.float32)
2212
+ sw_in_reflected = np.zeros(n_mesh_faces, dtype=np.float32)
2213
+ total_sw = np.zeros(n_mesh_faces, dtype=np.float32)
2214
+
2215
+ # -------------------------------------------------------------------------
2216
+ # Set vertical faces on domain perimeter to NaN (matching VoxCity behavior)
2217
+ # Use cached boundary mask if available to avoid expensive mesh ops
2218
+ # -------------------------------------------------------------------------
2219
+ cache = _building_radiation_model_cache
2220
+ if cache is not None and cache.boundary_mask is not None and len(cache.boundary_mask) == n_mesh_faces:
2221
+ # Use cached boundary mask
2222
+ is_boundary_vertical = cache.boundary_mask
2223
+ else:
2224
+ # Compute and cache boundary mask (first call)
2225
+ ny_vc, nx_vc, nz = voxel_data.shape
2226
+ grid_bounds_real = np.array([
2227
+ [0.0, 0.0, 0.0],
2228
+ [nx_vc * meshsize, ny_vc * meshsize, nz * meshsize]
2229
+ ], dtype=np.float64)
2230
+ boundary_epsilon = meshsize * 0.05
2231
+
2232
+ # Use cached geometry if available, otherwise compute and cache
2233
+ if cache is not None and cache.mesh_face_centers is not None:
2234
+ mesh_face_centers = cache.mesh_face_centers
2235
+ mesh_face_normals = cache.mesh_face_normals
2236
+ else:
2237
+ mesh_face_centers = building_mesh.triangles_center
2238
+ mesh_face_normals = building_mesh.face_normals
2239
+ # Cache geometry for future calls
2240
+ if cache is not None:
2241
+ cache.mesh_face_centers = mesh_face_centers
2242
+ cache.mesh_face_normals = mesh_face_normals
2243
+
2244
+ # Detect vertical faces (normal z-component near zero)
2245
+ is_vertical = np.abs(mesh_face_normals[:, 2]) < 0.01
2246
+
2247
+ # Detect faces on domain boundary
2248
+ on_x_min = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[0, 0]) < boundary_epsilon
2249
+ on_y_min = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[0, 1]) < boundary_epsilon
2250
+ on_x_max = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[1, 0]) < boundary_epsilon
2251
+ on_y_max = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[1, 1]) < boundary_epsilon
2252
+
2253
+ is_boundary_vertical = is_vertical & (on_x_min | on_y_min | on_x_max | on_y_max)
2254
+
2255
+ # Cache the boundary mask
2256
+ if cache is not None:
2257
+ cache.boundary_mask = is_boundary_vertical
2258
+
2259
+ # Set boundary vertical faces to NaN using np.where (avoids expensive astype conversion)
2260
+ sw_in_direct = np.where(is_boundary_vertical, np.nan, sw_in_direct)
2261
+ sw_in_diffuse = np.where(is_boundary_vertical, np.nan, sw_in_diffuse)
2262
+ sw_in_reflected = np.where(is_boundary_vertical, np.nan, sw_in_reflected)
2263
+ total_sw = np.where(is_boundary_vertical, np.nan, total_sw)
2264
+
2265
+ if progress_report:
2266
+ n_boundary = np.sum(is_boundary_vertical)
2267
+ print(f" Boundary vertical faces set to NaN: {n_boundary}/{n_mesh_faces} ({100*n_boundary/n_mesh_faces:.1f}%)")
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
+
2316
+ building_mesh.metadata = {
2317
+ 'irradiance_direct': sw_in_direct,
2318
+ 'irradiance_diffuse': sw_in_diffuse,
2319
+ 'irradiance_reflected': sw_in_reflected,
2320
+ 'irradiance_total': total_sw,
2321
+ 'direct': sw_in_direct, # VoxCity API compatibility alias
2322
+ 'diffuse': sw_in_diffuse, # VoxCity API compatibility alias
2323
+ 'global': total_sw, # VoxCity API compatibility alias
2324
+ }
2325
+ if face_svf is not None:
2326
+ building_mesh.metadata['svf'] = face_svf
2327
+
2328
+ if kwargs.get('obj_export', False):
2329
+ import os
2330
+ output_dir = kwargs.get('output_directory', 'output')
2331
+ output_file_name = kwargs.get('output_file_name', 'building_solar_irradiance')
2332
+ os.makedirs(output_dir, exist_ok=True)
2333
+ try:
2334
+ building_mesh.export(f"{output_dir}/{output_file_name}.obj")
2335
+ if progress_report:
2336
+ print(f"Exported to {output_dir}/{output_file_name}.obj")
2337
+ except Exception as e:
2338
+ print(f"Error exporting mesh: {e}")
2339
+
2340
+ return building_mesh
2341
+
2342
+
2343
+ def get_cumulative_building_solar_irradiance(
2344
+ voxcity,
2345
+ building_svf_mesh,
2346
+ weather_df,
2347
+ lon: float,
2348
+ lat: float,
2349
+ tz: float,
2350
+ direct_normal_irradiance_scaling: float = 1.0,
2351
+ diffuse_irradiance_scaling: float = 1.0,
2352
+ **kwargs
2353
+ ):
2354
+ """
2355
+ GPU-accelerated cumulative solar irradiance on building surfaces.
2356
+
2357
+ This function matches the signature of voxcity.simulator.solar.get_cumulative_building_solar_irradiance
2358
+ using Taichi GPU acceleration.
2359
+
2360
+ Integrates solar irradiance over a time period from weather data,
2361
+ returning cumulative Wh/m² on building faces.
2362
+
2363
+ Args:
2364
+ voxcity: VoxCity object
2365
+ building_svf_mesh: Trimesh object with SVF in metadata
2366
+ weather_df: pandas DataFrame with 'DNI' and 'DHI' columns
2367
+ lon: Longitude in degrees
2368
+ lat: Latitude in degrees
2369
+ tz: Timezone offset in hours
2370
+ direct_normal_irradiance_scaling: Scaling factor for DNI
2371
+ diffuse_irradiance_scaling: Scaling factor for DHI
2372
+ **kwargs: Additional parameters including:
2373
+ - period_start (str): Start time 'MM-DD HH:MM:SS' (default: '01-01 00:00:00')
2374
+ - period_end (str): End time 'MM-DD HH:MM:SS' (default: '12-31 23:59:59')
2375
+ - time_step_hours (float): Time step in hours (default: 1.0)
2376
+ - use_sky_patches (bool): Use sky patch optimization (default: True)
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.
2380
+ - progress_report (bool): Print progress (default: False)
2381
+ - with_reflections (bool): Enable multi-bounce surface reflections (default: False).
2382
+ Set to True for more accurate results but slower computation.
2383
+
2384
+ Returns:
2385
+ Trimesh object with cumulative irradiance (Wh/m²) in metadata
2386
+ """
2387
+ from datetime import datetime
2388
+ import pytz
2389
+
2390
+ # Extract parameters that we pass explicitly (use pop to avoid duplicate kwargs)
2391
+ kwargs = dict(kwargs) # Copy to avoid modifying original
2392
+ period_start = kwargs.pop('period_start', '01-01 00:00:00')
2393
+ period_end = kwargs.pop('period_end', '12-31 23:59:59')
2394
+ time_step_hours = float(kwargs.pop('time_step_hours', 1.0))
2395
+ progress_report = kwargs.pop('progress_report', False)
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
2398
+
2399
+ if weather_df.empty:
2400
+ raise ValueError("No data in weather dataframe.")
2401
+
2402
+ # Parse period
2403
+ try:
2404
+ start_dt = datetime.strptime(period_start, '%m-%d %H:%M:%S')
2405
+ end_dt = datetime.strptime(period_end, '%m-%d %H:%M:%S')
2406
+ except ValueError:
2407
+ raise ValueError("period_start and period_end must be in format 'MM-DD HH:MM:SS'")
2408
+
2409
+ # Filter dataframe to period
2410
+ df = weather_df.copy()
2411
+ df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
2412
+ start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
2413
+ end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
2414
+ start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
2415
+ end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
2416
+
2417
+ if start_hour <= end_hour:
2418
+ df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
2419
+ else:
2420
+ df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
2421
+
2422
+ if df_period.empty:
2423
+ raise ValueError("No weather data in the specified period.")
2424
+
2425
+ # Localize and convert to UTC
2426
+ offset_minutes = int(tz * 60)
2427
+ local_tz = pytz.FixedOffset(offset_minutes)
2428
+ df_period_local = df_period.copy()
2429
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
2430
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
2431
+
2432
+ # Get solar positions
2433
+ solar_positions = _get_solar_positions_astral(df_period_utc.index, lon, lat)
2434
+
2435
+ # Initialize cumulative arrays
2436
+ result_mesh = building_svf_mesh.copy() if hasattr(building_svf_mesh, 'copy') else building_svf_mesh
2437
+ n_faces = len(result_mesh.faces) if hasattr(result_mesh, 'faces') else 0
2438
+
2439
+ if n_faces == 0:
2440
+ raise ValueError("Building mesh has no faces")
2441
+
2442
+ cumulative_direct = np.zeros(n_faces, dtype=np.float64)
2443
+ cumulative_diffuse = np.zeros(n_faces, dtype=np.float64)
2444
+ cumulative_global = np.zeros(n_faces, dtype=np.float64)
2445
+
2446
+ # Get SVF from mesh if available
2447
+ face_svf = None
2448
+ if hasattr(result_mesh, 'metadata') and 'svf' in result_mesh.metadata:
2449
+ face_svf = result_mesh.metadata['svf']
2450
+
2451
+ if progress_report:
2452
+ print(f"Computing cumulative irradiance for {n_faces} faces...")
2453
+
2454
+ # Extract arrays for processing
2455
+ azimuth_arr = solar_positions['azimuth'].to_numpy()
2456
+ elevation_arr = solar_positions['elevation'].to_numpy()
2457
+ dni_arr = df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling
2458
+ dhi_arr = df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling
2459
+ n_timesteps = len(azimuth_arr)
2460
+
2461
+ if use_sky_patches:
2462
+ # Use sky patch aggregation for efficiency (same as ground-level)
2463
+ from .sky import generate_sky_patches, get_tregenza_patch_index
2464
+
2465
+ sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
2466
+
2467
+ # Get method-specific parameters
2468
+ sky_kwargs = {}
2469
+ if sky_discretization.lower() == 'reinhart':
2470
+ sky_kwargs['mf'] = kwargs.pop('reinhart_mf', kwargs.pop('mf', 4))
2471
+ elif sky_discretization.lower() == 'uniform':
2472
+ sky_kwargs['n_azimuth'] = kwargs.pop('sky_n_azimuth', kwargs.pop('n_azimuth', 36))
2473
+ sky_kwargs['n_elevation'] = kwargs.pop('sky_n_elevation', kwargs.pop('n_elevation', 9))
2474
+ elif sky_discretization.lower() == 'fibonacci':
2475
+ sky_kwargs['n_patches'] = kwargs.pop('sky_n_patches', kwargs.pop('n_patches', 145))
2476
+
2477
+ # Generate sky patches using unified interface
2478
+ sky_patches = generate_sky_patches(sky_discretization, **sky_kwargs)
2479
+ patches = sky_patches.patches # (N, 2) azimuth, elevation
2480
+ directions = sky_patches.directions # (N, 3) unit vectors
2481
+
2482
+ n_patches = sky_patches.n_patches
2483
+ cumulative_dni_per_patch = np.zeros(n_patches, dtype=np.float64)
2484
+ total_cumulative_dhi = 0.0
2485
+
2486
+ # Bin sun positions to patches
2487
+ for i in range(n_timesteps):
2488
+ elev = elevation_arr[i]
2489
+ dhi = dhi_arr[i]
2490
+
2491
+ if dhi > 0:
2492
+ total_cumulative_dhi += dhi * time_step_hours
2493
+
2494
+ if elev <= 0:
2495
+ continue
2496
+
2497
+ az = azimuth_arr[i]
2498
+ dni = dni_arr[i]
2499
+
2500
+ if dni <= 0:
2501
+ continue
2502
+
2503
+ # Find nearest patch based on method
2504
+ if sky_discretization.lower() == 'tregenza':
2505
+ patch_idx = int(get_tregenza_patch_index(float(az), float(elev)))
2506
+ else:
2507
+ # For other methods, find nearest patch by direction vector
2508
+ elev_rad = np.deg2rad(elev)
2509
+ az_rad = np.deg2rad(az)
2510
+ sun_dir = np.array([
2511
+ np.cos(elev_rad) * np.sin(az_rad), # East
2512
+ np.cos(elev_rad) * np.cos(az_rad), # North
2513
+ np.sin(elev_rad) # Up
2514
+ ])
2515
+ dots = np.sum(directions * sun_dir, axis=1)
2516
+ patch_idx = int(np.argmax(dots))
2517
+
2518
+ if 0 <= patch_idx < n_patches:
2519
+ cumulative_dni_per_patch[patch_idx] += dni * time_step_hours
2520
+
2521
+ active_mask = cumulative_dni_per_patch > 0
2522
+ n_active = int(np.sum(active_mask))
2523
+
2524
+ if progress_report:
2525
+ print(f" Sky patch optimization: {n_timesteps} timesteps -> {n_active} active patches ({sky_discretization})")
2526
+ print(f" Total cumulative DHI: {total_cumulative_dhi:.1f} Wh/m²")
2527
+
2528
+ # First pass: compute diffuse component using SVF (if available) or a single call
2529
+ if face_svf is not None and len(face_svf) == n_faces:
2530
+ cumulative_diffuse = face_svf * total_cumulative_dhi
2531
+ else:
2532
+ # Compute diffuse using a single call with sun at zenith
2533
+ diffuse_mesh = get_building_solar_irradiance(
2534
+ voxcity,
2535
+ building_svf_mesh=building_svf_mesh,
2536
+ azimuth_degrees_ori=180.0,
2537
+ elevation_degrees=45.0,
2538
+ direct_normal_irradiance=0.0,
2539
+ diffuse_irradiance=1.0,
2540
+ progress_report=False,
2541
+ **kwargs
2542
+ )
2543
+ if diffuse_mesh is not None and 'diffuse' in diffuse_mesh.metadata:
2544
+ base_diffuse = diffuse_mesh.metadata['diffuse']
2545
+ cumulative_diffuse = np.nan_to_num(base_diffuse, nan=0.0) * total_cumulative_dhi
2546
+
2547
+ # Second pass: loop over active patches for direct component
2548
+ active_indices = np.where(active_mask)[0]
2549
+ for i, patch_idx in enumerate(active_indices):
2550
+ az_deg = patches[patch_idx, 0]
2551
+ el_deg = patches[patch_idx, 1]
2552
+ cumulative_dni_patch = cumulative_dni_per_patch[patch_idx]
2553
+
2554
+ irradiance_mesh = get_building_solar_irradiance(
2555
+ voxcity,
2556
+ building_svf_mesh=building_svf_mesh,
2557
+ azimuth_degrees_ori=az_deg,
2558
+ elevation_degrees=el_deg,
2559
+ direct_normal_irradiance=1.0, # Unit irradiance, scale by cumulative
2560
+ diffuse_irradiance=0.0, # Diffuse handled separately
2561
+ progress_report=False,
2562
+ **kwargs
2563
+ )
2564
+
2565
+ if irradiance_mesh is not None and hasattr(irradiance_mesh, 'metadata'):
2566
+ if 'direct' in irradiance_mesh.metadata:
2567
+ direct_vals = irradiance_mesh.metadata['direct']
2568
+ if len(direct_vals) == n_faces:
2569
+ cumulative_direct += np.nan_to_num(direct_vals, nan=0.0) * cumulative_dni_patch
2570
+
2571
+ if progress_report and ((i + 1) % max(1, len(active_indices) // 10) == 0 or i == len(active_indices) - 1):
2572
+ pct = (i + 1) * 100.0 / len(active_indices)
2573
+ print(f" Patch {i+1}/{len(active_indices)} ({pct:.1f}%)")
2574
+
2575
+ # Combine direct and diffuse
2576
+ cumulative_global = cumulative_direct + cumulative_diffuse
2577
+
2578
+ else:
2579
+ # Per-timestep path (no optimization)
2580
+ if progress_report:
2581
+ print(f" Processing {n_timesteps} timesteps (no sky patch optimization)...")
2582
+
2583
+ for t_idx, (timestamp, row) in enumerate(df_period_utc.iterrows()):
2584
+ dni = float(row['DNI']) * direct_normal_irradiance_scaling
2585
+ dhi = float(row['DHI']) * diffuse_irradiance_scaling
2586
+
2587
+ elevation = float(solar_positions.loc[timestamp, 'elevation'])
2588
+ azimuth = float(solar_positions.loc[timestamp, 'azimuth'])
2589
+
2590
+ # Skip nighttime
2591
+ if elevation <= 0 or (dni <= 0 and dhi <= 0):
2592
+ continue
2593
+
2594
+ # Compute instantaneous irradiance for this timestep
2595
+ irradiance_mesh = get_building_solar_irradiance(
2596
+ voxcity,
2597
+ building_svf_mesh=building_svf_mesh,
2598
+ azimuth_degrees_ori=azimuth,
2599
+ elevation_degrees=elevation,
2600
+ direct_normal_irradiance=dni,
2601
+ diffuse_irradiance=dhi,
2602
+ progress_report=False,
2603
+ **kwargs
2604
+ )
2605
+
2606
+ if irradiance_mesh is not None and hasattr(irradiance_mesh, 'metadata'):
2607
+ # Accumulate (convert W/m² to Wh/m² by multiplying by time_step_hours)
2608
+ if 'direct' in irradiance_mesh.metadata:
2609
+ direct_vals = irradiance_mesh.metadata['direct']
2610
+ if len(direct_vals) == n_faces:
2611
+ cumulative_direct += np.nan_to_num(direct_vals, nan=0.0) * time_step_hours
2612
+ if 'diffuse' in irradiance_mesh.metadata:
2613
+ diffuse_vals = irradiance_mesh.metadata['diffuse']
2614
+ if len(diffuse_vals) == n_faces:
2615
+ cumulative_diffuse += np.nan_to_num(diffuse_vals, nan=0.0) * time_step_hours
2616
+ if 'global' in irradiance_mesh.metadata:
2617
+ global_vals = irradiance_mesh.metadata['global']
2618
+ if len(global_vals) == n_faces:
2619
+ cumulative_global += np.nan_to_num(global_vals, nan=0.0) * time_step_hours
2620
+
2621
+ if progress_report and (t_idx + 1) % max(1, n_timesteps // 10) == 0:
2622
+ print(f" Processed {t_idx + 1}/{n_timesteps} timesteps ({100*(t_idx+1)/n_timesteps:.1f}%)")
2623
+
2624
+ # -------------------------------------------------------------------------
2625
+ # Set vertical faces on domain perimeter to NaN (matching VoxCity behavior)
2626
+ # -------------------------------------------------------------------------
2627
+ voxel_data = voxcity.voxels.classes
2628
+ meshsize = voxcity.voxels.meta.meshsize
2629
+ ny_vc, nx_vc, nz = voxel_data.shape
2630
+ grid_bounds_real = np.array([
2631
+ [0.0, 0.0, 0.0],
2632
+ [ny_vc * meshsize, nx_vc * meshsize, nz * meshsize]
2633
+ ], dtype=np.float64)
2634
+ boundary_epsilon = meshsize * 0.05
2635
+
2636
+ mesh_face_centers = result_mesh.triangles_center
2637
+ mesh_face_normals = result_mesh.face_normals
2638
+
2639
+ # Detect vertical faces (normal z-component near zero)
2640
+ is_vertical = np.abs(mesh_face_normals[:, 2]) < 0.01
2641
+
2642
+ # Detect faces on domain boundary
2643
+ on_x_min = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[0, 0]) < boundary_epsilon
2644
+ on_y_min = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[0, 1]) < boundary_epsilon
2645
+ on_x_max = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[1, 0]) < boundary_epsilon
2646
+ on_y_max = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[1, 1]) < boundary_epsilon
2647
+
2648
+ is_boundary_vertical = is_vertical & (on_x_min | on_y_min | on_x_max | on_y_max)
2649
+
2650
+ # Set boundary vertical faces to NaN
2651
+ cumulative_direct[is_boundary_vertical] = np.nan
2652
+ cumulative_diffuse[is_boundary_vertical] = np.nan
2653
+ cumulative_global[is_boundary_vertical] = np.nan
2654
+
2655
+ if progress_report:
2656
+ n_boundary = np.sum(is_boundary_vertical)
2657
+ print(f" Boundary vertical faces set to NaN: {n_boundary}/{n_faces} ({100*n_boundary/n_faces:.1f}%)")
2658
+
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', {})
2701
+ result_mesh.metadata['cumulative_direct'] = cumulative_direct
2702
+ result_mesh.metadata['cumulative_diffuse'] = cumulative_diffuse
2703
+ result_mesh.metadata['cumulative_global'] = cumulative_global
2704
+ result_mesh.metadata['direct'] = cumulative_direct # VoxCity API alias
2705
+ result_mesh.metadata['diffuse'] = cumulative_diffuse # VoxCity API alias
2706
+ result_mesh.metadata['global'] = cumulative_global # VoxCity API alias
2707
+ if face_svf is not None:
2708
+ result_mesh.metadata['svf'] = face_svf
2709
+
2710
+ if progress_report:
2711
+ valid_mask = ~np.isnan(cumulative_global)
2712
+ total_irradiance = np.nansum(cumulative_global)
2713
+ print(f"Cumulative irradiance computation complete:")
2714
+ print(f" Total faces: {n_faces}, Valid: {np.sum(valid_mask)}")
2715
+ print(f" Mean cumulative: {np.nanmean(cumulative_global):.1f} Wh/m²")
2716
+ print(f" Max cumulative: {np.nanmax(cumulative_global):.1f} Wh/m²")
2717
+
2718
+ # Export if requested
2719
+ if kwargs.get('obj_export', False):
2720
+ import os
2721
+ output_dir = kwargs.get('output_directory', 'output')
2722
+ output_file_name = kwargs.get('output_file_name', 'cumulative_building_irradiance')
2723
+ os.makedirs(output_dir, exist_ok=True)
2724
+ try:
2725
+ result_mesh.export(f"{output_dir}/{output_file_name}.obj")
2726
+ if progress_report:
2727
+ print(f"Exported to {output_dir}/{output_file_name}.obj")
2728
+ except Exception as e:
2729
+ print(f"Error exporting mesh: {e}")
2730
+
2731
+ return result_mesh
2732
+
2733
+
2734
+ def get_building_global_solar_irradiance_using_epw(
2735
+ voxcity,
2736
+ calc_type: str = 'instantaneous',
2737
+ direct_normal_irradiance_scaling: float = 1.0,
2738
+ diffuse_irradiance_scaling: float = 1.0,
2739
+ building_svf_mesh=None,
2740
+ **kwargs
2741
+ ):
2742
+ """
2743
+ GPU-accelerated building surface irradiance using EPW weather data.
2744
+
2745
+ This function matches the signature of voxcity.simulator.solar.get_building_global_solar_irradiance_using_epw
2746
+ using Taichi GPU acceleration.
2747
+
2748
+ Args:
2749
+ voxcity: VoxCity object
2750
+ calc_type: 'instantaneous' or 'cumulative'
2751
+ direct_normal_irradiance_scaling: Scaling factor for DNI
2752
+ diffuse_irradiance_scaling: Scaling factor for DHI
2753
+ building_svf_mesh: Pre-computed building mesh with SVF (optional)
2754
+ **kwargs: Additional parameters including:
2755
+ - epw_file_path (str): Path to EPW file
2756
+ - download_nearest_epw (bool): Download nearest EPW (default: False)
2757
+ - calc_time (str): For instantaneous: 'MM-DD HH:MM:SS'
2758
+ - period_start, period_end (str): For cumulative: 'MM-DD HH:MM:SS'
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.
2763
+ - progress_report (bool): Print progress
2764
+ - with_reflections (bool): Enable multi-bounce surface reflections (default: False).
2765
+ Set to True for more accurate results but slower computation.
2766
+
2767
+ Returns:
2768
+ Trimesh object with irradiance values (W/m² or Wh/m²) in metadata
2769
+ """
2770
+ from datetime import datetime
2771
+ import pytz
2772
+
2773
+ # NOTE: We frequently forward **kwargs to lower-level functions; ensure
2774
+ # we don't pass duplicate keyword args (e.g., progress_report).
2775
+ progress_report = kwargs.get('progress_report', False)
2776
+ kwargs = dict(kwargs)
2777
+ kwargs.pop('progress_report', None)
2778
+
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
+ )
2786
+
2787
+ # Create building mesh for output (just geometry, no SVF computation)
2788
+ # The RadiationModel computes SVF internally for voxel surfaces, so we don't need
2789
+ # the expensive get_surface_view_factor() call. We just need the mesh geometry.
2790
+ if building_svf_mesh is None:
2791
+ try:
2792
+ from voxcity.geoprocessor.mesh import create_voxel_mesh
2793
+ building_class_id = kwargs.get('building_class_id', -3)
2794
+ voxel_data = voxcity.voxels.classes
2795
+ meshsize = voxcity.voxels.meta.meshsize
2796
+ building_id_grid = voxcity.buildings.ids
2797
+
2798
+ building_svf_mesh = create_voxel_mesh(
2799
+ voxel_data,
2800
+ building_class_id,
2801
+ meshsize,
2802
+ building_id_grid=building_id_grid,
2803
+ mesh_type='open_air'
2804
+ )
2805
+ if progress_report:
2806
+ n_faces = len(building_svf_mesh.faces) if building_svf_mesh is not None else 0
2807
+ print(f"Created building mesh with {n_faces} faces")
2808
+ except ImportError:
2809
+ pass # Will fail later with "Building mesh has no faces" error
2810
+
2811
+ if calc_type == 'instantaneous':
2812
+ calc_time = kwargs.get('calc_time', '01-01 12:00:00')
2813
+ try:
2814
+ calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%S')
2815
+ except ValueError:
2816
+ raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'")
2817
+
2818
+ df_period = df[
2819
+ (df.index.month == calc_dt.month) &
2820
+ (df.index.day == calc_dt.day) &
2821
+ (df.index.hour == calc_dt.hour)
2822
+ ]
2823
+ if df_period.empty:
2824
+ raise ValueError("No EPW data at the specified time.")
2825
+
2826
+ # Get solar position
2827
+ offset_minutes = int(tz * 60)
2828
+ local_tz = pytz.FixedOffset(offset_minutes)
2829
+ df_local = df_period.copy()
2830
+ df_local.index = df_local.index.tz_localize(local_tz)
2831
+ df_utc = df_local.tz_convert(pytz.UTC)
2832
+
2833
+ solar_positions = _get_solar_positions_astral(df_utc.index, lon, lat)
2834
+ DNI = float(df_utc.iloc[0]['DNI']) * direct_normal_irradiance_scaling
2835
+ DHI = float(df_utc.iloc[0]['DHI']) * diffuse_irradiance_scaling
2836
+ azimuth_degrees = float(solar_positions.iloc[0]['azimuth'])
2837
+ elevation_degrees = float(solar_positions.iloc[0]['elevation'])
2838
+
2839
+ return get_building_solar_irradiance(
2840
+ voxcity,
2841
+ building_svf_mesh=building_svf_mesh,
2842
+ azimuth_degrees_ori=azimuth_degrees,
2843
+ elevation_degrees=elevation_degrees,
2844
+ direct_normal_irradiance=DNI,
2845
+ diffuse_irradiance=DHI,
2846
+ **kwargs
2847
+ )
2848
+
2849
+ elif calc_type == 'cumulative':
2850
+ period_start = kwargs.get('period_start', '01-01 00:00:00')
2851
+ period_end = kwargs.get('period_end', '12-31 23:59:59')
2852
+ time_step_hours = float(kwargs.get('time_step_hours', 1.0))
2853
+
2854
+ # Avoid passing duplicates: we pass these explicitly below.
2855
+ kwargs.pop('period_start', None)
2856
+ kwargs.pop('period_end', None)
2857
+ kwargs.pop('time_step_hours', None)
2858
+
2859
+ return get_cumulative_building_solar_irradiance(
2860
+ voxcity,
2861
+ building_svf_mesh=building_svf_mesh,
2862
+ weather_df=df,
2863
+ lon=lon,
2864
+ lat=lat,
2865
+ tz=tz,
2866
+ direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
2867
+ diffuse_irradiance_scaling=diffuse_irradiance_scaling,
2868
+ period_start=period_start,
2869
+ period_end=period_end,
2870
+ time_step_hours=time_step_hours,
2871
+ **kwargs
2872
+ )
2873
+
2874
+ else:
2875
+ raise ValueError(f"Unknown calc_type: {calc_type}. Use 'instantaneous' or 'cumulative'.")
2876
+
2877
+
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(
2892
+ voxcity,
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,
2993
+ show_plot: bool = False,
2994
+ **kwargs
2995
+ ) -> np.ndarray:
2996
+ """
2997
+ GPU-accelerated volumetric solar irradiance map at a specified height.
2998
+
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
3004
+
3005
+ Args:
3006
+ voxcity: VoxCity object
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.
3014
+ show_plot: Whether to display a matplotlib plot
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)
3024
+
3025
+ Returns:
3026
+ 2D numpy array of volumetric irradiance at the specified height (W/m²)
3027
+ """
3028
+ import math
3029
+
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)
3035
+
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
+ )
3044
+
3045
+ voxel_data = voxcity.voxels.classes
3046
+ meshsize = voxcity.voxels.meta.meshsize
3047
+ ni, nj, nk = voxel_data.shape
3048
+
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)
3056
+
3057
+ cos_elev = math.cos(elevation_rad)
3058
+ sin_elev = math.sin(elevation_rad)
3059
+
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)
3065
+
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...")
3073
+
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
+ )
3082
+
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
3087
+
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
+ )
3093
+
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
3130
+ )
3131
+
3132
+ # Compute ground_k for terrain-following extraction
3133
+ ground_k = _compute_ground_k_from_voxels(voxel_data)
3134
+
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)")
3140
+
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
+ )
3977
+
3978
+
3979
+ def save_irradiance_mesh(mesh, filepath: str) -> None:
3980
+ """
3981
+ Save irradiance mesh to pickle file.
3982
+
3983
+ Args:
3984
+ mesh: Trimesh object with irradiance metadata
3985
+ filepath: Output file path
3986
+ """
3987
+ import pickle
3988
+ with open(filepath, 'wb') as f:
3989
+ pickle.dump(mesh, f)
3990
+
3991
+
3992
+ def load_irradiance_mesh(filepath: str):
3993
+ """
3994
+ Load irradiance mesh from pickle file.
3995
+
3996
+ Args:
3997
+ filepath: Input file path
3998
+
3999
+ Returns:
4000
+ Trimesh object with irradiance metadata
4001
+ """
4002
+ import pickle
4003
+ with open(filepath, 'rb') as f:
4004
+ return pickle.load(f)
4005
+
4006
+
4007
+ # =============================================================================
4008
+ # Internal Helper Functions
4009
+ # =============================================================================
4010
+
4011
+ # Module-level cache for GPU ray tracer fields
4012
+ @dataclass
4013
+ class _CachedGPURayTracer:
4014
+ """Cached Taichi fields for GPU ray tracing."""
4015
+ is_solid_field: object # ti.field
4016
+ lad_field: object # ti.field
4017
+ transmittance_field: object # ti.field
4018
+ topo_top_field: object # ti.field
4019
+ trace_rays_kernel: object # compiled kernel
4020
+ voxel_shape: Tuple[int, int, int]
4021
+ meshsize: float
4022
+ voxel_data_id: int = 0 # id() of last voxel_data array to detect changes
4023
+
4024
+
4025
+ _gpu_ray_tracer_cache: Optional[_CachedGPURayTracer] = None
4026
+
4027
+ # Module-level cached kernel for topo computation
4028
+ _cached_topo_kernel = None
4029
+
4030
+
4031
+ def _get_cached_topo_kernel():
4032
+ """Get or create cached topography kernel."""
4033
+ global _cached_topo_kernel
4034
+ if _cached_topo_kernel is not None:
4035
+ return _cached_topo_kernel
4036
+
4037
+ import taichi as ti
4038
+ from ..init_taichi import ensure_initialized
4039
+ ensure_initialized()
4040
+
4041
+ @ti.kernel
4042
+ def _topo_kernel(
4043
+ is_solid_f: ti.template(),
4044
+ topo_f: ti.template(),
4045
+ grid_nz: ti.i32
4046
+ ):
4047
+ for i, j in topo_f:
4048
+ max_k = -1
4049
+ for k in range(grid_nz):
4050
+ if is_solid_f[i, j, k] == 1:
4051
+ max_k = k
4052
+ topo_f[i, j] = max_k
4053
+
4054
+ _cached_topo_kernel = _topo_kernel
4055
+ return _cached_topo_kernel
4056
+
4057
+
4058
+ def _compute_topo_gpu(is_solid_field, topo_top_field, nz: int):
4059
+ """Compute topography (highest solid voxel) using GPU."""
4060
+ kernel = _get_cached_topo_kernel()
4061
+ kernel(is_solid_field, topo_top_field, nz)
4062
+
4063
+
4064
+ # Module-level cached kernel for ray tracing
4065
+ _cached_trace_rays_kernel = None
4066
+
4067
+
4068
+ def _get_cached_trace_rays_kernel():
4069
+ """Get or create cached ray tracing kernel."""
4070
+ global _cached_trace_rays_kernel
4071
+ if _cached_trace_rays_kernel is not None:
4072
+ return _cached_trace_rays_kernel
4073
+
4074
+ import taichi as ti
4075
+ from ..init_taichi import ensure_initialized
4076
+ ensure_initialized()
4077
+
4078
+ @ti.kernel
4079
+ def trace_rays_kernel(
4080
+ is_solid_f: ti.template(),
4081
+ lad_f: ti.template(),
4082
+ topo_f: ti.template(),
4083
+ trans_f: ti.template(),
4084
+ sun_x: ti.f32, sun_y: ti.f32, sun_z: ti.f32,
4085
+ vhk: ti.i32, ext: ti.f32,
4086
+ dx: ti.f32, step: ti.f32, max_dist: ti.f32,
4087
+ grid_nx: ti.i32, grid_ny: ti.i32, grid_nz: ti.i32
4088
+ ):
4089
+ for i, j in trans_f:
4090
+ ground_k = topo_f[i, j]
4091
+ start_k = ground_k + vhk
4092
+ if start_k < 0:
4093
+ start_k = 0
4094
+ if start_k >= grid_nz:
4095
+ start_k = grid_nz - 1
4096
+
4097
+ while start_k < grid_nz - 1 and is_solid_f[i, j, start_k] == 1:
4098
+ start_k += 1
4099
+
4100
+ if is_solid_f[i, j, start_k] == 1:
4101
+ trans_f[i, j] = 0.0
4102
+ else:
4103
+ ox = (float(i) + 0.5) * dx
4104
+ oy = (float(j) + 0.5) * dx
4105
+ oz = (float(start_k) + 0.5) * dx
4106
+
4107
+ trans = 1.0
4108
+ t = step
4109
+
4110
+ while t < max_dist and trans > 0.001:
4111
+ px = ox + sun_x * t
4112
+ py = oy + sun_y * t
4113
+ pz = oz + sun_z * t
4114
+
4115
+ gi = int(px / dx)
4116
+ gj = int(py / dx)
4117
+ gk = int(pz / dx)
4118
+
4119
+ if gi < 0 or gi >= grid_nx or gj < 0 or gj >= grid_ny:
4120
+ break
4121
+ if gk < 0 or gk >= grid_nz:
4122
+ break
4123
+
4124
+ if is_solid_f[gi, gj, gk] == 1:
4125
+ trans = 0.0
4126
+ break
4127
+
4128
+ lad_val = lad_f[gi, gj, gk]
4129
+ if lad_val > 0.0:
4130
+ trans *= ti.exp(-ext * lad_val * step)
4131
+
4132
+ t += step
4133
+
4134
+ trans_f[i, j] = trans
4135
+
4136
+ _cached_trace_rays_kernel = trace_rays_kernel
4137
+ return _cached_trace_rays_kernel
4138
+
4139
+
4140
+ def _get_or_create_gpu_ray_tracer(
4141
+ voxel_data: np.ndarray,
4142
+ meshsize: float,
4143
+ tree_lad: float = 1.0
4144
+ ) -> _CachedGPURayTracer:
4145
+ """
4146
+ Get cached GPU ray tracer or create new one if cache is invalid.
4147
+
4148
+ The Taichi fields and kernels are expensive to create, so we cache them.
4149
+ """
4150
+ global _gpu_ray_tracer_cache
4151
+
4152
+ import taichi as ti
4153
+ from ..init_taichi import ensure_initialized
4154
+ ensure_initialized()
4155
+
4156
+ nx, ny, nz = voxel_data.shape
4157
+
4158
+ # Check if cache is valid
4159
+ if _gpu_ray_tracer_cache is not None:
4160
+ cache = _gpu_ray_tracer_cache
4161
+ if cache.voxel_shape == (nx, ny, nz) and cache.meshsize == meshsize:
4162
+ # Check if voxel data has changed (same array object = same data)
4163
+ if cache.voxel_data_id == id(voxel_data):
4164
+ # Data hasn't changed, reuse cached fields directly
4165
+ return cache
4166
+
4167
+ # Data changed, need to re-upload (but keep fields)
4168
+ # Use vectorized helper
4169
+ is_solid, lad_array = _convert_voxel_data_to_arrays(voxel_data, tree_lad)
4170
+
4171
+ cache.is_solid_field.from_numpy(is_solid)
4172
+ cache.lad_field.from_numpy(lad_array)
4173
+ cache.voxel_data_id = id(voxel_data)
4174
+
4175
+ # Recompute topo
4176
+ _compute_topo_gpu(cache.is_solid_field, cache.topo_top_field, nz)
4177
+ return cache
4178
+
4179
+ # Need to create new cache - use vectorized helper
4180
+ is_solid, lad_array = _convert_voxel_data_to_arrays(voxel_data, tree_lad)
4181
+
4182
+ # Create Taichi fields
4183
+ is_solid_field = ti.field(dtype=ti.i32, shape=(nx, ny, nz))
4184
+ lad_field = ti.field(dtype=ti.f32, shape=(nx, ny, nz))
4185
+ transmittance_field = ti.field(dtype=ti.f32, shape=(nx, ny))
4186
+ topo_top_field = ti.field(dtype=ti.i32, shape=(nx, ny))
4187
+
4188
+ is_solid_field.from_numpy(is_solid)
4189
+ lad_field.from_numpy(lad_array)
4190
+
4191
+ # Compute topography using cached kernel
4192
+ _compute_topo_gpu(is_solid_field, topo_top_field, nz)
4193
+
4194
+ # Get cached ray tracing kernel
4195
+ trace_rays_kernel = _get_cached_trace_rays_kernel()
4196
+
4197
+ # Cache it
4198
+ _gpu_ray_tracer_cache = _CachedGPURayTracer(
4199
+ is_solid_field=is_solid_field,
4200
+ lad_field=lad_field,
4201
+ transmittance_field=transmittance_field,
4202
+ topo_top_field=topo_top_field,
4203
+ trace_rays_kernel=trace_rays_kernel,
4204
+ voxel_shape=(nx, ny, nz),
4205
+ meshsize=meshsize,
4206
+ voxel_data_id=id(voxel_data)
4207
+ )
4208
+
4209
+ return _gpu_ray_tracer_cache
4210
+
4211
+
4212
+ def _compute_direct_transmittance_map_gpu(
4213
+ voxel_data: np.ndarray,
4214
+ sun_direction: Tuple[float, float, float],
4215
+ view_point_height: float,
4216
+ meshsize: float,
4217
+ tree_k: float = 0.6,
4218
+ tree_lad: float = 1.0
4219
+ ) -> np.ndarray:
4220
+ """
4221
+ Compute direct solar transmittance map using GPU ray tracing.
4222
+
4223
+ Returns a 2D array where each cell contains the transmittance (0-1)
4224
+ for direct sunlight from the given direction.
4225
+
4226
+ Uses cached Taichi fields to avoid expensive re-creation.
4227
+ """
4228
+ nx, ny, nz = voxel_data.shape
4229
+
4230
+ # Get or create cached ray tracer
4231
+ cache = _get_or_create_gpu_ray_tracer(voxel_data, meshsize, tree_lad)
4232
+
4233
+ # Run ray tracing with current sun direction
4234
+ sun_dir_x = float(sun_direction[0])
4235
+ sun_dir_y = float(sun_direction[1])
4236
+ sun_dir_z = float(sun_direction[2])
4237
+ view_height_k = max(1, int(view_point_height / meshsize))
4238
+ step_size = meshsize * 0.5
4239
+ max_trace_dist = float(max(nx, ny, nz) * meshsize * 2)
4240
+
4241
+ cache.trace_rays_kernel(
4242
+ cache.is_solid_field,
4243
+ cache.lad_field,
4244
+ cache.topo_top_field,
4245
+ cache.transmittance_field,
4246
+ sun_dir_x, sun_dir_y, sun_dir_z,
4247
+ view_height_k, tree_k,
4248
+ meshsize, step_size, max_trace_dist,
4249
+ nx, ny, nz # Grid dimensions as parameters
4250
+ )
4251
+
4252
+ return cache.transmittance_field.to_numpy()
4253
+
4254
+
4255
+ def _get_solar_positions_astral(times, lon: float, lat: float):
4256
+ """
4257
+ Compute solar azimuth and elevation using Astral library.
4258
+ """
4259
+ import pandas as pd
4260
+ try:
4261
+ from astral import Observer
4262
+ from astral.sun import elevation, azimuth
4263
+
4264
+ observer = Observer(latitude=lat, longitude=lon)
4265
+ df_pos = pd.DataFrame(index=times, columns=['azimuth', 'elevation'], dtype=float)
4266
+ for t in times:
4267
+ el = elevation(observer=observer, dateandtime=t)
4268
+ az = azimuth(observer=observer, dateandtime=t)
4269
+ df_pos.at[t, 'elevation'] = el
4270
+ df_pos.at[t, 'azimuth'] = az
4271
+ return df_pos
4272
+ except ImportError:
4273
+ raise ImportError("Astral library required for solar position calculation. Install with: pip install astral")
4274
+
4275
+
4276
+ # Public alias for VoxCity API compatibility
4277
+ def get_solar_positions_astral(times, lon: float, lat: float):
4278
+ """
4279
+ Compute solar azimuth and elevation for given times and location using Astral.
4280
+
4281
+ This function matches the signature of voxcity.simulator.solar.get_solar_positions_astral.
4282
+
4283
+ Args:
4284
+ times: Pandas DatetimeIndex of times (should be timezone-aware, preferably UTC)
4285
+ lon: Longitude in degrees
4286
+ lat: Latitude in degrees
4287
+
4288
+ Returns:
4289
+ DataFrame indexed by times with columns ['azimuth', 'elevation'] in degrees
4290
+ """
4291
+ return _get_solar_positions_astral(times, lon, lat)
4292
+
4293
+
4294
+ def _export_irradiance_to_obj(voxcity, irradiance_map: np.ndarray, output_name: str = 'irradiance', **kwargs):
4295
+ """Export irradiance map to OBJ file using VoxCity utilities."""
4296
+ try:
4297
+ from voxcity.exporter.obj import grid_to_obj
4298
+ meshsize = voxcity.voxels.meta.meshsize
4299
+ dem_grid = voxcity.dem.elevation if hasattr(voxcity, 'dem') and voxcity.dem else np.zeros_like(irradiance_map)
4300
+ output_dir = kwargs.get('output_directory', 'output')
4301
+ view_point_height = kwargs.get('view_point_height', 1.5)
4302
+ colormap = kwargs.get('colormap', 'magma')
4303
+ vmin = kwargs.get('vmin', 0.0)
4304
+ vmax = kwargs.get('vmax', float(np.nanmax(irradiance_map)) if not np.all(np.isnan(irradiance_map)) else 1.0)
4305
+ num_colors = kwargs.get('num_colors', 10)
4306
+ alpha = kwargs.get('alpha', 1.0)
4307
+
4308
+ grid_to_obj(
4309
+ irradiance_map,
4310
+ dem_grid,
4311
+ output_dir,
4312
+ output_name,
4313
+ meshsize,
4314
+ view_point_height,
4315
+ colormap_name=colormap,
4316
+ num_colors=num_colors,
4317
+ alpha=alpha,
4318
+ vmin=vmin,
4319
+ vmax=vmax
4320
+ )
4321
+ except ImportError:
4322
+ print("VoxCity exporter.obj required for OBJ export")