voxcity 1.0.13__py3-none-any.whl → 1.0.15__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2953 +0,0 @@
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
- @dataclass
37
- class LandCoverAlbedo:
38
- """
39
- Mapping of land cover classes to albedo values.
40
-
41
- Default values are based on literature values for typical urban materials.
42
- References:
43
- - Oke, T.R. (1987) Boundary Layer Climates
44
- - Sailor, D.J. (1995) Simulated urban climate response to modifications
45
- """
46
- # OpenStreetMap / Standard land cover classes (0-indexed after +1 in voxelizer)
47
- # These map to land_cover_grid values in VoxCity
48
- bareland: float = 0.20 # Class 0: Bare soil/dirt
49
- rangeland: float = 0.25 # Class 1: Grassland/rangeland
50
- shrub: float = 0.20 # Class 2: Shrubland
51
- agriculture: float = 0.20 # Class 3: Agricultural land
52
- tree: float = 0.15 # Class 4: Tree cover (ground under canopy)
53
- wetland: float = 0.12 # Class 5: Wetland
54
- mangrove: float = 0.12 # Class 6: Mangrove
55
- water: float = 0.06 # Class 7: Water bodies
56
- snow_ice: float = 0.80 # Class 8: Snow and ice
57
- developed: float = 0.20 # Class 9: Developed/paved areas
58
- road: float = 0.12 # Class 10: Roads (asphalt)
59
- building_ground: float = 0.20 # Class 11: Building footprint area
60
-
61
- # Building surfaces (walls and roofs)
62
- building_wall: float = 0.30 # Vertical building surfaces
63
- building_roof: float = 0.25 # Building rooftops
64
-
65
- # Vegetation
66
- leaf: float = 0.15 # Plant canopy (PALM default)
67
-
68
- def get_land_cover_albedo(self, class_code: int) -> float:
69
- """
70
- Get albedo value for a land cover class code.
71
-
72
- Args:
73
- class_code: Land cover class code (0-11 for standard classes)
74
-
75
- Returns:
76
- Albedo value for the class
77
- """
78
- albedo_map = {
79
- 0: self.bareland,
80
- 1: self.rangeland,
81
- 2: self.shrub,
82
- 3: self.agriculture,
83
- 4: self.tree,
84
- 5: self.wetland,
85
- 6: self.mangrove,
86
- 7: self.water,
87
- 8: self.snow_ice,
88
- 9: self.developed,
89
- 10: self.road,
90
- 11: self.building_ground,
91
- }
92
- return albedo_map.get(class_code, self.developed) # Default to developed
93
-
94
-
95
- @dataclass
96
- class VoxCityDomainResult:
97
- """Result of VoxCity to palm_solar conversion."""
98
- domain: Domain
99
- surface_land_cover: Optional[np.ndarray] = None # Land cover code per surface
100
- surface_material_type: Optional[np.ndarray] = None # 0=ground, 1=wall, 2=roof
101
-
102
-
103
- # =============================================================================
104
- # RadiationModel Caching for Cumulative Calculations
105
- # =============================================================================
106
- # The SVF and CSF matrices are geometry-dependent and expensive to compute.
107
- # We cache the RadiationModel so it can be reused across multiple solar positions.
108
-
109
- @dataclass
110
- class _CachedRadiationModel:
111
- """Cached RadiationModel with associated metadata."""
112
- model: object # RadiationModel instance
113
- valid_ground: np.ndarray # Valid ground mask
114
- ground_k: np.ndarray # Ground level k indices
115
- voxcity_shape: Tuple[int, int, int] # Shape of voxel data for cache validation
116
- meshsize: float # Meshsize for cache validation
117
- n_reflection_steps: int # Number of reflection steps used
118
- # Performance optimization: pre-computed surface-to-grid mapping
119
- grid_indices: Optional[np.ndarray] = None # (N, 2) array of (i, j) grid coords for valid ground surfaces
120
- surface_indices: Optional[np.ndarray] = None # (N,) array of surface indices matching grid_indices
121
- # Cached numpy arrays (positions/directions don't change)
122
- positions_np: Optional[np.ndarray] = None # Cached positions array
123
- directions_np: Optional[np.ndarray] = None # Cached directions array
124
-
125
-
126
- # Module-level cache for RadiationModel
127
- _radiation_model_cache: Optional[_CachedRadiationModel] = None
128
-
129
- # Module-level cache for GPU ray tracer (forward declaration, actual class defined later)
130
- _gpu_ray_tracer_cache = None
131
-
132
-
133
- def _get_or_create_radiation_model(
134
- voxcity,
135
- n_reflection_steps: int = 2,
136
- progress_report: bool = False,
137
- **kwargs
138
- ) -> Tuple[object, np.ndarray, np.ndarray]:
139
- """
140
- Get cached RadiationModel or create a new one if cache is invalid.
141
-
142
- The SVF and CSF matrices are O(n²) to compute and only depend on geometry,
143
- not solar position. This function caches the model for reuse.
144
-
145
- Args:
146
- voxcity: VoxCity object
147
- n_reflection_steps: Number of reflection bounces
148
- progress_report: Print progress messages
149
- **kwargs: Additional RadiationConfig parameters
150
-
151
- Returns:
152
- Tuple of (RadiationModel, valid_ground array, ground_k array)
153
- """
154
- global _radiation_model_cache
155
-
156
- from .radiation import RadiationModel, RadiationConfig
157
- from .domain import IUP
158
-
159
- voxel_data = voxcity.voxels.classes
160
- meshsize = voxcity.voxels.meta.meshsize
161
- ni, nj, nk = voxel_data.shape
162
-
163
- # Check if cache is valid
164
- cache_valid = False
165
- if _radiation_model_cache is not None:
166
- cache = _radiation_model_cache
167
- if (cache.voxcity_shape == voxel_data.shape and
168
- cache.meshsize == meshsize and
169
- cache.n_reflection_steps == n_reflection_steps):
170
- cache_valid = True
171
- if progress_report:
172
- print("Using cached RadiationModel (SVF/CSF already computed)")
173
-
174
- if cache_valid:
175
- return (_radiation_model_cache.model,
176
- _radiation_model_cache.valid_ground,
177
- _radiation_model_cache.ground_k)
178
-
179
- # Need to create new model
180
- if progress_report:
181
- print("Creating new RadiationModel (computing SVF/CSF matrices)...")
182
-
183
- # Get location
184
- rectangle_vertices = getattr(voxcity, 'extras', {}).get('rectangle_vertices', None)
185
- if rectangle_vertices is not None:
186
- lons = [v[0] for v in rectangle_vertices]
187
- lats = [v[1] for v in rectangle_vertices]
188
- origin_lat = np.mean(lats)
189
- origin_lon = np.mean(lons)
190
- else:
191
- origin_lat = 1.35
192
- origin_lon = 103.82
193
-
194
- # Create domain
195
- domain = Domain(
196
- nx=ni, ny=nj, nz=nk,
197
- dx=meshsize, dy=meshsize, dz=meshsize,
198
- origin_lat=origin_lat,
199
- origin_lon=origin_lon
200
- )
201
-
202
- # Convert VoxCity voxel data to domain arrays
203
- is_solid_np = np.zeros((ni, nj, nk), dtype=np.int32)
204
- lad_np = np.zeros((ni, nj, nk), dtype=np.float32)
205
- default_lad = kwargs.get('default_lad', 1.0)
206
-
207
- valid_ground = np.zeros((ni, nj), dtype=bool)
208
-
209
- for i in range(ni):
210
- for j in range(nj):
211
- for k in range(nk):
212
- voxel_val = voxel_data[i, j, k]
213
-
214
- if voxel_val == VOXCITY_BUILDING_CODE:
215
- is_solid_np[i, j, k] = 1
216
- elif voxel_val == VOXCITY_GROUND_CODE:
217
- is_solid_np[i, j, k] = 1
218
- elif voxel_val == VOXCITY_TREE_CODE:
219
- lad_np[i, j, k] = default_lad
220
- elif voxel_val > 0:
221
- is_solid_np[i, j, k] = 1
222
-
223
- # Determine valid ground cells
224
- for k in range(1, nk):
225
- curr_val = voxel_data[i, j, k]
226
- below_val = voxel_data[i, j, k - 1]
227
- if curr_val in (0, VOXCITY_TREE_CODE) and below_val not in (0, VOXCITY_TREE_CODE):
228
- if below_val in (7, 8, 9) or below_val < 0:
229
- valid_ground[i, j] = False
230
- else:
231
- valid_ground[i, j] = True
232
- break
233
-
234
- # Set domain arrays
235
- _set_solid_array(domain, is_solid_np)
236
- domain.set_lad_from_array(lad_np)
237
- _update_topo_from_solid(domain)
238
-
239
- # Create RadiationModel
240
- config = RadiationConfig(
241
- n_reflection_steps=n_reflection_steps,
242
- n_azimuth=kwargs.get('n_azimuth', 40),
243
- n_elevation=kwargs.get('n_elevation', 10)
244
- )
245
-
246
- model = RadiationModel(domain, config)
247
-
248
- # Compute SVF (this is the expensive part)
249
- if progress_report:
250
- print("Computing Sky View Factors...")
251
- model.compute_svf()
252
-
253
- # Pre-compute ground_k for surface mapping
254
- n_surfaces = model.surfaces.count
255
- positions = model.surfaces.position.to_numpy()[:n_surfaces]
256
- directions = model.surfaces.direction.to_numpy()[:n_surfaces]
257
-
258
- ground_k = np.full((ni, nj), -1, dtype=np.int32)
259
- for idx in range(n_surfaces):
260
- pos_i, pos_j, k = positions[idx]
261
- direction = directions[idx]
262
- if direction == IUP:
263
- ii, jj = int(pos_i), int(pos_j)
264
- if 0 <= ii < ni and 0 <= jj < nj:
265
- if not valid_ground[ii, jj]:
266
- continue
267
- if ground_k[ii, jj] < 0 or k < ground_k[ii, jj]:
268
- ground_k[ii, jj] = int(k)
269
-
270
- # Pre-compute surface-to-grid mapping for fast vectorized extraction
271
- # This maps which surface indices correspond to which grid cells
272
- if progress_report:
273
- print("Pre-computing surface-to-grid mapping...")
274
- surface_to_grid_map = {} # (i, j) -> surface_idx
275
- for idx in range(n_surfaces):
276
- direction = directions[idx]
277
- if direction == IUP:
278
- pi = int(positions[idx, 0])
279
- pj = int(positions[idx, 1])
280
- pk = int(positions[idx, 2])
281
- if 0 <= pi < ni and 0 <= pj < nj:
282
- if valid_ground[pi, pj] and pk == ground_k[pi, pj]:
283
- surface_to_grid_map[(pi, pj)] = idx
284
-
285
- # Convert to arrays for vectorized access
286
- if surface_to_grid_map:
287
- grid_indices = np.array(list(surface_to_grid_map.keys()), dtype=np.int32)
288
- surface_indices = np.array(list(surface_to_grid_map.values()), dtype=np.int32)
289
- else:
290
- grid_indices = np.empty((0, 2), dtype=np.int32)
291
- surface_indices = np.empty((0,), dtype=np.int32)
292
-
293
- # Cache the model with pre-computed mappings
294
- _radiation_model_cache = _CachedRadiationModel(
295
- model=model,
296
- valid_ground=valid_ground,
297
- ground_k=ground_k,
298
- voxcity_shape=voxel_data.shape,
299
- meshsize=meshsize,
300
- n_reflection_steps=n_reflection_steps,
301
- grid_indices=grid_indices,
302
- surface_indices=surface_indices,
303
- positions_np=positions,
304
- directions_np=directions
305
- )
306
-
307
- if progress_report:
308
- print(f"RadiationModel cached. Valid ground cells: {np.sum(valid_ground)}, mapped surfaces: {len(surface_indices)}")
309
-
310
- return model, valid_ground, ground_k
311
-
312
-
313
- def clear_radiation_model_cache():
314
- """Clear the cached RadiationModel to free memory or force recomputation."""
315
- global _radiation_model_cache
316
- _radiation_model_cache = None
317
-
318
-
319
- def clear_gpu_ray_tracer_cache():
320
- """Clear the cached GPU ray tracer fields to free memory or force recomputation."""
321
- global _gpu_ray_tracer_cache
322
- _gpu_ray_tracer_cache = None
323
-
324
-
325
- def clear_all_caches():
326
- """Clear all GPU caches (RadiationModel, Building RadiationModel, GPU ray tracer)."""
327
- global _radiation_model_cache, _building_radiation_model_cache, _gpu_ray_tracer_cache
328
- _radiation_model_cache = None
329
- _building_radiation_model_cache = None
330
- _gpu_ray_tracer_cache = None
331
-
332
-
333
- # =============================================================================
334
- # Building RadiationModel Caching
335
- # =============================================================================
336
- # Separate cache for building solar irradiance calculations
337
-
338
- @dataclass
339
- class _CachedBuildingRadiationModel:
340
- """Cached RadiationModel for building surface calculations."""
341
- model: object # RadiationModel instance
342
- voxcity_shape: Tuple[int, int, int] # Shape of voxel data for cache validation
343
- meshsize: float # Meshsize for cache validation
344
- n_reflection_steps: int # Number of reflection steps used
345
- is_building_surf: np.ndarray # Boolean mask for building surfaces
346
- building_svf_mesh: object # Building mesh (can be None)
347
- # Performance optimization: pre-computed mesh face to surface mapping
348
- bldg_indices: Optional[np.ndarray] = None # Indices of building surfaces
349
- mesh_to_surface_idx: Optional[np.ndarray] = None # Direct mapping: mesh face -> surface index
350
- # Cached mesh geometry to avoid recomputing each call
351
- mesh_face_centers: Optional[np.ndarray] = None # Pre-computed triangles_center
352
- mesh_face_normals: Optional[np.ndarray] = None # Pre-computed face_normals
353
- boundary_mask: Optional[np.ndarray] = None # Pre-computed boundary vertical face mask
354
- # Cached building mesh (expensive to create, ~2.4s)
355
- cached_building_mesh: object = None # Pre-computed building mesh from create_voxel_mesh
356
-
357
-
358
- # Module-level cache for Building RadiationModel
359
- _building_radiation_model_cache: Optional[_CachedBuildingRadiationModel] = None
360
-
361
-
362
- def _get_or_create_building_radiation_model(
363
- voxcity,
364
- n_reflection_steps: int = 2,
365
- progress_report: bool = False,
366
- building_class_id: int = -3,
367
- **kwargs
368
- ) -> Tuple[object, np.ndarray]:
369
- """
370
- Get cached RadiationModel for building surfaces or create a new one.
371
-
372
- Args:
373
- voxcity: VoxCity object
374
- n_reflection_steps: Number of reflection bounces
375
- progress_report: Print progress messages
376
- building_class_id: Building voxel class code
377
- **kwargs: Additional RadiationConfig parameters
378
-
379
- Returns:
380
- Tuple of (RadiationModel, is_building_surf boolean array)
381
- """
382
- global _building_radiation_model_cache
383
-
384
- from .radiation import RadiationModel, RadiationConfig
385
-
386
- voxel_data = voxcity.voxels.classes
387
- meshsize = voxcity.voxels.meta.meshsize
388
- ny_vc, nx_vc, nz = voxel_data.shape
389
-
390
- # Check if cache is valid
391
- # A cached model with reflections (n_reflection_steps > 0) can be reused for non-reflection calls
392
- # But a cached model without reflections cannot be used for reflection calls
393
- cache_valid = False
394
- if _building_radiation_model_cache is not None:
395
- cache = _building_radiation_model_cache
396
- if (cache.voxcity_shape == voxel_data.shape and
397
- cache.meshsize == meshsize):
398
- # Cache is valid if:
399
- # 1. We don't need reflections (n_reflection_steps=0), OR
400
- # 2. Cached model has reflections enabled (can handle any n_reflection_steps)
401
- if n_reflection_steps == 0 or cache.n_reflection_steps > 0:
402
- cache_valid = True
403
- if progress_report:
404
- print("Using cached Building RadiationModel (SVF/CSF already computed)")
405
-
406
- if cache_valid:
407
- return (_building_radiation_model_cache.model,
408
- _building_radiation_model_cache.is_building_surf)
409
-
410
- # Need to create new model
411
- if progress_report:
412
- print("Creating new Building RadiationModel (computing SVF/CSF matrices)...")
413
-
414
- # Get location
415
- rectangle_vertices = getattr(voxcity, 'extras', {}).get('rectangle_vertices', None)
416
- if rectangle_vertices is not None:
417
- lons = [v[0] for v in rectangle_vertices]
418
- lats = [v[1] for v in rectangle_vertices]
419
- origin_lat = np.mean(lats)
420
- origin_lon = np.mean(lons)
421
- else:
422
- origin_lat = 1.35
423
- origin_lon = 103.82
424
-
425
- # Create domain - consistent with ground-level model
426
- # VoxCity uses [row, col, z] = [i, j, k] convention
427
- # We create domain with nx=ny_vc, ny=nx_vc to match the palm_solar convention
428
- # but keep the same indexing as the ground model for consistency
429
- ni, nj, nk = ny_vc, nx_vc, nz # Rename for clarity (matches ground model naming)
430
-
431
- domain = Domain(
432
- nx=ni, ny=nj, nz=nk,
433
- dx=meshsize, dy=meshsize, dz=meshsize,
434
- origin_lat=origin_lat,
435
- origin_lon=origin_lon
436
- )
437
-
438
- # Convert VoxCity voxel data to domain arrays
439
- # Use the same convention as ground-level model: direct indexing without swap
440
- is_solid_np = np.zeros((ni, nj, nk), dtype=np.int32)
441
- lad_np = np.zeros((ni, nj, nk), dtype=np.float32)
442
- default_lad = kwargs.get('default_lad', 2.0)
443
-
444
- for i in range(ni):
445
- for j in range(nj):
446
- for z in range(nk):
447
- voxel_val = voxel_data[i, j, z]
448
-
449
- if voxel_val == VOXCITY_BUILDING_CODE:
450
- is_solid_np[i, j, z] = 1
451
- elif voxel_val == VOXCITY_GROUND_CODE:
452
- is_solid_np[i, j, z] = 1
453
- elif voxel_val == VOXCITY_TREE_CODE:
454
- lad_np[i, j, z] = default_lad
455
- elif voxel_val > 0:
456
- is_solid_np[i, j, z] = 1
457
-
458
- # Set domain arrays
459
- _set_solid_array(domain, is_solid_np)
460
- domain.set_lad_from_array(lad_np)
461
- _update_topo_from_solid(domain)
462
-
463
- # When n_reflection_steps=0, disable surface reflections to skip expensive SVF matrix computation
464
- surface_reflections = n_reflection_steps > 0
465
-
466
- config = RadiationConfig(
467
- n_reflection_steps=n_reflection_steps,
468
- n_azimuth=40,
469
- n_elevation=10,
470
- surface_reflections=surface_reflections, # Disable when no reflections needed
471
- cache_svf_matrix=surface_reflections, # Skip SVF matrix when reflections disabled
472
- )
473
-
474
- model = RadiationModel(domain, config)
475
-
476
- # Compute SVF (expensive! but only for sky view, not surface-to-surface when disabled)
477
- if progress_report:
478
- print("Computing Sky View Factors...")
479
- model.compute_svf()
480
-
481
- # Pre-compute building surface mask
482
- n_surfaces = model.surfaces.count
483
- surf_positions_all = model.surfaces.position.to_numpy()[:n_surfaces]
484
-
485
- is_building_surf = np.zeros(n_surfaces, dtype=bool)
486
- for s_idx in range(n_surfaces):
487
- i_idx, j_idx, z_idx = surf_positions_all[s_idx]
488
- i, j, z = int(i_idx), int(j_idx), int(z_idx)
489
- if 0 <= i < ni and 0 <= j < nj and 0 <= z < nk:
490
- if voxel_data[i, j, z] == building_class_id:
491
- is_building_surf[s_idx] = True
492
-
493
- if progress_report:
494
- print(f"Building RadiationModel cached. Building surfaces: {np.sum(is_building_surf)}/{n_surfaces}")
495
-
496
- # Pre-compute bldg_indices for caching
497
- bldg_indices = np.where(is_building_surf)[0]
498
-
499
- # Cache the model
500
- _building_radiation_model_cache = _CachedBuildingRadiationModel(
501
- model=model,
502
- voxcity_shape=voxel_data.shape,
503
- meshsize=meshsize,
504
- n_reflection_steps=n_reflection_steps,
505
- is_building_surf=is_building_surf,
506
- building_svf_mesh=None,
507
- bldg_indices=bldg_indices,
508
- mesh_to_surface_idx=None # Will be computed on first use with a specific mesh
509
- )
510
-
511
- return model, is_building_surf
512
-
513
-
514
- def clear_building_radiation_model_cache():
515
- """Clear the cached Building RadiationModel to free memory."""
516
- global _building_radiation_model_cache
517
- _building_radiation_model_cache = None
518
-
519
-
520
- def clear_all_radiation_caches():
521
- """Clear all cached RadiationModels to free GPU memory."""
522
- clear_radiation_model_cache()
523
- clear_building_radiation_model_cache()
524
- land_cover_albedo: Optional[LandCoverAlbedo] = None
525
-
526
-
527
- def load_voxcity(filepath: Union[str, Path]):
528
- """
529
- Load VoxCity data from pickle file.
530
-
531
- Attempts to use the voxcity package if available, otherwise
532
- loads as raw pickle with fallback handling.
533
-
534
- Args:
535
- filepath: Path to the VoxCity pickle file
536
-
537
- Returns:
538
- VoxCity object or dict containing the model data
539
- """
540
- import pickle
541
-
542
- filepath = Path(filepath)
543
-
544
- try:
545
- # Try using voxcity package loader
546
- from voxcity.generator.io import load_voxcity as voxcity_load
547
- return voxcity_load(str(filepath))
548
- except ImportError:
549
- # Fallback: load as raw pickle
550
- with open(filepath, 'rb') as f:
551
- data = pickle.load(f)
552
-
553
- # Handle wrapper dict format (has 'voxcity' key)
554
- if isinstance(data, dict) and 'voxcity' in data:
555
- return data['voxcity']
556
-
557
- return data
558
-
559
-
560
- def convert_voxcity_to_domain(
561
- voxcity_data,
562
- default_lad: float = 2.0,
563
- land_cover_albedo: Optional[LandCoverAlbedo] = None,
564
- origin_lat: Optional[float] = None,
565
- origin_lon: Optional[float] = None
566
- ) -> VoxCityDomainResult:
567
- """
568
- Convert VoxCity voxel grid to palm_solar Domain with material properties.
569
-
570
- This function:
571
- 1. Extracts voxel grid, dimensions, and location from VoxCity data
572
- 2. Creates a palm_solar Domain with solid cells and LAD
573
- 3. Tracks land cover information for surface albedo assignment
574
-
575
- Args:
576
- voxcity_data: VoxCity object or dict from load_voxcity()
577
- default_lad: Default Leaf Area Density for tree voxels (m²/m³)
578
- land_cover_albedo: Custom land cover to albedo mapping
579
- origin_lat: Override latitude (degrees)
580
- origin_lon: Override longitude (degrees)
581
-
582
- Returns:
583
- VoxCityDomainResult with Domain and material information
584
- """
585
- if land_cover_albedo is None:
586
- land_cover_albedo = LandCoverAlbedo()
587
-
588
- # Extract data from VoxCity object or dict
589
- if hasattr(voxcity_data, 'voxels'):
590
- # New VoxCity dataclass format
591
- voxel_grid = voxcity_data.voxels.classes
592
- meshsize = voxcity_data.voxels.meta.meshsize
593
- land_cover_grid = voxcity_data.land_cover.classes
594
- dem_grid = voxcity_data.dem.elevation
595
- extras = getattr(voxcity_data, 'extras', {})
596
- rectangle_vertices = extras.get('rectangle_vertices', None)
597
- else:
598
- # Legacy dict format
599
- voxel_grid = voxcity_data['voxcity_grid']
600
- meshsize = voxcity_data['meshsize']
601
- land_cover_grid = voxcity_data.get('land_cover_grid', None)
602
- dem_grid = voxcity_data.get('dem_grid', None)
603
- rectangle_vertices = voxcity_data.get('rectangle_vertices', None)
604
-
605
- # Get grid dimensions (VoxCity is [row, col, z] = [y, x, z])
606
- ny, nx, nz = voxel_grid.shape
607
-
608
- # Use meshsize as voxel size
609
- dx = dy = dz = float(meshsize)
610
-
611
- # Determine location
612
- if origin_lat is None or origin_lon is None:
613
- if rectangle_vertices is not None and len(rectangle_vertices) > 0:
614
- lons = [v[0] for v in rectangle_vertices]
615
- lats = [v[1] for v in rectangle_vertices]
616
- if origin_lon is None:
617
- origin_lon = np.mean(lons)
618
- if origin_lat is None:
619
- origin_lat = np.mean(lats)
620
- else:
621
- # Default to Singapore
622
- if origin_lat is None:
623
- origin_lat = 1.35
624
- if origin_lon is None:
625
- origin_lon = 103.82
626
-
627
- print(f"VoxCity grid shape: ({ny}, {nx}, {nz})")
628
- print(f"Voxel size: {dx} m")
629
- print(f"Domain size: {nx*dx:.1f} x {ny*dy:.1f} x {nz*dz:.1f} m")
630
- print(f"Location: lat={origin_lat:.4f}, lon={origin_lon:.4f}")
631
-
632
- # Create palm_solar Domain
633
- domain = Domain(
634
- nx=nx, ny=ny, nz=nz,
635
- dx=dx, dy=dy, dz=dz,
636
- origin=(0.0, 0.0, 0.0),
637
- origin_lat=origin_lat,
638
- origin_lon=origin_lon
639
- )
640
-
641
- # Create arrays for conversion
642
- is_solid_np = np.zeros((nx, ny, nz), dtype=np.int32)
643
- lad_np = np.zeros((nx, ny, nz), dtype=np.float32)
644
-
645
- # Surface land cover tracking (indexed by grid position)
646
- # This will store the land cover code for ground-level surfaces
647
- surface_land_cover_grid = np.full((nx, ny), -1, dtype=np.int32)
648
-
649
- # Convert from VoxCity [row, col, z] to palm_solar [x, y, z]
650
- for row in range(ny):
651
- for col in range(nx):
652
- x_idx = col
653
- y_idx = row
654
-
655
- # Get land cover for this column (from ground surface)
656
- if land_cover_grid is not None:
657
- # Land cover grid is [row, col], values are class codes
658
- lc_val = land_cover_grid[row, col]
659
- if lc_val > 0:
660
- # VoxCity adds +1 to land cover codes, so subtract 1
661
- surface_land_cover_grid[x_idx, y_idx] = int(lc_val) - 1
662
- else:
663
- surface_land_cover_grid[x_idx, y_idx] = 9 # Default: developed
664
-
665
- for z in range(nz):
666
- voxel_val = voxel_grid[row, col, z]
667
-
668
- if voxel_val == VOXCITY_BUILDING_CODE:
669
- is_solid_np[x_idx, y_idx, z] = 1
670
- elif voxel_val == VOXCITY_GROUND_CODE:
671
- is_solid_np[x_idx, y_idx, z] = 1
672
- elif voxel_val == VOXCITY_TREE_CODE:
673
- lad_np[x_idx, y_idx, z] = default_lad
674
- elif voxel_val > 0:
675
- # Positive values are land cover codes on ground
676
- is_solid_np[x_idx, y_idx, z] = 1
677
-
678
- # Set domain arrays
679
- _set_solid_array(domain, is_solid_np)
680
- domain.set_lad_from_array(lad_np)
681
- _update_topo_from_solid(domain)
682
-
683
- # Count statistics
684
- solid_count = is_solid_np.sum()
685
- lad_count = (lad_np > 0).sum()
686
- print(f"Solid voxels: {solid_count:,}")
687
- print(f"Vegetation voxels (LAD > 0): {lad_count:,}")
688
-
689
- return VoxCityDomainResult(
690
- domain=domain,
691
- surface_land_cover=surface_land_cover_grid,
692
- land_cover_albedo=land_cover_albedo
693
- )
694
-
695
-
696
- def apply_voxcity_albedo(
697
- model,
698
- voxcity_result: VoxCityDomainResult
699
- ) -> None:
700
- """
701
- Apply VoxCity land cover-based albedo values to radiation model surfaces.
702
-
703
- This function sets surface albedo values based on:
704
- - Land cover class for ground surfaces
705
- - Building wall/roof albedo for building surfaces
706
-
707
- Args:
708
- model: RadiationModel instance (after surface extraction)
709
- voxcity_result: Result from convert_voxcity_to_domain()
710
- """
711
- import taichi as ti
712
- from ..init_taichi import ensure_initialized
713
- ensure_initialized()
714
-
715
- if voxcity_result.surface_land_cover is None:
716
- print("Warning: No land cover data available, using default albedos")
717
- return
718
-
719
- domain = voxcity_result.domain
720
- lc_grid = voxcity_result.surface_land_cover
721
- lc_albedo = voxcity_result.land_cover_albedo
722
-
723
- # Get surface data
724
- n_surfaces = model.surfaces.n_surfaces[None]
725
- max_surfaces = model.surfaces.max_surfaces
726
- positions = model.surfaces.position.to_numpy()[:n_surfaces]
727
- directions = model.surfaces.direction.to_numpy()[:n_surfaces]
728
-
729
- # Create albedo array with full size (must match Taichi field shape)
730
- albedo_values = np.zeros(max_surfaces, dtype=np.float32)
731
-
732
- # Direction codes
733
- IUP = 0
734
- IDOWN = 1
735
-
736
- for idx in range(n_surfaces):
737
- i, j, k = positions[idx]
738
- direction = directions[idx]
739
-
740
- if direction == IUP: # Upward facing
741
- if k == 0 or k == 1:
742
- # Ground level - use land cover albedo
743
- lc_code = lc_grid[i, j]
744
- if lc_code >= 0:
745
- albedo_values[idx] = lc_albedo.get_land_cover_albedo(lc_code)
746
- else:
747
- albedo_values[idx] = lc_albedo.developed
748
- else:
749
- # Roof
750
- albedo_values[idx] = lc_albedo.building_roof
751
- elif direction == IDOWN: # Downward facing
752
- albedo_values[idx] = lc_albedo.building_wall
753
- else: # Walls (N, S, E, W)
754
- albedo_values[idx] = lc_albedo.building_wall
755
-
756
- # Apply albedo values to surfaces
757
- model.surfaces.albedo.from_numpy(albedo_values)
758
-
759
- # Print summary
760
- unique_albedos = np.unique(albedo_values[:n_surfaces])
761
- print(f"Applied {len(unique_albedos)} unique albedo values to {n_surfaces} surfaces")
762
-
763
-
764
- def _set_solid_array(domain: Domain, solid_array: np.ndarray) -> None:
765
- """Set domain solid cells from numpy array."""
766
- import taichi as ti
767
- from ..init_taichi import ensure_initialized
768
- ensure_initialized()
769
-
770
- @ti.kernel
771
- def _set_solid_kernel(domain: ti.template(), solid: ti.types.ndarray()):
772
- for i, j, k in domain.is_solid:
773
- domain.is_solid[i, j, k] = solid[i, j, k]
774
-
775
- _set_solid_kernel(domain, solid_array)
776
-
777
-
778
- def _update_topo_from_solid(domain: Domain) -> None:
779
- """Update topography field from solid array."""
780
- import taichi as ti
781
- from ..init_taichi import ensure_initialized
782
- ensure_initialized()
783
-
784
- @ti.kernel
785
- def _update_topo_kernel(domain: ti.template()):
786
- for i, j in domain.topo_top:
787
- max_k = 0
788
- for k in range(domain.nz):
789
- if domain.is_solid[i, j, k] == 1:
790
- max_k = k
791
- domain.topo_top[i, j] = max_k
792
-
793
- _update_topo_kernel(domain)
794
-
795
-
796
- def create_radiation_config_for_voxcity(
797
- land_cover_albedo: Optional[LandCoverAlbedo] = None,
798
- **kwargs
799
- ) -> RadiationConfig:
800
- """
801
- Create a RadiationConfig suitable for VoxCity simulations.
802
-
803
- This sets appropriate default values for urban environments.
804
-
805
- Args:
806
- land_cover_albedo: Land cover albedo mapping (for reference)
807
- **kwargs: Additional RadiationConfig parameters
808
-
809
- Returns:
810
- RadiationConfig instance
811
- """
812
- if land_cover_albedo is None:
813
- land_cover_albedo = LandCoverAlbedo()
814
-
815
- # Set defaults suitable for urban environments
816
- defaults = {
817
- 'albedo_ground': land_cover_albedo.developed,
818
- 'albedo_wall': land_cover_albedo.building_wall,
819
- 'albedo_roof': land_cover_albedo.building_roof,
820
- 'albedo_leaf': land_cover_albedo.leaf,
821
- 'n_azimuth': 40, # Reduced for faster computation
822
- 'n_elevation': 10,
823
- 'n_reflection_steps': 2,
824
- }
825
-
826
- # Override with user-provided values
827
- defaults.update(kwargs)
828
-
829
- return RadiationConfig(**defaults)
830
-
831
-
832
- def _compute_ground_irradiance_with_reflections(
833
- voxcity,
834
- azimuth_degrees_ori: float,
835
- elevation_degrees: float,
836
- direct_normal_irradiance: float,
837
- diffuse_irradiance: float,
838
- view_point_height: float = 1.5,
839
- n_reflection_steps: int = 2,
840
- progress_report: bool = False,
841
- **kwargs
842
- ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
843
- """
844
- Compute ground-level irradiance using full RadiationModel with reflections.
845
-
846
- Uses a cached RadiationModel to avoid recomputing SVF/CSF matrices for each
847
- solar position. The geometry-dependent matrices are computed once and reused.
848
-
849
- Note: The diffuse component includes sky diffuse + multi-bounce surface reflections +
850
- canopy scattering, as computed by the RadiationModel.
851
-
852
- Args:
853
- voxcity: VoxCity object
854
- azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
855
- elevation_degrees: Solar elevation in degrees above horizon
856
- direct_normal_irradiance: DNI in W/m²
857
- diffuse_irradiance: DHI in W/m²
858
- view_point_height: Observer height above ground (default: 1.5)
859
- n_reflection_steps: Number of reflection bounces (default: 2)
860
- progress_report: Print progress (default: False)
861
- **kwargs: Additional parameters
862
-
863
- Returns:
864
- Tuple of (direct_map, diffuse_map, reflected_map) as 2D numpy arrays
865
- """
866
- from .domain import IUP
867
-
868
- voxel_data = voxcity.voxels.classes
869
- ni, nj, nk = voxel_data.shape
870
-
871
- # Remove parameters that we pass explicitly to avoid duplicates
872
- filtered_kwargs = {k: v for k, v in kwargs.items()
873
- if k not in ('n_reflection_steps', 'progress_report', 'view_point_height')}
874
-
875
- # Get or create cached RadiationModel (SVF/CSF only computed once)
876
- model, valid_ground, ground_k = _get_or_create_radiation_model(
877
- voxcity,
878
- n_reflection_steps=n_reflection_steps,
879
- progress_report=progress_report,
880
- **filtered_kwargs
881
- )
882
-
883
- # Set solar position for this timestep
884
- azimuth_degrees = 180 - azimuth_degrees_ori
885
- azimuth_radians = np.deg2rad(azimuth_degrees)
886
- elevation_radians = np.deg2rad(elevation_degrees)
887
-
888
- sun_dir_x = np.cos(elevation_radians) * np.cos(azimuth_radians)
889
- sun_dir_y = np.cos(elevation_radians) * np.sin(azimuth_radians)
890
- sun_dir_z = np.sin(elevation_radians)
891
-
892
- # Set sun direction and cos_zenith directly on the SolarCalculator fields
893
- model.solar_calc.sun_direction[None] = (sun_dir_x, sun_dir_y, sun_dir_z)
894
- model.solar_calc.cos_zenith[None] = np.sin(elevation_radians) # cos(zenith) = sin(elevation)
895
- model.solar_calc.sun_up[None] = 1 if elevation_degrees > 0 else 0
896
-
897
- # Compute shortwave radiation (uses cached SVF/CSF matrices)
898
- model.compute_shortwave_radiation(
899
- sw_direct=direct_normal_irradiance,
900
- sw_diffuse=diffuse_irradiance
901
- )
902
-
903
- # Extract surface irradiance using cached mapping for vectorized extraction
904
- # This is much faster than iterating through all surfaces
905
- n_surfaces = model.surfaces.count
906
-
907
- # Initialize output arrays
908
- direct_map = np.full((ni, nj), np.nan, dtype=np.float32)
909
- diffuse_map = np.full((ni, nj), np.nan, dtype=np.float32)
910
- reflected_map = np.zeros((ni, nj), dtype=np.float32)
911
-
912
- # Use pre-computed surface-to-grid mapping if available (from cache)
913
- if (_radiation_model_cache is not None and
914
- _radiation_model_cache.grid_indices is not None and
915
- len(_radiation_model_cache.grid_indices) > 0):
916
-
917
- grid_indices = _radiation_model_cache.grid_indices
918
- surface_indices = _radiation_model_cache.surface_indices
919
-
920
- # Extract only the irradiance values we need (vectorized)
921
- sw_in_direct = model.surfaces.sw_in_direct.to_numpy()
922
- sw_in_diffuse = model.surfaces.sw_in_diffuse.to_numpy()
923
-
924
- # Vectorized assignment using pre-computed indices
925
- direct_map[grid_indices[:, 0], grid_indices[:, 1]] = sw_in_direct[surface_indices]
926
- diffuse_map[grid_indices[:, 0], grid_indices[:, 1]] = sw_in_diffuse[surface_indices]
927
- else:
928
- # Fallback to original loop if no cached mapping
929
- from .domain import IUP
930
- positions = model.surfaces.position.to_numpy()[:n_surfaces]
931
- directions = model.surfaces.direction.to_numpy()[:n_surfaces]
932
- sw_in_direct = model.surfaces.sw_in_direct.to_numpy()[:n_surfaces]
933
- sw_in_diffuse = model.surfaces.sw_in_diffuse.to_numpy()[:n_surfaces]
934
-
935
- for idx in range(n_surfaces):
936
- pos_i, pos_j, k = positions[idx]
937
- direction = directions[idx]
938
-
939
- if direction == IUP:
940
- ii, jj = int(pos_i), int(pos_j)
941
- if 0 <= ii < ni and 0 <= jj < nj:
942
- if not valid_ground[ii, jj]:
943
- continue
944
- if int(k) == ground_k[ii, jj]:
945
- if np.isnan(direct_map[ii, jj]):
946
- direct_map[ii, jj] = sw_in_direct[idx]
947
- diffuse_map[ii, jj] = sw_in_diffuse[idx]
948
-
949
- # Flip to match VoxCity coordinate system
950
- direct_map = np.flipud(direct_map)
951
- diffuse_map = np.flipud(diffuse_map)
952
- reflected_map = np.flipud(reflected_map)
953
-
954
- return direct_map, diffuse_map, reflected_map
955
-
956
-
957
- # =============================================================================
958
- # VoxCity API-Compatible Solar Irradiance Functions
959
- # =============================================================================
960
- # These functions match the voxcity.simulator.solar API signatures for
961
- # drop-in replacement with GPU acceleration.
962
-
963
- def get_direct_solar_irradiance_map(
964
- voxcity,
965
- azimuth_degrees_ori: float,
966
- elevation_degrees: float,
967
- direct_normal_irradiance: float,
968
- show_plot: bool = False,
969
- with_reflections: bool = False,
970
- **kwargs
971
- ) -> np.ndarray:
972
- """
973
- GPU-accelerated direct horizontal irradiance map computation.
974
-
975
- This function matches the signature of voxcity.simulator.solar.get_direct_solar_irradiance_map
976
- using Taichi GPU acceleration.
977
-
978
- Args:
979
- voxcity: VoxCity object
980
- azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
981
- elevation_degrees: Solar elevation in degrees above horizon
982
- direct_normal_irradiance: DNI in W/m²
983
- show_plot: Whether to display a matplotlib plot
984
- with_reflections: If True, use full RadiationModel with multi-bounce
985
- reflections. If False (default), use simple ray-tracing for
986
- faster but less accurate results.
987
- **kwargs: Additional parameters including:
988
- - view_point_height (float): Observer height above ground (default: 1.5)
989
- - tree_k (float): Tree extinction coefficient (default: 0.6)
990
- - tree_lad (float): Leaf area density (default: 1.0)
991
- - colormap (str): Matplotlib colormap name (default: 'magma')
992
- - vmin, vmax (float): Colormap limits
993
- - obj_export (bool): Export to OBJ file (default: False)
994
- - n_reflection_steps (int): Number of reflection bounces when
995
- with_reflections=True (default: 2)
996
- - progress_report (bool): Print progress (default: False)
997
-
998
- Returns:
999
- 2D numpy array of direct horizontal irradiance (W/m²)
1000
- """
1001
- import taichi as ti
1002
- from ..init_taichi import ensure_initialized
1003
- ensure_initialized()
1004
-
1005
- colormap = kwargs.get('colormap', 'magma')
1006
- vmin = kwargs.get('vmin', 0.0)
1007
- vmax = kwargs.get('vmax', direct_normal_irradiance)
1008
-
1009
- if with_reflections:
1010
- # Use full RadiationModel with reflections
1011
- direct_map, _, _ = _compute_ground_irradiance_with_reflections(
1012
- voxcity=voxcity,
1013
- azimuth_degrees_ori=azimuth_degrees_ori,
1014
- elevation_degrees=elevation_degrees,
1015
- direct_normal_irradiance=direct_normal_irradiance,
1016
- diffuse_irradiance=0.0, # Only compute direct component
1017
- **kwargs
1018
- )
1019
- else:
1020
- # Use simple ray-tracing (faster but no reflections)
1021
- voxel_data = voxcity.voxels.classes
1022
- meshsize = voxcity.voxels.meta.meshsize
1023
-
1024
- view_point_height = kwargs.get('view_point_height', 1.5)
1025
- tree_k = kwargs.get('tree_k', 0.6)
1026
- tree_lad = kwargs.get('tree_lad', 1.0)
1027
-
1028
- # Convert to sun direction vector
1029
- # VoxCity convention: azimuth 0=North, clockwise
1030
- # Convert to standard: 180 - azimuth
1031
- azimuth_degrees = 180 - azimuth_degrees_ori
1032
- azimuth_radians = np.deg2rad(azimuth_degrees)
1033
- elevation_radians = np.deg2rad(elevation_degrees)
1034
-
1035
- dx_dir = np.cos(elevation_radians) * np.cos(azimuth_radians)
1036
- dy_dir = np.cos(elevation_radians) * np.sin(azimuth_radians)
1037
- dz_dir = np.sin(elevation_radians)
1038
-
1039
- # Compute transmittance map using ray tracing
1040
- transmittance_map = _compute_direct_transmittance_map_gpu(
1041
- voxel_data=voxel_data,
1042
- sun_direction=(dx_dir, dy_dir, dz_dir),
1043
- view_point_height=view_point_height,
1044
- meshsize=meshsize,
1045
- tree_k=tree_k,
1046
- tree_lad=tree_lad
1047
- )
1048
-
1049
- # Convert to horizontal irradiance
1050
- sin_elev = np.sin(elevation_radians)
1051
- direct_map = transmittance_map * direct_normal_irradiance * sin_elev
1052
-
1053
- # Flip to match VoxCity coordinate system
1054
- direct_map = np.flipud(direct_map)
1055
-
1056
- if show_plot:
1057
- try:
1058
- import matplotlib.pyplot as plt
1059
- cmap = plt.cm.get_cmap(colormap).copy()
1060
- cmap.set_bad(color='lightgray')
1061
- plt.figure(figsize=(10, 8))
1062
- plt.imshow(direct_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
1063
- plt.colorbar(label='Direct Solar Irradiance (W/m²)')
1064
- plt.axis('off')
1065
- plt.show()
1066
- except ImportError:
1067
- pass
1068
-
1069
- if kwargs.get('obj_export', False):
1070
- _export_irradiance_to_obj(
1071
- voxcity, direct_map,
1072
- output_name=kwargs.get('output_file_name', 'direct_solar_irradiance'),
1073
- **kwargs
1074
- )
1075
-
1076
- return direct_map
1077
-
1078
-
1079
- def get_diffuse_solar_irradiance_map(
1080
- voxcity,
1081
- diffuse_irradiance: float = 1.0,
1082
- show_plot: bool = False,
1083
- with_reflections: bool = False,
1084
- azimuth_degrees_ori: float = 180.0,
1085
- elevation_degrees: float = 45.0,
1086
- **kwargs
1087
- ) -> np.ndarray:
1088
- """
1089
- GPU-accelerated diffuse horizontal irradiance map computation using SVF.
1090
-
1091
- This function matches the signature of voxcity.simulator.solar.get_diffuse_solar_irradiance_map
1092
- using Taichi GPU acceleration.
1093
-
1094
- Args:
1095
- voxcity: VoxCity object
1096
- diffuse_irradiance: Diffuse horizontal irradiance in W/m²
1097
- show_plot: Whether to display a matplotlib plot
1098
- with_reflections: If True, use full RadiationModel with multi-bounce
1099
- reflections (requires azimuth_degrees_ori and elevation_degrees).
1100
- If False (default), use simple SVF-based computation.
1101
- azimuth_degrees_ori: Solar azimuth in degrees (only used when with_reflections=True)
1102
- elevation_degrees: Solar elevation in degrees (only used when with_reflections=True)
1103
- **kwargs: Additional parameters including:
1104
- - view_point_height (float): Observer height above ground (default: 1.5)
1105
- - N_azimuth (int): Number of azimuthal divisions (default: 120)
1106
- - N_elevation (int): Number of elevation divisions (default: 20)
1107
- - tree_k (float): Tree extinction coefficient (default: 0.6)
1108
- - tree_lad (float): Leaf area density (default: 1.0)
1109
- - colormap (str): Matplotlib colormap name (default: 'magma')
1110
- - vmin, vmax (float): Colormap limits
1111
- - obj_export (bool): Export to OBJ file (default: False)
1112
- - n_reflection_steps (int): Number of reflection bounces when
1113
- with_reflections=True (default: 2)
1114
- - progress_report (bool): Print progress (default: False)
1115
-
1116
- Returns:
1117
- 2D numpy array of diffuse horizontal irradiance (W/m²)
1118
- """
1119
- colormap = kwargs.get('colormap', 'magma')
1120
- vmin = kwargs.get('vmin', 0.0)
1121
- vmax = kwargs.get('vmax', diffuse_irradiance)
1122
-
1123
- if with_reflections:
1124
- # Use full RadiationModel with reflections
1125
- # Remove parameters we explicitly set to avoid conflicts
1126
- refl_kwargs = {k: v for k, v in kwargs.items()
1127
- if k not in ('direct_normal_irradiance', 'diffuse_irradiance')}
1128
- _, diffuse_map, reflected_map = _compute_ground_irradiance_with_reflections(
1129
- voxcity=voxcity,
1130
- azimuth_degrees_ori=azimuth_degrees_ori,
1131
- elevation_degrees=elevation_degrees,
1132
- direct_normal_irradiance=kwargs.get('direct_normal_irradiance', 0.0),
1133
- diffuse_irradiance=diffuse_irradiance,
1134
- **refl_kwargs
1135
- )
1136
- # Include reflected component in diffuse when using reflection model
1137
- diffuse_map = np.where(np.isnan(diffuse_map), np.nan, diffuse_map + reflected_map)
1138
- else:
1139
- # Use simple SVF-based computation (faster but no reflections)
1140
- # Import the visibility SVF function
1141
- from ..visibility.voxcity import get_sky_view_factor_map as get_svf_map
1142
-
1143
- # Get SVF map using GPU-accelerated visibility module
1144
- svf_kwargs = kwargs.copy()
1145
- svf_kwargs['colormap'] = 'BuPu_r'
1146
- svf_kwargs['vmin'] = 0
1147
- svf_kwargs['vmax'] = 1
1148
-
1149
- SVF_map = get_svf_map(voxcity, show_plot=False, **svf_kwargs)
1150
- diffuse_map = SVF_map * diffuse_irradiance
1151
-
1152
- if show_plot:
1153
- try:
1154
- import matplotlib.pyplot as plt
1155
- cmap = plt.cm.get_cmap(colormap).copy()
1156
- cmap.set_bad(color='lightgray')
1157
- plt.figure(figsize=(10, 8))
1158
- plt.imshow(diffuse_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
1159
- plt.colorbar(label='Diffuse Solar Irradiance (W/m²)')
1160
- plt.axis('off')
1161
- plt.show()
1162
- except ImportError:
1163
- pass
1164
-
1165
- if kwargs.get('obj_export', False):
1166
- _export_irradiance_to_obj(
1167
- voxcity, diffuse_map,
1168
- output_name=kwargs.get('output_file_name', 'diffuse_solar_irradiance'),
1169
- **kwargs
1170
- )
1171
-
1172
- return diffuse_map
1173
-
1174
-
1175
- def get_global_solar_irradiance_map(
1176
- voxcity,
1177
- azimuth_degrees_ori: float,
1178
- elevation_degrees: float,
1179
- direct_normal_irradiance: float,
1180
- diffuse_irradiance: float,
1181
- show_plot: bool = False,
1182
- with_reflections: bool = False,
1183
- **kwargs
1184
- ) -> np.ndarray:
1185
- """
1186
- GPU-accelerated global (direct + diffuse) horizontal irradiance map.
1187
-
1188
- This function matches the signature of voxcity.simulator.solar.get_global_solar_irradiance_map
1189
- using Taichi GPU acceleration.
1190
-
1191
- Args:
1192
- voxcity: VoxCity object
1193
- azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
1194
- elevation_degrees: Solar elevation in degrees above horizon
1195
- direct_normal_irradiance: DNI in W/m²
1196
- diffuse_irradiance: DHI in W/m²
1197
- show_plot: Whether to display a matplotlib plot
1198
- with_reflections: If True, use full RadiationModel with multi-bounce
1199
- reflections. If False (default), use simple ray-tracing/SVF for
1200
- faster but less accurate results.
1201
- **kwargs: Additional parameters (see get_direct_solar_irradiance_map)
1202
- - n_reflection_steps (int): Number of reflection bounces when
1203
- with_reflections=True (default: 2)
1204
- - progress_report (bool): Print progress (default: False)
1205
-
1206
- Returns:
1207
- 2D numpy array of global horizontal irradiance (W/m²)
1208
- """
1209
- if with_reflections:
1210
- # Use full RadiationModel with reflections (single call for all components)
1211
- direct_map, diffuse_map, reflected_map = _compute_ground_irradiance_with_reflections(
1212
- voxcity=voxcity,
1213
- azimuth_degrees_ori=azimuth_degrees_ori,
1214
- elevation_degrees=elevation_degrees,
1215
- direct_normal_irradiance=direct_normal_irradiance,
1216
- diffuse_irradiance=diffuse_irradiance,
1217
- **kwargs
1218
- )
1219
- # Combine all components: direct + diffuse + reflected
1220
- global_map = np.where(
1221
- np.isnan(direct_map),
1222
- np.nan,
1223
- direct_map + diffuse_map + reflected_map
1224
- )
1225
- else:
1226
- # Compute direct and diffuse components separately (no reflections)
1227
- direct_map = get_direct_solar_irradiance_map(
1228
- voxcity,
1229
- azimuth_degrees_ori,
1230
- elevation_degrees,
1231
- direct_normal_irradiance,
1232
- show_plot=False,
1233
- with_reflections=False,
1234
- **kwargs
1235
- )
1236
-
1237
- diffuse_map = get_diffuse_solar_irradiance_map(
1238
- voxcity,
1239
- diffuse_irradiance=diffuse_irradiance,
1240
- show_plot=False,
1241
- with_reflections=False,
1242
- **kwargs
1243
- )
1244
-
1245
- # Combine: where direct is NaN, use only diffuse
1246
- global_map = np.where(np.isnan(direct_map), diffuse_map, direct_map + diffuse_map)
1247
-
1248
- if show_plot:
1249
- colormap = kwargs.get('colormap', 'magma')
1250
- vmin = kwargs.get('vmin', 0.0)
1251
- vmax = kwargs.get('vmax', max(float(np.nanmax(global_map)), 1.0))
1252
- try:
1253
- import matplotlib.pyplot as plt
1254
- cmap = plt.cm.get_cmap(colormap).copy()
1255
- cmap.set_bad(color='lightgray')
1256
- plt.figure(figsize=(10, 8))
1257
- plt.imshow(global_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
1258
- plt.colorbar(label='Global Solar Irradiance (W/m²)')
1259
- plt.axis('off')
1260
- plt.show()
1261
- except ImportError:
1262
- pass
1263
-
1264
- if kwargs.get('obj_export', False):
1265
- _export_irradiance_to_obj(
1266
- voxcity, global_map,
1267
- output_name=kwargs.get('output_file_name', 'global_solar_irradiance'),
1268
- **kwargs
1269
- )
1270
-
1271
- return global_map
1272
-
1273
-
1274
- def get_cumulative_global_solar_irradiance(
1275
- voxcity,
1276
- df,
1277
- lon: float,
1278
- lat: float,
1279
- tz: float,
1280
- direct_normal_irradiance_scaling: float = 1.0,
1281
- diffuse_irradiance_scaling: float = 1.0,
1282
- show_plot: bool = False,
1283
- with_reflections: bool = False,
1284
- **kwargs
1285
- ) -> np.ndarray:
1286
- """
1287
- GPU-accelerated cumulative global solar irradiance over a period.
1288
-
1289
- This function matches the signature of voxcity.simulator.solar.get_cumulative_global_solar_irradiance
1290
- using Taichi GPU acceleration with sky patch optimization.
1291
-
1292
- OPTIMIZATIONS IMPLEMENTED:
1293
- 1. Vectorized sun position binning using bin_sun_positions_to_tregenza_fast
1294
- 2. Pre-allocated output arrays for patch loop
1295
- 3. Cached model reuse across patches (SVF/CSF computed only once)
1296
- 4. Efficient array extraction with pre-computed surface-to-grid mapping
1297
-
1298
- Args:
1299
- voxcity: VoxCity object
1300
- df: pandas DataFrame with 'DNI' and 'DHI' columns, datetime-indexed
1301
- lon: Longitude in degrees
1302
- lat: Latitude in degrees
1303
- tz: Timezone offset in hours
1304
- direct_normal_irradiance_scaling: Scaling factor for DNI
1305
- diffuse_irradiance_scaling: Scaling factor for DHI
1306
- show_plot: Whether to display a matplotlib plot
1307
- with_reflections: If True, use full RadiationModel with multi-bounce
1308
- reflections for each timestep/patch. If False (default), use simple
1309
- ray-tracing/SVF for faster computation.
1310
- **kwargs: Additional parameters including:
1311
- - start_time (str): Start time 'MM-DD HH:MM:SS' (default: '01-01 05:00:00')
1312
- - end_time (str): End time 'MM-DD HH:MM:SS' (default: '01-01 20:00:00')
1313
- - view_point_height (float): Observer height (default: 1.5)
1314
- - use_sky_patches (bool): Use sky patch optimization (default: True)
1315
- - sky_discretization (str): 'tregenza', 'reinhart', 'uniform', 'fibonacci'
1316
- - progress_report (bool): Print progress (default: False)
1317
- - colormap (str): Colormap name (default: 'magma')
1318
- - n_reflection_steps (int): Number of reflection bounces when
1319
- with_reflections=True (default: 2)
1320
-
1321
- Returns:
1322
- 2D numpy array of cumulative irradiance (Wh/m²)
1323
- """
1324
- import time
1325
- from datetime import datetime
1326
- import pytz
1327
-
1328
- # Extract parameters that we pass explicitly (use pop to avoid duplicate kwargs)
1329
- kwargs = kwargs.copy() # Don't modify the original
1330
- view_point_height = kwargs.pop('view_point_height', 1.5)
1331
- colormap = kwargs.pop('colormap', 'magma')
1332
- start_time = kwargs.pop('start_time', '01-01 05:00:00')
1333
- end_time = kwargs.pop('end_time', '01-01 20:00:00')
1334
- progress_report = kwargs.pop('progress_report', False)
1335
- use_sky_patches = kwargs.pop('use_sky_patches', True)
1336
- sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
1337
-
1338
- if df.empty:
1339
- raise ValueError("No data in EPW dataframe.")
1340
-
1341
- # Parse time range
1342
- try:
1343
- start_dt = datetime.strptime(start_time, '%m-%d %H:%M:%S')
1344
- end_dt = datetime.strptime(end_time, '%m-%d %H:%M:%S')
1345
- except ValueError as ve:
1346
- raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
1347
-
1348
- # Filter dataframe to period
1349
- df = df.copy()
1350
- df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
1351
- start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
1352
- end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
1353
- start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
1354
- end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
1355
-
1356
- if start_hour <= end_hour:
1357
- df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
1358
- else:
1359
- df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
1360
-
1361
- if df_period.empty:
1362
- raise ValueError("No EPW data in the specified period.")
1363
-
1364
- # Localize and convert to UTC
1365
- offset_minutes = int(tz * 60)
1366
- local_tz = pytz.FixedOffset(offset_minutes)
1367
- df_period_local = df_period.copy()
1368
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
1369
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
1370
-
1371
- # Get solar positions
1372
- solar_positions = _get_solar_positions_astral(df_period_utc.index, lon, lat)
1373
-
1374
- # Compute base diffuse map (SVF-based for efficiency, or with reflections if requested)
1375
- # Note: For cumulative with_reflections, we still use SVF-based base for diffuse sky contribution
1376
- # The reflection component is computed per timestep when with_reflections=True
1377
- diffuse_kwargs = kwargs.copy()
1378
- diffuse_kwargs.update({'show_plot': False, 'obj_export': False})
1379
- base_diffuse_map = get_diffuse_solar_irradiance_map(
1380
- voxcity,
1381
- diffuse_irradiance=1.0,
1382
- with_reflections=False, # Always use SVF for base diffuse in cumulative mode
1383
- **diffuse_kwargs
1384
- )
1385
-
1386
- voxel_data = voxcity.voxels.classes
1387
- nx, ny, _ = voxel_data.shape
1388
- cumulative_map = np.zeros((nx, ny))
1389
- mask_map = np.ones((nx, ny), dtype=bool)
1390
-
1391
- direct_kwargs = kwargs.copy()
1392
- direct_kwargs.update({
1393
- 'show_plot': False,
1394
- 'view_point_height': view_point_height,
1395
- 'obj_export': False,
1396
- 'with_reflections': with_reflections # Pass through to direct/global map calls
1397
- })
1398
-
1399
- if use_sky_patches:
1400
- # Use sky patch aggregation for efficiency
1401
- from .sky import (
1402
- generate_tregenza_patches,
1403
- generate_reinhart_patches,
1404
- generate_uniform_grid_patches,
1405
- generate_fibonacci_patches,
1406
- get_tregenza_patch_index
1407
- )
1408
-
1409
- t0 = time.perf_counter() if progress_report else 0
1410
-
1411
- # Extract arrays
1412
- azimuth_arr = solar_positions['azimuth'].to_numpy()
1413
- elevation_arr = solar_positions['elevation'].to_numpy()
1414
- dni_arr = df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling
1415
- dhi_arr = df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling
1416
- time_step_hours = kwargs.get('time_step_hours', 1.0)
1417
-
1418
- # Generate sky patches
1419
- if sky_discretization.lower() == 'tregenza':
1420
- patches, directions, solid_angles = generate_tregenza_patches()
1421
- elif sky_discretization.lower() == 'reinhart':
1422
- mf = kwargs.get('reinhart_mf', kwargs.get('mf', 4))
1423
- patches, directions, solid_angles = generate_reinhart_patches(mf=mf)
1424
- elif sky_discretization.lower() == 'uniform':
1425
- n_az = kwargs.get('sky_n_azimuth', kwargs.get('n_azimuth', 36))
1426
- n_el = kwargs.get('sky_n_elevation', kwargs.get('n_elevation', 9))
1427
- patches, directions, solid_angles = generate_uniform_grid_patches(n_az, n_el)
1428
- elif sky_discretization.lower() == 'fibonacci':
1429
- n_patches = kwargs.get('sky_n_patches', kwargs.get('n_patches', 145))
1430
- patches, directions, solid_angles = generate_fibonacci_patches(n_patches=n_patches)
1431
- else:
1432
- raise ValueError(f"Unknown sky discretization method: {sky_discretization}")
1433
-
1434
- n_patches = len(patches)
1435
- n_timesteps = len(azimuth_arr)
1436
- cumulative_dni = np.zeros(n_patches, dtype=np.float64)
1437
-
1438
- # OPTIMIZATION: Vectorized DHI accumulation (only for positive values)
1439
- # This replaces the loop-based accumulation
1440
- valid_dhi_mask = dhi_arr > 0
1441
- total_cumulative_dhi = np.sum(dhi_arr[valid_dhi_mask]) * time_step_hours
1442
-
1443
- # DNI binning - loop is already fast (~7ms for 731 timesteps)
1444
- # The loop is necessary because patch assignment depends on sun position
1445
- for i in range(n_timesteps):
1446
- elev = elevation_arr[i]
1447
- if elev <= 0:
1448
- continue
1449
-
1450
- az = azimuth_arr[i]
1451
- dni = dni_arr[i]
1452
-
1453
- if dni <= 0:
1454
- continue
1455
-
1456
- patch_idx = int(get_tregenza_patch_index(float(az), float(elev)))
1457
- if patch_idx >= 0 and patch_idx < n_patches:
1458
- cumulative_dni[patch_idx] += dni * time_step_hours
1459
-
1460
- active_mask = cumulative_dni > 0
1461
- n_active = int(np.sum(active_mask))
1462
-
1463
- if progress_report:
1464
- bin_time = time.perf_counter() - t0
1465
- print(f"Sky patch optimization: {n_timesteps} timesteps -> {n_active} active patches ({sky_discretization})")
1466
- print(f" Sun position binning: {bin_time:.3f}s")
1467
- print(f" Total cumulative DHI: {total_cumulative_dhi:.1f} Wh/m²")
1468
- if with_reflections:
1469
- print(" Using RadiationModel with multi-bounce reflections")
1470
-
1471
- # Diffuse component
1472
- cumulative_diffuse = base_diffuse_map * total_cumulative_dhi
1473
- cumulative_map += np.nan_to_num(cumulative_diffuse, nan=0.0)
1474
- mask_map &= ~np.isnan(cumulative_diffuse)
1475
-
1476
- # Direct component - loop over active patches
1477
- # When with_reflections=True, use get_global_solar_irradiance_map to include
1478
- # reflections for each patch direction
1479
- active_indices = np.where(active_mask)[0]
1480
-
1481
- # OPTIMIZATION: Pre-warm the model (ensures JIT compilation is done)
1482
- if with_reflections and len(active_indices) > 0:
1483
- # Ensure model is created and cached before timing
1484
- n_reflection_steps = kwargs.get('n_reflection_steps', 2)
1485
- _ = _get_or_create_radiation_model(
1486
- voxcity,
1487
- n_reflection_steps=n_reflection_steps,
1488
- progress_report=progress_report
1489
- )
1490
-
1491
- if progress_report:
1492
- t_patch_start = time.perf_counter()
1493
-
1494
- for i, patch_idx in enumerate(active_indices):
1495
- az_deg = patches[patch_idx, 0]
1496
- el_deg = patches[patch_idx, 1]
1497
- cumulative_dni_patch = cumulative_dni[patch_idx]
1498
-
1499
- if with_reflections:
1500
- # Use full RadiationModel: compute direct + reflected for this direction
1501
- # We set diffuse_irradiance=0 since we handle diffuse separately
1502
- direct_map, _, reflected_map = _compute_ground_irradiance_with_reflections(
1503
- voxcity=voxcity,
1504
- azimuth_degrees_ori=az_deg,
1505
- elevation_degrees=el_deg,
1506
- direct_normal_irradiance=1.0,
1507
- diffuse_irradiance=0.0,
1508
- view_point_height=view_point_height,
1509
- **kwargs
1510
- )
1511
- # Include reflections in patch contribution
1512
- patch_contribution = (direct_map + np.nan_to_num(reflected_map, nan=0.0)) * cumulative_dni_patch
1513
- else:
1514
- # Simple ray tracing (no reflections)
1515
- direct_map = get_direct_solar_irradiance_map(
1516
- voxcity,
1517
- az_deg,
1518
- el_deg,
1519
- direct_normal_irradiance=1.0,
1520
- **direct_kwargs
1521
- )
1522
- patch_contribution = direct_map * cumulative_dni_patch
1523
-
1524
- mask_map &= ~np.isnan(patch_contribution)
1525
- cumulative_map += np.nan_to_num(patch_contribution, nan=0.0)
1526
-
1527
- if progress_report and ((i + 1) % max(1, len(active_indices) // 10) == 0 or i == len(active_indices) - 1):
1528
- elapsed = time.perf_counter() - t_patch_start
1529
- pct = (i + 1) * 100.0 / len(active_indices)
1530
- avg_per_patch = elapsed / (i + 1)
1531
- eta = avg_per_patch * (len(active_indices) - i - 1)
1532
- 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")
1533
-
1534
- if progress_report:
1535
- total_patch_time = time.perf_counter() - t_patch_start
1536
- print(f" Total patch processing: {total_patch_time:.2f}s ({n_active} patches)")
1537
-
1538
- else:
1539
- # Per-timestep path
1540
- if progress_report and with_reflections:
1541
- print(" Using RadiationModel with multi-bounce reflections (per-timestep)")
1542
-
1543
- for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
1544
- DNI = float(row['DNI']) * direct_normal_irradiance_scaling
1545
- DHI = float(row['DHI']) * diffuse_irradiance_scaling
1546
-
1547
- solpos = solar_positions.loc[time_utc]
1548
- azimuth_degrees = float(solpos['azimuth'])
1549
- elevation_degrees_val = float(solpos['elevation'])
1550
-
1551
- if with_reflections:
1552
- # Use full RadiationModel for this timestep
1553
- direct_map, diffuse_map_ts, reflected_map = _compute_ground_irradiance_with_reflections(
1554
- voxcity=voxcity,
1555
- azimuth_degrees_ori=azimuth_degrees,
1556
- elevation_degrees=elevation_degrees_val,
1557
- direct_normal_irradiance=DNI,
1558
- diffuse_irradiance=DHI,
1559
- view_point_height=view_point_height,
1560
- **kwargs
1561
- )
1562
- # Combine all components
1563
- combined = (np.nan_to_num(direct_map, nan=0.0) +
1564
- np.nan_to_num(diffuse_map_ts, nan=0.0) +
1565
- np.nan_to_num(reflected_map, nan=0.0))
1566
- mask_map &= ~np.isnan(direct_map)
1567
- else:
1568
- # Simple ray tracing (no reflections)
1569
- direct_map = get_direct_solar_irradiance_map(
1570
- voxcity,
1571
- azimuth_degrees,
1572
- elevation_degrees_val,
1573
- direct_normal_irradiance=DNI,
1574
- **direct_kwargs # with_reflections already in direct_kwargs
1575
- )
1576
-
1577
- diffuse_contrib = base_diffuse_map * DHI
1578
- combined = np.nan_to_num(direct_map, nan=0.0) + np.nan_to_num(diffuse_contrib, nan=0.0)
1579
- mask_map &= ~np.isnan(direct_map) & ~np.isnan(diffuse_contrib)
1580
-
1581
- cumulative_map += combined
1582
-
1583
- if progress_report and (idx + 1) % max(1, len(df_period_utc) // 10) == 0:
1584
- pct = (idx + 1) * 100.0 / len(df_period_utc)
1585
- print(f" Timestep {idx+1}/{len(df_period_utc)} ({pct:.1f}%)")
1586
-
1587
- # Apply mask for plotting
1588
- cumulative_map = np.where(mask_map, cumulative_map, np.nan)
1589
-
1590
- if show_plot:
1591
- vmax = kwargs.get('vmax', float(np.nanmax(cumulative_map)) if not np.all(np.isnan(cumulative_map)) else 1.0)
1592
- vmin = kwargs.get('vmin', 0.0)
1593
- try:
1594
- import matplotlib.pyplot as plt
1595
- cmap = plt.cm.get_cmap(colormap).copy()
1596
- cmap.set_bad(color='lightgray')
1597
- plt.figure(figsize=(10, 8))
1598
- plt.imshow(cumulative_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
1599
- plt.colorbar(label='Cumulative Global Solar Irradiance (Wh/m²)')
1600
- plt.axis('off')
1601
- plt.show()
1602
- except ImportError:
1603
- pass
1604
-
1605
- return cumulative_map
1606
-
1607
-
1608
- def get_building_solar_irradiance(
1609
- voxcity,
1610
- building_svf_mesh=None,
1611
- azimuth_degrees_ori: float = None,
1612
- elevation_degrees: float = None,
1613
- direct_normal_irradiance: float = None,
1614
- diffuse_irradiance: float = None,
1615
- **kwargs
1616
- ):
1617
- """
1618
- GPU-accelerated building surface solar irradiance computation.
1619
-
1620
- This function matches the signature of voxcity.simulator.solar.get_building_solar_irradiance
1621
- using Taichi GPU acceleration with multi-bounce reflections.
1622
-
1623
- Uses cached RadiationModel to avoid recomputing SVF/CSF matrices for each timestep.
1624
-
1625
- Args:
1626
- voxcity: VoxCity object
1627
- building_svf_mesh: Pre-computed mesh with SVF values (optional, for VoxCity API compatibility)
1628
- If provided, SVF values from mesh metadata will be used.
1629
- If None, SVF will be computed internally.
1630
- azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
1631
- elevation_degrees: Solar elevation in degrees above horizon
1632
- direct_normal_irradiance: DNI in W/m²
1633
- diffuse_irradiance: DHI in W/m²
1634
- **kwargs: Additional parameters including:
1635
- - with_reflections (bool): Enable multi-bounce surface reflections (default: False).
1636
- Set to True for more accurate results but slower computation.
1637
- - n_reflection_steps (int): Number of reflection bounces when with_reflections=True (default: 2)
1638
- - tree_k (float): Tree extinction coefficient (default: 0.6)
1639
- - building_class_id (int): Building voxel class code (default: -3)
1640
- - progress_report (bool): Print progress (default: False)
1641
- - colormap (str): Colormap name (default: 'magma')
1642
- - obj_export (bool): Export mesh to OBJ (default: False)
1643
-
1644
- Returns:
1645
- Trimesh object with irradiance values in metadata
1646
- """
1647
- # Handle positional argument order from VoxCity API:
1648
- # VoxCity: get_building_solar_irradiance(voxcity, building_svf_mesh, azimuth, elevation, dni, dhi, **kwargs)
1649
- # If building_svf_mesh is a number, assume old GPU-only API call where second arg is azimuth
1650
- if isinstance(building_svf_mesh, (int, float)):
1651
- # Old API: get_building_solar_irradiance(voxcity, azimuth, elevation, dni, dhi, ...)
1652
- diffuse_irradiance = direct_normal_irradiance
1653
- direct_normal_irradiance = elevation_degrees
1654
- elevation_degrees = azimuth_degrees_ori
1655
- azimuth_degrees_ori = building_svf_mesh
1656
- building_svf_mesh = None
1657
-
1658
- voxel_data = voxcity.voxels.classes
1659
- meshsize = voxcity.voxels.meta.meshsize
1660
- building_id_grid = voxcity.buildings.ids
1661
- ny_vc, nx_vc, nz = voxel_data.shape
1662
-
1663
- # Extract parameters that we pass explicitly (to avoid duplicate kwargs error)
1664
- progress_report = kwargs.pop('progress_report', False)
1665
- building_class_id = kwargs.pop('building_class_id', -3)
1666
- n_reflection_steps = kwargs.pop('n_reflection_steps', 2)
1667
- colormap = kwargs.pop('colormap', 'magma')
1668
- with_reflections = kwargs.pop('with_reflections', False) # Default False for speed; set True for multi-bounce reflections
1669
-
1670
- # If with_reflections=False, set n_reflection_steps=0 to skip expensive SVF matrix computation
1671
- if not with_reflections:
1672
- n_reflection_steps = 0
1673
-
1674
- # Get cached or create new RadiationModel (SVF/CSF computed only once)
1675
- model, is_building_surf = _get_or_create_building_radiation_model(
1676
- voxcity,
1677
- n_reflection_steps=n_reflection_steps,
1678
- progress_report=progress_report,
1679
- building_class_id=building_class_id,
1680
- **kwargs
1681
- )
1682
-
1683
- # Set solar position for this timestep
1684
- azimuth_degrees = 180 - azimuth_degrees_ori
1685
- azimuth_radians = np.deg2rad(azimuth_degrees)
1686
- elevation_radians = np.deg2rad(elevation_degrees)
1687
-
1688
- sun_dir_x = np.cos(elevation_radians) * np.cos(azimuth_radians)
1689
- sun_dir_y = np.cos(elevation_radians) * np.sin(azimuth_radians)
1690
- sun_dir_z = np.sin(elevation_radians)
1691
-
1692
- # Set sun direction and cos_zenith directly on the SolarCalculator fields
1693
- model.solar_calc.sun_direction[None] = (sun_dir_x, sun_dir_y, sun_dir_z)
1694
- model.solar_calc.cos_zenith[None] = np.sin(elevation_radians)
1695
- model.solar_calc.sun_up[None] = 1 if elevation_degrees > 0 else 0
1696
-
1697
- # Compute shortwave radiation (uses cached SVF/CSF matrices)
1698
- model.compute_shortwave_radiation(
1699
- sw_direct=direct_normal_irradiance,
1700
- sw_diffuse=diffuse_irradiance
1701
- )
1702
-
1703
- # Extract surface irradiance from palm_solar model
1704
- # Note: Use to_numpy() without slicing - slicing is VERY slow on Taichi arrays
1705
- # The mesh_to_surface_idx will handle extracting only the values we need
1706
- n_surfaces = model.surfaces.count
1707
- sw_in_direct_all = model.surfaces.sw_in_direct.to_numpy()
1708
- sw_in_diffuse_all = model.surfaces.sw_in_diffuse.to_numpy()
1709
-
1710
- if hasattr(model.surfaces, 'sw_in_reflected'):
1711
- sw_in_reflected_all = model.surfaces.sw_in_reflected.to_numpy()
1712
- else:
1713
- sw_in_reflected_all = np.zeros_like(sw_in_direct_all)
1714
-
1715
- total_sw_all = sw_in_direct_all + sw_in_diffuse_all + sw_in_reflected_all
1716
-
1717
- # Get building indices from cache (avoids np.where every time)
1718
- bldg_indices = _building_radiation_model_cache.bldg_indices if _building_radiation_model_cache else np.where(is_building_surf)[0]
1719
- if progress_report:
1720
- print(f" palm_solar surfaces: {n_surfaces}, building surfaces: {len(bldg_indices)}")
1721
-
1722
- # Get or create building mesh - use cached mesh if available (expensive: ~2.4s)
1723
- cache = _building_radiation_model_cache
1724
- if building_svf_mesh is not None:
1725
- # Use provided mesh directly (no copy needed - we only update metadata)
1726
- building_mesh = building_svf_mesh
1727
- # Extract SVF from mesh metadata if available
1728
- if hasattr(building_mesh, 'metadata') and 'svf' in building_mesh.metadata:
1729
- face_svf = building_mesh.metadata['svf']
1730
- else:
1731
- face_svf = None
1732
- # Cache mesh geometry on first use (avoids recomputing triangles_center/face_normals)
1733
- if cache is not None and cache.mesh_face_centers is None:
1734
- cache.mesh_face_centers = building_mesh.triangles_center.copy()
1735
- cache.mesh_face_normals = building_mesh.face_normals.copy()
1736
- elif cache is not None and cache.cached_building_mesh is not None:
1737
- # Use cached mesh (avoids expensive ~2.4s mesh creation each call)
1738
- building_mesh = cache.cached_building_mesh
1739
- face_svf = None
1740
- else:
1741
- # Create mesh for building surfaces (expensive, ~2.4s)
1742
- try:
1743
- from voxcity.geoprocessor.mesh import create_voxel_mesh
1744
- if progress_report:
1745
- print(" Creating building mesh (first call, will be cached)...")
1746
- building_mesh = create_voxel_mesh(
1747
- voxel_data,
1748
- building_class_id,
1749
- meshsize,
1750
- building_id_grid=building_id_grid,
1751
- mesh_type='open_air'
1752
- )
1753
- if building_mesh is None or len(building_mesh.faces) == 0:
1754
- print("No building surfaces found.")
1755
- return None
1756
- # Cache the mesh for future calls
1757
- if cache is not None:
1758
- cache.cached_building_mesh = building_mesh
1759
- if progress_report:
1760
- print(f" Cached building mesh with {len(building_mesh.faces)} faces")
1761
- except ImportError:
1762
- print("VoxCity geoprocessor.mesh required for mesh creation")
1763
- return None
1764
- face_svf = None
1765
-
1766
- n_mesh_faces = len(building_mesh.faces)
1767
-
1768
- # Map palm_solar building surface values to building mesh faces.
1769
- # Use cached mapping if available (avoids expensive KDTree query every call)
1770
- if len(bldg_indices) > 0:
1771
- # Check if we have cached mesh_to_surface_idx mapping
1772
- if (cache is not None and
1773
- cache.mesh_to_surface_idx is not None and
1774
- len(cache.mesh_to_surface_idx) == n_mesh_faces):
1775
- # Use cached direct mapping: mesh face -> surface index
1776
- mesh_to_surface_idx = cache.mesh_to_surface_idx
1777
-
1778
- # Fast vectorized indexing using pre-computed mapping
1779
- sw_in_direct = sw_in_direct_all[mesh_to_surface_idx]
1780
- sw_in_diffuse = sw_in_diffuse_all[mesh_to_surface_idx]
1781
- sw_in_reflected = sw_in_reflected_all[mesh_to_surface_idx]
1782
- total_sw = total_sw_all[mesh_to_surface_idx]
1783
- else:
1784
- # Need to compute mapping (first call with this mesh)
1785
- from scipy.spatial import cKDTree
1786
-
1787
- # Get surface centers (only needed for KDTree building)
1788
- surf_centers_all = model.surfaces.center.to_numpy()[:n_surfaces]
1789
- bldg_centers = surf_centers_all[bldg_indices]
1790
-
1791
- # Use cached geometry if available, otherwise compute from mesh
1792
- if cache is not None and cache.mesh_face_centers is not None:
1793
- mesh_face_centers = cache.mesh_face_centers
1794
- else:
1795
- mesh_face_centers = building_mesh.triangles_center
1796
- if cache is not None:
1797
- cache.mesh_face_centers = mesh_face_centers.copy()
1798
- cache.mesh_face_normals = building_mesh.face_normals.copy()
1799
-
1800
- if progress_report:
1801
- print(f" Computing mesh-to-surface mapping (first call)...")
1802
- print(f" palm_solar bldg centers: x=[{bldg_centers[:,0].min():.1f}, {bldg_centers[:,0].max():.1f}], "
1803
- f"y=[{bldg_centers[:,1].min():.1f}, {bldg_centers[:,1].max():.1f}], "
1804
- f"z=[{bldg_centers[:,2].min():.1f}, {bldg_centers[:,2].max():.1f}]")
1805
- print(f" mesh face centers: x=[{mesh_face_centers[:,0].min():.1f}, {mesh_face_centers[:,0].max():.1f}], "
1806
- f"y=[{mesh_face_centers[:,1].min():.1f}, {mesh_face_centers[:,1].max():.1f}], "
1807
- f"z=[{mesh_face_centers[:,2].min():.1f}, {mesh_face_centers[:,2].max():.1f}]")
1808
-
1809
- tree = cKDTree(bldg_centers)
1810
- distances, nearest_idx = tree.query(mesh_face_centers, k=1)
1811
-
1812
- if progress_report:
1813
- print(f" KDTree match distances: min={distances.min():.2f}, mean={distances.mean():.2f}, max={distances.max():.2f}")
1814
-
1815
- # Create direct mapping: mesh face -> surface index
1816
- # This combines bldg_indices[nearest_idx] into a single array
1817
- mesh_to_surface_idx = bldg_indices[nearest_idx]
1818
-
1819
- # Cache the mapping for subsequent calls
1820
- if cache is not None:
1821
- cache.mesh_to_surface_idx = mesh_to_surface_idx
1822
- cache.bldg_indices = bldg_indices
1823
- if progress_report:
1824
- print(f" Cached mesh-to-surface mapping for {n_mesh_faces} faces")
1825
-
1826
- # Map irradiance arrays
1827
- sw_in_direct = sw_in_direct_all[mesh_to_surface_idx]
1828
- sw_in_diffuse = sw_in_diffuse_all[mesh_to_surface_idx]
1829
- sw_in_reflected = sw_in_reflected_all[mesh_to_surface_idx]
1830
- total_sw = total_sw_all[mesh_to_surface_idx]
1831
- else:
1832
- # Fallback: no building surfaces in palm_solar model (edge case)
1833
- sw_in_direct = np.zeros(n_mesh_faces, dtype=np.float32)
1834
- sw_in_diffuse = np.zeros(n_mesh_faces, dtype=np.float32)
1835
- sw_in_reflected = np.zeros(n_mesh_faces, dtype=np.float32)
1836
- total_sw = np.zeros(n_mesh_faces, dtype=np.float32)
1837
-
1838
- # -------------------------------------------------------------------------
1839
- # Set vertical faces on domain perimeter to NaN (matching VoxCity behavior)
1840
- # Use cached boundary mask if available to avoid expensive mesh ops
1841
- # -------------------------------------------------------------------------
1842
- cache = _building_radiation_model_cache
1843
- if cache is not None and cache.boundary_mask is not None and len(cache.boundary_mask) == n_mesh_faces:
1844
- # Use cached boundary mask
1845
- is_boundary_vertical = cache.boundary_mask
1846
- else:
1847
- # Compute and cache boundary mask (first call)
1848
- ny_vc, nx_vc, nz = voxel_data.shape
1849
- grid_bounds_real = np.array([
1850
- [0.0, 0.0, 0.0],
1851
- [nx_vc * meshsize, ny_vc * meshsize, nz * meshsize]
1852
- ], dtype=np.float64)
1853
- boundary_epsilon = meshsize * 0.05
1854
-
1855
- # Use cached geometry if available, otherwise compute and cache
1856
- if cache is not None and cache.mesh_face_centers is not None:
1857
- mesh_face_centers = cache.mesh_face_centers
1858
- mesh_face_normals = cache.mesh_face_normals
1859
- else:
1860
- mesh_face_centers = building_mesh.triangles_center
1861
- mesh_face_normals = building_mesh.face_normals
1862
- # Cache geometry for future calls
1863
- if cache is not None:
1864
- cache.mesh_face_centers = mesh_face_centers
1865
- cache.mesh_face_normals = mesh_face_normals
1866
-
1867
- # Detect vertical faces (normal z-component near zero)
1868
- is_vertical = np.abs(mesh_face_normals[:, 2]) < 0.01
1869
-
1870
- # Detect faces on domain boundary
1871
- on_x_min = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[0, 0]) < boundary_epsilon
1872
- on_y_min = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[0, 1]) < boundary_epsilon
1873
- on_x_max = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[1, 0]) < boundary_epsilon
1874
- on_y_max = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[1, 1]) < boundary_epsilon
1875
-
1876
- is_boundary_vertical = is_vertical & (on_x_min | on_y_min | on_x_max | on_y_max)
1877
-
1878
- # Cache the boundary mask
1879
- if cache is not None:
1880
- cache.boundary_mask = is_boundary_vertical
1881
-
1882
- # Set boundary vertical faces to NaN using np.where (avoids expensive astype conversion)
1883
- sw_in_direct = np.where(is_boundary_vertical, np.nan, sw_in_direct)
1884
- sw_in_diffuse = np.where(is_boundary_vertical, np.nan, sw_in_diffuse)
1885
- sw_in_reflected = np.where(is_boundary_vertical, np.nan, sw_in_reflected)
1886
- total_sw = np.where(is_boundary_vertical, np.nan, total_sw)
1887
-
1888
- if progress_report:
1889
- n_boundary = np.sum(is_boundary_vertical)
1890
- print(f" Boundary vertical faces set to NaN: {n_boundary}/{n_mesh_faces} ({100*n_boundary/n_mesh_faces:.1f}%)")
1891
-
1892
- building_mesh.metadata = {
1893
- 'irradiance_direct': sw_in_direct,
1894
- 'irradiance_diffuse': sw_in_diffuse,
1895
- 'irradiance_reflected': sw_in_reflected,
1896
- 'irradiance_total': total_sw,
1897
- 'direct': sw_in_direct, # VoxCity API compatibility alias
1898
- 'diffuse': sw_in_diffuse, # VoxCity API compatibility alias
1899
- 'global': total_sw, # VoxCity API compatibility alias
1900
- }
1901
- if face_svf is not None:
1902
- building_mesh.metadata['svf'] = face_svf
1903
-
1904
- if kwargs.get('obj_export', False):
1905
- import os
1906
- output_dir = kwargs.get('output_directory', 'output')
1907
- output_file_name = kwargs.get('output_file_name', 'building_solar_irradiance')
1908
- os.makedirs(output_dir, exist_ok=True)
1909
- try:
1910
- building_mesh.export(f"{output_dir}/{output_file_name}.obj")
1911
- if progress_report:
1912
- print(f"Exported to {output_dir}/{output_file_name}.obj")
1913
- except Exception as e:
1914
- print(f"Error exporting mesh: {e}")
1915
-
1916
- return building_mesh
1917
-
1918
-
1919
- def get_cumulative_building_solar_irradiance(
1920
- voxcity,
1921
- building_svf_mesh,
1922
- weather_df,
1923
- lon: float,
1924
- lat: float,
1925
- tz: float,
1926
- direct_normal_irradiance_scaling: float = 1.0,
1927
- diffuse_irradiance_scaling: float = 1.0,
1928
- **kwargs
1929
- ):
1930
- """
1931
- GPU-accelerated cumulative solar irradiance on building surfaces.
1932
-
1933
- This function matches the signature of voxcity.simulator.solar.get_cumulative_building_solar_irradiance
1934
- using Taichi GPU acceleration.
1935
-
1936
- Integrates solar irradiance over a time period from weather data,
1937
- returning cumulative Wh/m² on building faces.
1938
-
1939
- Args:
1940
- voxcity: VoxCity object
1941
- building_svf_mesh: Trimesh object with SVF in metadata
1942
- weather_df: pandas DataFrame with 'DNI' and 'DHI' columns
1943
- lon: Longitude in degrees
1944
- lat: Latitude in degrees
1945
- tz: Timezone offset in hours
1946
- direct_normal_irradiance_scaling: Scaling factor for DNI
1947
- diffuse_irradiance_scaling: Scaling factor for DHI
1948
- **kwargs: Additional parameters including:
1949
- - period_start (str): Start time 'MM-DD HH:MM:SS' (default: '01-01 00:00:00')
1950
- - period_end (str): End time 'MM-DD HH:MM:SS' (default: '12-31 23:59:59')
1951
- - time_step_hours (float): Time step in hours (default: 1.0)
1952
- - use_sky_patches (bool): Use sky patch optimization (default: True)
1953
- - sky_discretization (str): 'tregenza', 'reinhart', etc.
1954
- - progress_report (bool): Print progress (default: False)
1955
- - with_reflections (bool): Enable multi-bounce surface reflections (default: False).
1956
- Set to True for more accurate results but slower computation.
1957
- - fast_path (bool): Use optimized paths (default: True)
1958
-
1959
- Returns:
1960
- Trimesh object with cumulative irradiance (Wh/m²) in metadata
1961
- """
1962
- from datetime import datetime
1963
- import pytz
1964
-
1965
- # Extract parameters that we pass explicitly (use pop to avoid duplicate kwargs)
1966
- kwargs = dict(kwargs) # Copy to avoid modifying original
1967
- period_start = kwargs.pop('period_start', '01-01 00:00:00')
1968
- period_end = kwargs.pop('period_end', '12-31 23:59:59')
1969
- time_step_hours = float(kwargs.pop('time_step_hours', 1.0))
1970
- progress_report = kwargs.pop('progress_report', False)
1971
- use_sky_patches = kwargs.pop('use_sky_patches', False) # Default False for accuracy, True for speed
1972
-
1973
- if weather_df.empty:
1974
- raise ValueError("No data in weather dataframe.")
1975
-
1976
- # Parse period
1977
- try:
1978
- start_dt = datetime.strptime(period_start, '%m-%d %H:%M:%S')
1979
- end_dt = datetime.strptime(period_end, '%m-%d %H:%M:%S')
1980
- except ValueError:
1981
- raise ValueError("period_start and period_end must be in format 'MM-DD HH:MM:SS'")
1982
-
1983
- # Filter dataframe to period
1984
- df = weather_df.copy()
1985
- df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
1986
- start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
1987
- end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
1988
- start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
1989
- end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
1990
-
1991
- if start_hour <= end_hour:
1992
- df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
1993
- else:
1994
- df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
1995
-
1996
- if df_period.empty:
1997
- raise ValueError("No weather data in the specified period.")
1998
-
1999
- # Localize and convert to UTC
2000
- offset_minutes = int(tz * 60)
2001
- local_tz = pytz.FixedOffset(offset_minutes)
2002
- df_period_local = df_period.copy()
2003
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
2004
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
2005
-
2006
- # Get solar positions
2007
- solar_positions = _get_solar_positions_astral(df_period_utc.index, lon, lat)
2008
-
2009
- # Initialize cumulative arrays
2010
- result_mesh = building_svf_mesh.copy() if hasattr(building_svf_mesh, 'copy') else building_svf_mesh
2011
- n_faces = len(result_mesh.faces) if hasattr(result_mesh, 'faces') else 0
2012
-
2013
- if n_faces == 0:
2014
- raise ValueError("Building mesh has no faces")
2015
-
2016
- cumulative_direct = np.zeros(n_faces, dtype=np.float64)
2017
- cumulative_diffuse = np.zeros(n_faces, dtype=np.float64)
2018
- cumulative_global = np.zeros(n_faces, dtype=np.float64)
2019
-
2020
- # Get SVF from mesh if available
2021
- face_svf = None
2022
- if hasattr(result_mesh, 'metadata') and 'svf' in result_mesh.metadata:
2023
- face_svf = result_mesh.metadata['svf']
2024
-
2025
- if progress_report:
2026
- print(f"Computing cumulative irradiance for {n_faces} faces...")
2027
-
2028
- # Extract arrays for processing
2029
- azimuth_arr = solar_positions['azimuth'].to_numpy()
2030
- elevation_arr = solar_positions['elevation'].to_numpy()
2031
- dni_arr = df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling
2032
- dhi_arr = df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling
2033
- n_timesteps = len(azimuth_arr)
2034
-
2035
- if use_sky_patches:
2036
- # Use sky patch aggregation for efficiency (same as ground-level)
2037
- from .sky import generate_sky_patches, get_tregenza_patch_index
2038
-
2039
- sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
2040
-
2041
- # Get method-specific parameters
2042
- sky_kwargs = {}
2043
- if sky_discretization.lower() == 'reinhart':
2044
- sky_kwargs['mf'] = kwargs.pop('reinhart_mf', kwargs.pop('mf', 4))
2045
- elif sky_discretization.lower() == 'uniform':
2046
- sky_kwargs['n_azimuth'] = kwargs.pop('sky_n_azimuth', kwargs.pop('n_azimuth', 36))
2047
- sky_kwargs['n_elevation'] = kwargs.pop('sky_n_elevation', kwargs.pop('n_elevation', 9))
2048
- elif sky_discretization.lower() == 'fibonacci':
2049
- sky_kwargs['n_patches'] = kwargs.pop('sky_n_patches', kwargs.pop('n_patches', 145))
2050
-
2051
- # Generate sky patches using unified interface
2052
- sky_patches = generate_sky_patches(sky_discretization, **sky_kwargs)
2053
- patches = sky_patches.patches # (N, 2) azimuth, elevation
2054
- directions = sky_patches.directions # (N, 3) unit vectors
2055
-
2056
- n_patches = sky_patches.n_patches
2057
- cumulative_dni_per_patch = np.zeros(n_patches, dtype=np.float64)
2058
- total_cumulative_dhi = 0.0
2059
-
2060
- # Bin sun positions to patches
2061
- for i in range(n_timesteps):
2062
- elev = elevation_arr[i]
2063
- dhi = dhi_arr[i]
2064
-
2065
- if dhi > 0:
2066
- total_cumulative_dhi += dhi * time_step_hours
2067
-
2068
- if elev <= 0:
2069
- continue
2070
-
2071
- az = azimuth_arr[i]
2072
- dni = dni_arr[i]
2073
-
2074
- if dni <= 0:
2075
- continue
2076
-
2077
- # Find nearest patch based on method
2078
- if sky_discretization.lower() == 'tregenza':
2079
- patch_idx = int(get_tregenza_patch_index(float(az), float(elev)))
2080
- else:
2081
- # For other methods, find nearest patch by direction vector
2082
- elev_rad = np.deg2rad(elev)
2083
- az_rad = np.deg2rad(az)
2084
- sun_dir = np.array([
2085
- np.cos(elev_rad) * np.sin(az_rad), # East
2086
- np.cos(elev_rad) * np.cos(az_rad), # North
2087
- np.sin(elev_rad) # Up
2088
- ])
2089
- dots = np.sum(directions * sun_dir, axis=1)
2090
- patch_idx = int(np.argmax(dots))
2091
-
2092
- if 0 <= patch_idx < n_patches:
2093
- cumulative_dni_per_patch[patch_idx] += dni * time_step_hours
2094
-
2095
- active_mask = cumulative_dni_per_patch > 0
2096
- n_active = int(np.sum(active_mask))
2097
-
2098
- if progress_report:
2099
- print(f" Sky patch optimization: {n_timesteps} timesteps -> {n_active} active patches ({sky_discretization})")
2100
- print(f" Total cumulative DHI: {total_cumulative_dhi:.1f} Wh/m²")
2101
-
2102
- # First pass: compute diffuse component using SVF (if available) or a single call
2103
- if face_svf is not None and len(face_svf) == n_faces:
2104
- cumulative_diffuse = face_svf * total_cumulative_dhi
2105
- else:
2106
- # Compute diffuse using a single call with sun at zenith
2107
- diffuse_mesh = get_building_solar_irradiance(
2108
- voxcity,
2109
- building_svf_mesh=building_svf_mesh,
2110
- azimuth_degrees_ori=180.0,
2111
- elevation_degrees=45.0,
2112
- direct_normal_irradiance=0.0,
2113
- diffuse_irradiance=1.0,
2114
- progress_report=False,
2115
- **kwargs
2116
- )
2117
- if diffuse_mesh is not None and 'diffuse' in diffuse_mesh.metadata:
2118
- base_diffuse = diffuse_mesh.metadata['diffuse']
2119
- cumulative_diffuse = np.nan_to_num(base_diffuse, nan=0.0) * total_cumulative_dhi
2120
-
2121
- # Second pass: loop over active patches for direct component
2122
- active_indices = np.where(active_mask)[0]
2123
- for i, patch_idx in enumerate(active_indices):
2124
- az_deg = patches[patch_idx, 0]
2125
- el_deg = patches[patch_idx, 1]
2126
- cumulative_dni_patch = cumulative_dni_per_patch[patch_idx]
2127
-
2128
- irradiance_mesh = get_building_solar_irradiance(
2129
- voxcity,
2130
- building_svf_mesh=building_svf_mesh,
2131
- azimuth_degrees_ori=az_deg,
2132
- elevation_degrees=el_deg,
2133
- direct_normal_irradiance=1.0, # Unit irradiance, scale by cumulative
2134
- diffuse_irradiance=0.0, # Diffuse handled separately
2135
- progress_report=False,
2136
- **kwargs
2137
- )
2138
-
2139
- if irradiance_mesh is not None and hasattr(irradiance_mesh, 'metadata'):
2140
- if 'direct' in irradiance_mesh.metadata:
2141
- direct_vals = irradiance_mesh.metadata['direct']
2142
- if len(direct_vals) == n_faces:
2143
- cumulative_direct += np.nan_to_num(direct_vals, nan=0.0) * cumulative_dni_patch
2144
-
2145
- if progress_report and ((i + 1) % max(1, len(active_indices) // 10) == 0 or i == len(active_indices) - 1):
2146
- pct = (i + 1) * 100.0 / len(active_indices)
2147
- print(f" Patch {i+1}/{len(active_indices)} ({pct:.1f}%)")
2148
-
2149
- # Combine direct and diffuse
2150
- cumulative_global = cumulative_direct + cumulative_diffuse
2151
-
2152
- else:
2153
- # Per-timestep path (no optimization)
2154
- if progress_report:
2155
- print(f" Processing {n_timesteps} timesteps (no sky patch optimization)...")
2156
-
2157
- for t_idx, (timestamp, row) in enumerate(df_period_utc.iterrows()):
2158
- dni = float(row['DNI']) * direct_normal_irradiance_scaling
2159
- dhi = float(row['DHI']) * diffuse_irradiance_scaling
2160
-
2161
- elevation = float(solar_positions.loc[timestamp, 'elevation'])
2162
- azimuth = float(solar_positions.loc[timestamp, 'azimuth'])
2163
-
2164
- # Skip nighttime
2165
- if elevation <= 0 or (dni <= 0 and dhi <= 0):
2166
- continue
2167
-
2168
- # Compute instantaneous irradiance for this timestep
2169
- irradiance_mesh = get_building_solar_irradiance(
2170
- voxcity,
2171
- building_svf_mesh=building_svf_mesh,
2172
- azimuth_degrees_ori=azimuth,
2173
- elevation_degrees=elevation,
2174
- direct_normal_irradiance=dni,
2175
- diffuse_irradiance=dhi,
2176
- progress_report=False,
2177
- **kwargs
2178
- )
2179
-
2180
- if irradiance_mesh is not None and hasattr(irradiance_mesh, 'metadata'):
2181
- # Accumulate (convert W/m² to Wh/m² by multiplying by time_step_hours)
2182
- if 'direct' in irradiance_mesh.metadata:
2183
- direct_vals = irradiance_mesh.metadata['direct']
2184
- if len(direct_vals) == n_faces:
2185
- cumulative_direct += np.nan_to_num(direct_vals, nan=0.0) * time_step_hours
2186
- if 'diffuse' in irradiance_mesh.metadata:
2187
- diffuse_vals = irradiance_mesh.metadata['diffuse']
2188
- if len(diffuse_vals) == n_faces:
2189
- cumulative_diffuse += np.nan_to_num(diffuse_vals, nan=0.0) * time_step_hours
2190
- if 'global' in irradiance_mesh.metadata:
2191
- global_vals = irradiance_mesh.metadata['global']
2192
- if len(global_vals) == n_faces:
2193
- cumulative_global += np.nan_to_num(global_vals, nan=0.0) * time_step_hours
2194
-
2195
- if progress_report and (t_idx + 1) % max(1, n_timesteps // 10) == 0:
2196
- print(f" Processed {t_idx + 1}/{n_timesteps} timesteps ({100*(t_idx+1)/n_timesteps:.1f}%)")
2197
-
2198
- # -------------------------------------------------------------------------
2199
- # Set vertical faces on domain perimeter to NaN (matching VoxCity behavior)
2200
- # -------------------------------------------------------------------------
2201
- voxel_data = voxcity.voxels.classes
2202
- meshsize = voxcity.voxels.meta.meshsize
2203
- ny_vc, nx_vc, nz = voxel_data.shape
2204
- grid_bounds_real = np.array([
2205
- [0.0, 0.0, 0.0],
2206
- [ny_vc * meshsize, nx_vc * meshsize, nz * meshsize]
2207
- ], dtype=np.float64)
2208
- boundary_epsilon = meshsize * 0.05
2209
-
2210
- mesh_face_centers = result_mesh.triangles_center
2211
- mesh_face_normals = result_mesh.face_normals
2212
-
2213
- # Detect vertical faces (normal z-component near zero)
2214
- is_vertical = np.abs(mesh_face_normals[:, 2]) < 0.01
2215
-
2216
- # Detect faces on domain boundary
2217
- on_x_min = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[0, 0]) < boundary_epsilon
2218
- on_y_min = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[0, 1]) < boundary_epsilon
2219
- on_x_max = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[1, 0]) < boundary_epsilon
2220
- on_y_max = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[1, 1]) < boundary_epsilon
2221
-
2222
- is_boundary_vertical = is_vertical & (on_x_min | on_y_min | on_x_max | on_y_max)
2223
-
2224
- # Set boundary vertical faces to NaN
2225
- cumulative_direct[is_boundary_vertical] = np.nan
2226
- cumulative_diffuse[is_boundary_vertical] = np.nan
2227
- cumulative_global[is_boundary_vertical] = np.nan
2228
-
2229
- if progress_report:
2230
- n_boundary = np.sum(is_boundary_vertical)
2231
- print(f" Boundary vertical faces set to NaN: {n_boundary}/{n_faces} ({100*n_boundary/n_faces:.1f}%)")
2232
-
2233
- # Store results in mesh metadata
2234
- result_mesh.metadata = getattr(result_mesh, 'metadata', {})
2235
- result_mesh.metadata['cumulative_direct'] = cumulative_direct
2236
- result_mesh.metadata['cumulative_diffuse'] = cumulative_diffuse
2237
- result_mesh.metadata['cumulative_global'] = cumulative_global
2238
- result_mesh.metadata['direct'] = cumulative_direct # VoxCity API alias
2239
- result_mesh.metadata['diffuse'] = cumulative_diffuse # VoxCity API alias
2240
- result_mesh.metadata['global'] = cumulative_global # VoxCity API alias
2241
- if face_svf is not None:
2242
- result_mesh.metadata['svf'] = face_svf
2243
-
2244
- if progress_report:
2245
- valid_mask = ~np.isnan(cumulative_global)
2246
- total_irradiance = np.nansum(cumulative_global)
2247
- print(f"Cumulative irradiance computation complete:")
2248
- print(f" Total faces: {n_faces}, Valid: {np.sum(valid_mask)}")
2249
- print(f" Mean cumulative: {np.nanmean(cumulative_global):.1f} Wh/m²")
2250
- print(f" Max cumulative: {np.nanmax(cumulative_global):.1f} Wh/m²")
2251
-
2252
- # Export if requested
2253
- if kwargs.get('obj_export', False):
2254
- import os
2255
- output_dir = kwargs.get('output_directory', 'output')
2256
- output_file_name = kwargs.get('output_file_name', 'cumulative_building_irradiance')
2257
- os.makedirs(output_dir, exist_ok=True)
2258
- try:
2259
- result_mesh.export(f"{output_dir}/{output_file_name}.obj")
2260
- if progress_report:
2261
- print(f"Exported to {output_dir}/{output_file_name}.obj")
2262
- except Exception as e:
2263
- print(f"Error exporting mesh: {e}")
2264
-
2265
- return result_mesh
2266
-
2267
-
2268
- def get_building_global_solar_irradiance_using_epw(
2269
- voxcity,
2270
- calc_type: str = 'instantaneous',
2271
- direct_normal_irradiance_scaling: float = 1.0,
2272
- diffuse_irradiance_scaling: float = 1.0,
2273
- building_svf_mesh=None,
2274
- **kwargs
2275
- ):
2276
- """
2277
- GPU-accelerated building surface irradiance using EPW weather data.
2278
-
2279
- This function matches the signature of voxcity.simulator.solar.get_building_global_solar_irradiance_using_epw
2280
- using Taichi GPU acceleration.
2281
-
2282
- Args:
2283
- voxcity: VoxCity object
2284
- calc_type: 'instantaneous' or 'cumulative'
2285
- direct_normal_irradiance_scaling: Scaling factor for DNI
2286
- diffuse_irradiance_scaling: Scaling factor for DHI
2287
- building_svf_mesh: Pre-computed building mesh with SVF (optional)
2288
- **kwargs: Additional parameters including:
2289
- - epw_file_path (str): Path to EPW file
2290
- - download_nearest_epw (bool): Download nearest EPW (default: False)
2291
- - calc_time (str): For instantaneous: 'MM-DD HH:MM:SS'
2292
- - period_start, period_end (str): For cumulative: 'MM-DD HH:MM:SS'
2293
- - rectangle_vertices: Location vertices
2294
- - progress_report (bool): Print progress
2295
- - with_reflections (bool): Enable multi-bounce surface reflections (default: False).
2296
- Set to True for more accurate results but slower computation.
2297
-
2298
- Returns:
2299
- Trimesh object with irradiance values (W/m² or Wh/m²) in metadata
2300
- """
2301
- from datetime import datetime
2302
- import pytz
2303
-
2304
- # NOTE: We frequently forward **kwargs to lower-level functions; ensure
2305
- # we don't pass duplicate keyword args (e.g., progress_report).
2306
- progress_report = kwargs.get('progress_report', False)
2307
- kwargs = dict(kwargs)
2308
- kwargs.pop('progress_report', None)
2309
-
2310
- # Get EPW file
2311
- epw_file_path = kwargs.get('epw_file_path', None)
2312
- download_nearest_epw = kwargs.get('download_nearest_epw', False)
2313
-
2314
- rectangle_vertices = kwargs.get('rectangle_vertices', None)
2315
- if rectangle_vertices is None:
2316
- extras = getattr(voxcity, 'extras', None)
2317
- if isinstance(extras, dict):
2318
- rectangle_vertices = extras.get('rectangle_vertices', None)
2319
-
2320
- if download_nearest_epw:
2321
- if rectangle_vertices is None:
2322
- raise ValueError("rectangle_vertices required to download nearest EPW file")
2323
-
2324
- try:
2325
- from voxcity.utils.weather import get_nearest_epw_from_climate_onebuilding
2326
- lons = [coord[0] for coord in rectangle_vertices]
2327
- lats = [coord[1] for coord in rectangle_vertices]
2328
- center_lon = (min(lons) + max(lons)) / 2
2329
- center_lat = (min(lats) + max(lats)) / 2
2330
- output_dir = kwargs.get('output_dir', 'output')
2331
- max_distance = kwargs.get('max_distance', 100)
2332
-
2333
- epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
2334
- longitude=center_lon,
2335
- latitude=center_lat,
2336
- output_dir=output_dir,
2337
- max_distance=max_distance,
2338
- extract_zip=True,
2339
- load_data=True
2340
- )
2341
- except ImportError:
2342
- raise ImportError("VoxCity weather utilities required for EPW download")
2343
-
2344
- if not epw_file_path:
2345
- raise ValueError("epw_file_path must be provided when download_nearest_epw is False")
2346
-
2347
- # Read EPW
2348
- try:
2349
- from voxcity.utils.weather import read_epw_for_solar_simulation
2350
- df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
2351
- except ImportError:
2352
- # Fallback to our EPW reader
2353
- from .epw import read_epw_header, read_epw_solar_data
2354
- location = read_epw_header(epw_file_path)
2355
- df = read_epw_solar_data(epw_file_path)
2356
- lon, lat, tz = location.longitude, location.latitude, location.timezone
2357
-
2358
- if df.empty:
2359
- raise ValueError("No data in EPW file.")
2360
-
2361
- # Create building mesh for output (just geometry, no SVF computation)
2362
- # The RadiationModel computes SVF internally for voxel surfaces, so we don't need
2363
- # the expensive get_surface_view_factor() call. We just need the mesh geometry.
2364
- if building_svf_mesh is None:
2365
- try:
2366
- from voxcity.geoprocessor.mesh import create_voxel_mesh
2367
- building_class_id = kwargs.get('building_class_id', -3)
2368
- voxel_data = voxcity.voxels.classes
2369
- meshsize = voxcity.voxels.meta.meshsize
2370
- building_id_grid = voxcity.buildings.ids
2371
-
2372
- building_svf_mesh = create_voxel_mesh(
2373
- voxel_data,
2374
- building_class_id,
2375
- meshsize,
2376
- building_id_grid=building_id_grid,
2377
- mesh_type='open_air'
2378
- )
2379
- if progress_report:
2380
- n_faces = len(building_svf_mesh.faces) if building_svf_mesh is not None else 0
2381
- print(f"Created building mesh with {n_faces} faces")
2382
- except ImportError:
2383
- pass # Will fail later with "Building mesh has no faces" error
2384
-
2385
- if calc_type == 'instantaneous':
2386
- calc_time = kwargs.get('calc_time', '01-01 12:00:00')
2387
- try:
2388
- calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%S')
2389
- except ValueError:
2390
- raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'")
2391
-
2392
- df_period = df[
2393
- (df.index.month == calc_dt.month) &
2394
- (df.index.day == calc_dt.day) &
2395
- (df.index.hour == calc_dt.hour)
2396
- ]
2397
- if df_period.empty:
2398
- raise ValueError("No EPW data at the specified time.")
2399
-
2400
- # Get solar position
2401
- offset_minutes = int(tz * 60)
2402
- local_tz = pytz.FixedOffset(offset_minutes)
2403
- df_local = df_period.copy()
2404
- df_local.index = df_local.index.tz_localize(local_tz)
2405
- df_utc = df_local.tz_convert(pytz.UTC)
2406
-
2407
- solar_positions = _get_solar_positions_astral(df_utc.index, lon, lat)
2408
- DNI = float(df_utc.iloc[0]['DNI']) * direct_normal_irradiance_scaling
2409
- DHI = float(df_utc.iloc[0]['DHI']) * diffuse_irradiance_scaling
2410
- azimuth_degrees = float(solar_positions.iloc[0]['azimuth'])
2411
- elevation_degrees = float(solar_positions.iloc[0]['elevation'])
2412
-
2413
- return get_building_solar_irradiance(
2414
- voxcity,
2415
- building_svf_mesh=building_svf_mesh,
2416
- azimuth_degrees_ori=azimuth_degrees,
2417
- elevation_degrees=elevation_degrees,
2418
- direct_normal_irradiance=DNI,
2419
- diffuse_irradiance=DHI,
2420
- **kwargs
2421
- )
2422
-
2423
- elif calc_type == 'cumulative':
2424
- period_start = kwargs.get('period_start', '01-01 00:00:00')
2425
- period_end = kwargs.get('period_end', '12-31 23:59:59')
2426
- time_step_hours = float(kwargs.get('time_step_hours', 1.0))
2427
-
2428
- # Avoid passing duplicates: we pass these explicitly below.
2429
- kwargs.pop('period_start', None)
2430
- kwargs.pop('period_end', None)
2431
- kwargs.pop('time_step_hours', None)
2432
-
2433
- return get_cumulative_building_solar_irradiance(
2434
- voxcity,
2435
- building_svf_mesh=building_svf_mesh,
2436
- weather_df=df,
2437
- lon=lon,
2438
- lat=lat,
2439
- tz=tz,
2440
- direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
2441
- diffuse_irradiance_scaling=diffuse_irradiance_scaling,
2442
- period_start=period_start,
2443
- period_end=period_end,
2444
- time_step_hours=time_step_hours,
2445
- **kwargs
2446
- )
2447
-
2448
- else:
2449
- raise ValueError(f"Unknown calc_type: {calc_type}. Use 'instantaneous' or 'cumulative'.")
2450
-
2451
-
2452
- def get_global_solar_irradiance_using_epw(
2453
- voxcity,
2454
- calc_type: str = 'instantaneous',
2455
- direct_normal_irradiance_scaling: float = 1.0,
2456
- diffuse_irradiance_scaling: float = 1.0,
2457
- show_plot: bool = False,
2458
- **kwargs
2459
- ) -> np.ndarray:
2460
- """
2461
- GPU-accelerated global irradiance from EPW file.
2462
-
2463
- This function matches the signature of voxcity.simulator.solar.get_global_solar_irradiance_using_epw
2464
- using Taichi GPU acceleration.
2465
-
2466
- Args:
2467
- voxcity: VoxCity object
2468
- calc_type: 'instantaneous' or 'cumulative'
2469
- direct_normal_irradiance_scaling: Scaling factor for DNI
2470
- diffuse_irradiance_scaling: Scaling factor for DHI
2471
- show_plot: Whether to display a matplotlib plot
2472
- **kwargs: Additional parameters including:
2473
- - epw_file_path (str): Path to EPW file
2474
- - download_nearest_epw (bool): Download nearest EPW (default: False)
2475
- - calc_time (str): For instantaneous: 'MM-DD HH:MM:SS'
2476
- - start_time, end_time (str): For cumulative: 'MM-DD HH:MM:SS'
2477
- - rectangle_vertices: Location vertices (for EPW download)
2478
-
2479
- Returns:
2480
- 2D numpy array of irradiance (W/m² or Wh/m²)
2481
- """
2482
- from datetime import datetime
2483
- import pytz
2484
-
2485
- # Get EPW file
2486
- epw_file_path = kwargs.get('epw_file_path', None)
2487
- download_nearest_epw = kwargs.get('download_nearest_epw', False)
2488
-
2489
- rectangle_vertices = kwargs.get('rectangle_vertices', None)
2490
- if rectangle_vertices is None:
2491
- extras = getattr(voxcity, 'extras', None)
2492
- if isinstance(extras, dict):
2493
- rectangle_vertices = extras.get('rectangle_vertices', None)
2494
-
2495
- if download_nearest_epw:
2496
- if rectangle_vertices is None:
2497
- raise ValueError("rectangle_vertices required to download nearest EPW file")
2498
-
2499
- try:
2500
- from voxcity.utils.weather import get_nearest_epw_from_climate_onebuilding
2501
- lons = [coord[0] for coord in rectangle_vertices]
2502
- lats = [coord[1] for coord in rectangle_vertices]
2503
- center_lon = (min(lons) + max(lons)) / 2
2504
- center_lat = (min(lats) + max(lats)) / 2
2505
- output_dir = kwargs.get('output_dir', 'output')
2506
- max_distance = kwargs.get('max_distance', 100)
2507
-
2508
- epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
2509
- longitude=center_lon,
2510
- latitude=center_lat,
2511
- output_dir=output_dir,
2512
- max_distance=max_distance,
2513
- extract_zip=True,
2514
- load_data=True
2515
- )
2516
- except ImportError:
2517
- raise ImportError("VoxCity weather utilities required for EPW download")
2518
-
2519
- if not epw_file_path:
2520
- raise ValueError("epw_file_path must be provided when download_nearest_epw is False")
2521
-
2522
- # Read EPW
2523
- try:
2524
- from voxcity.utils.weather import read_epw_for_solar_simulation
2525
- df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
2526
- except ImportError:
2527
- # Fallback to our EPW reader
2528
- from .epw import read_epw_header, read_epw_solar_data
2529
- location = read_epw_header(epw_file_path)
2530
- df = read_epw_solar_data(epw_file_path)
2531
- lon, lat, tz = location.longitude, location.latitude, location.timezone
2532
-
2533
- if df.empty:
2534
- raise ValueError("No data in EPW file.")
2535
-
2536
- if calc_type == 'instantaneous':
2537
- calc_time = kwargs.get('calc_time', '01-01 12:00:00')
2538
- try:
2539
- calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%S')
2540
- except ValueError:
2541
- raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'")
2542
-
2543
- df_period = df[
2544
- (df.index.month == calc_dt.month) &
2545
- (df.index.day == calc_dt.day) &
2546
- (df.index.hour == calc_dt.hour)
2547
- ]
2548
- if df_period.empty:
2549
- raise ValueError("No EPW data at the specified time.")
2550
-
2551
- # Get solar position
2552
- offset_minutes = int(tz * 60)
2553
- local_tz = pytz.FixedOffset(offset_minutes)
2554
- df_local = df_period.copy()
2555
- df_local.index = df_local.index.tz_localize(local_tz)
2556
- df_utc = df_local.tz_convert(pytz.UTC)
2557
-
2558
- solar_positions = _get_solar_positions_astral(df_utc.index, lon, lat)
2559
- DNI = float(df_utc.iloc[0]['DNI']) * direct_normal_irradiance_scaling
2560
- DHI = float(df_utc.iloc[0]['DHI']) * diffuse_irradiance_scaling
2561
- azimuth_degrees = float(solar_positions.iloc[0]['azimuth'])
2562
- elevation_degrees = float(solar_positions.iloc[0]['elevation'])
2563
-
2564
- return get_global_solar_irradiance_map(
2565
- voxcity,
2566
- azimuth_degrees,
2567
- elevation_degrees,
2568
- DNI,
2569
- DHI,
2570
- show_plot=show_plot,
2571
- **kwargs
2572
- )
2573
-
2574
- elif calc_type == 'cumulative':
2575
- return get_cumulative_global_solar_irradiance(
2576
- voxcity,
2577
- df,
2578
- lon,
2579
- lat,
2580
- tz,
2581
- direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
2582
- diffuse_irradiance_scaling=diffuse_irradiance_scaling,
2583
- show_plot=show_plot,
2584
- **kwargs
2585
- )
2586
-
2587
- else:
2588
- raise ValueError(f"Unknown calc_type: {calc_type}. Use 'instantaneous' or 'cumulative'.")
2589
-
2590
-
2591
- def save_irradiance_mesh(mesh, filepath: str) -> None:
2592
- """
2593
- Save irradiance mesh to pickle file.
2594
-
2595
- Args:
2596
- mesh: Trimesh object with irradiance metadata
2597
- filepath: Output file path
2598
- """
2599
- import pickle
2600
- with open(filepath, 'wb') as f:
2601
- pickle.dump(mesh, f)
2602
-
2603
-
2604
- def load_irradiance_mesh(filepath: str):
2605
- """
2606
- Load irradiance mesh from pickle file.
2607
-
2608
- Args:
2609
- filepath: Input file path
2610
-
2611
- Returns:
2612
- Trimesh object with irradiance metadata
2613
- """
2614
- import pickle
2615
- with open(filepath, 'rb') as f:
2616
- return pickle.load(f)
2617
-
2618
-
2619
- # =============================================================================
2620
- # Internal Helper Functions
2621
- # =============================================================================
2622
-
2623
- # Module-level cache for GPU ray tracer fields
2624
- @dataclass
2625
- class _CachedGPURayTracer:
2626
- """Cached Taichi fields for GPU ray tracing."""
2627
- is_solid_field: object # ti.field
2628
- lad_field: object # ti.field
2629
- transmittance_field: object # ti.field
2630
- topo_top_field: object # ti.field
2631
- trace_rays_kernel: object # compiled kernel
2632
- voxel_shape: Tuple[int, int, int]
2633
- meshsize: float
2634
- voxel_data_id: int = 0 # id() of last voxel_data array to detect changes
2635
-
2636
-
2637
- _gpu_ray_tracer_cache: Optional[_CachedGPURayTracer] = None
2638
-
2639
- # Module-level cached kernel for topo computation
2640
- _cached_topo_kernel = None
2641
-
2642
-
2643
- def _get_cached_topo_kernel():
2644
- """Get or create cached topography kernel."""
2645
- global _cached_topo_kernel
2646
- if _cached_topo_kernel is not None:
2647
- return _cached_topo_kernel
2648
-
2649
- import taichi as ti
2650
- from ..init_taichi import ensure_initialized
2651
- ensure_initialized()
2652
-
2653
- @ti.kernel
2654
- def _topo_kernel(
2655
- is_solid_f: ti.template(),
2656
- topo_f: ti.template(),
2657
- grid_nz: ti.i32
2658
- ):
2659
- for i, j in topo_f:
2660
- max_k = -1
2661
- for k in range(grid_nz):
2662
- if is_solid_f[i, j, k] == 1:
2663
- max_k = k
2664
- topo_f[i, j] = max_k
2665
-
2666
- _cached_topo_kernel = _topo_kernel
2667
- return _cached_topo_kernel
2668
-
2669
-
2670
- def _compute_topo_gpu(is_solid_field, topo_top_field, nz: int):
2671
- """Compute topography (highest solid voxel) using GPU."""
2672
- kernel = _get_cached_topo_kernel()
2673
- kernel(is_solid_field, topo_top_field, nz)
2674
-
2675
-
2676
- # Module-level cached kernel for ray tracing
2677
- _cached_trace_rays_kernel = None
2678
-
2679
-
2680
- def _get_cached_trace_rays_kernel():
2681
- """Get or create cached ray tracing kernel."""
2682
- global _cached_trace_rays_kernel
2683
- if _cached_trace_rays_kernel is not None:
2684
- return _cached_trace_rays_kernel
2685
-
2686
- import taichi as ti
2687
- from ..init_taichi import ensure_initialized
2688
- ensure_initialized()
2689
-
2690
- @ti.kernel
2691
- def trace_rays_kernel(
2692
- is_solid_f: ti.template(),
2693
- lad_f: ti.template(),
2694
- topo_f: ti.template(),
2695
- trans_f: ti.template(),
2696
- sun_x: ti.f32, sun_y: ti.f32, sun_z: ti.f32,
2697
- vhk: ti.i32, ext: ti.f32,
2698
- dx: ti.f32, step: ti.f32, max_dist: ti.f32,
2699
- grid_nx: ti.i32, grid_ny: ti.i32, grid_nz: ti.i32
2700
- ):
2701
- for i, j in trans_f:
2702
- ground_k = topo_f[i, j]
2703
- start_k = ground_k + vhk
2704
- if start_k < 0:
2705
- start_k = 0
2706
- if start_k >= grid_nz:
2707
- start_k = grid_nz - 1
2708
-
2709
- while start_k < grid_nz - 1 and is_solid_f[i, j, start_k] == 1:
2710
- start_k += 1
2711
-
2712
- if is_solid_f[i, j, start_k] == 1:
2713
- trans_f[i, j] = 0.0
2714
- else:
2715
- ox = (float(i) + 0.5) * dx
2716
- oy = (float(j) + 0.5) * dx
2717
- oz = (float(start_k) + 0.5) * dx
2718
-
2719
- trans = 1.0
2720
- t = step
2721
-
2722
- while t < max_dist and trans > 0.001:
2723
- px = ox + sun_x * t
2724
- py = oy + sun_y * t
2725
- pz = oz + sun_z * t
2726
-
2727
- gi = int(px / dx)
2728
- gj = int(py / dx)
2729
- gk = int(pz / dx)
2730
-
2731
- if gi < 0 or gi >= grid_nx or gj < 0 or gj >= grid_ny:
2732
- break
2733
- if gk < 0 or gk >= grid_nz:
2734
- break
2735
-
2736
- if is_solid_f[gi, gj, gk] == 1:
2737
- trans = 0.0
2738
- break
2739
-
2740
- lad_val = lad_f[gi, gj, gk]
2741
- if lad_val > 0.0:
2742
- trans *= ti.exp(-ext * lad_val * step)
2743
-
2744
- t += step
2745
-
2746
- trans_f[i, j] = trans
2747
-
2748
- _cached_trace_rays_kernel = trace_rays_kernel
2749
- return _cached_trace_rays_kernel
2750
-
2751
-
2752
- def _get_or_create_gpu_ray_tracer(
2753
- voxel_data: np.ndarray,
2754
- meshsize: float,
2755
- tree_lad: float = 1.0
2756
- ) -> _CachedGPURayTracer:
2757
- """
2758
- Get cached GPU ray tracer or create new one if cache is invalid.
2759
-
2760
- The Taichi fields and kernels are expensive to create, so we cache them.
2761
- """
2762
- global _gpu_ray_tracer_cache
2763
-
2764
- import taichi as ti
2765
- from ..init_taichi import ensure_initialized
2766
- ensure_initialized()
2767
-
2768
- nx, ny, nz = voxel_data.shape
2769
-
2770
- # Check if cache is valid
2771
- if _gpu_ray_tracer_cache is not None:
2772
- cache = _gpu_ray_tracer_cache
2773
- if cache.voxel_shape == (nx, ny, nz) and cache.meshsize == meshsize:
2774
- # Check if voxel data has changed (same array object = same data)
2775
- if cache.voxel_data_id == id(voxel_data):
2776
- # Data hasn't changed, reuse cached fields directly
2777
- return cache
2778
-
2779
- # Data changed, need to re-upload (but keep fields)
2780
- is_solid = np.zeros((nx, ny, nz), dtype=np.int32)
2781
- lad_array = np.zeros((nx, ny, nz), dtype=np.float32)
2782
-
2783
- for i in range(nx):
2784
- for j in range(ny):
2785
- for k in range(nz):
2786
- val = voxel_data[i, j, k]
2787
- if val == VOXCITY_BUILDING_CODE or val == VOXCITY_GROUND_CODE or val > 0:
2788
- is_solid[i, j, k] = 1
2789
- elif val == VOXCITY_TREE_CODE:
2790
- lad_array[i, j, k] = tree_lad
2791
-
2792
- cache.is_solid_field.from_numpy(is_solid)
2793
- cache.lad_field.from_numpy(lad_array)
2794
- cache.voxel_data_id = id(voxel_data)
2795
-
2796
- # Recompute topo
2797
- _compute_topo_gpu(cache.is_solid_field, cache.topo_top_field, nz)
2798
- return cache
2799
-
2800
- # Need to create new cache
2801
- is_solid = np.zeros((nx, ny, nz), dtype=np.int32)
2802
- lad_array = np.zeros((nx, ny, nz), dtype=np.float32)
2803
-
2804
- for i in range(nx):
2805
- for j in range(ny):
2806
- for k in range(nz):
2807
- val = voxel_data[i, j, k]
2808
- if val == VOXCITY_BUILDING_CODE or val == VOXCITY_GROUND_CODE or val > 0:
2809
- is_solid[i, j, k] = 1
2810
- elif val == VOXCITY_TREE_CODE:
2811
- lad_array[i, j, k] = tree_lad
2812
-
2813
- # Create Taichi fields
2814
- is_solid_field = ti.field(dtype=ti.i32, shape=(nx, ny, nz))
2815
- lad_field = ti.field(dtype=ti.f32, shape=(nx, ny, nz))
2816
- transmittance_field = ti.field(dtype=ti.f32, shape=(nx, ny))
2817
- topo_top_field = ti.field(dtype=ti.i32, shape=(nx, ny))
2818
-
2819
- is_solid_field.from_numpy(is_solid)
2820
- lad_field.from_numpy(lad_array)
2821
-
2822
- # Compute topography using cached kernel
2823
- _compute_topo_gpu(is_solid_field, topo_top_field, nz)
2824
-
2825
- # Get cached ray tracing kernel
2826
- trace_rays_kernel = _get_cached_trace_rays_kernel()
2827
-
2828
- # Cache it
2829
- _gpu_ray_tracer_cache = _CachedGPURayTracer(
2830
- is_solid_field=is_solid_field,
2831
- lad_field=lad_field,
2832
- transmittance_field=transmittance_field,
2833
- topo_top_field=topo_top_field,
2834
- trace_rays_kernel=trace_rays_kernel,
2835
- voxel_shape=(nx, ny, nz),
2836
- meshsize=meshsize,
2837
- voxel_data_id=id(voxel_data)
2838
- )
2839
-
2840
- return _gpu_ray_tracer_cache
2841
-
2842
-
2843
- def _compute_direct_transmittance_map_gpu(
2844
- voxel_data: np.ndarray,
2845
- sun_direction: Tuple[float, float, float],
2846
- view_point_height: float,
2847
- meshsize: float,
2848
- tree_k: float = 0.6,
2849
- tree_lad: float = 1.0
2850
- ) -> np.ndarray:
2851
- """
2852
- Compute direct solar transmittance map using GPU ray tracing.
2853
-
2854
- Returns a 2D array where each cell contains the transmittance (0-1)
2855
- for direct sunlight from the given direction.
2856
-
2857
- Uses cached Taichi fields to avoid expensive re-creation.
2858
- """
2859
- nx, ny, nz = voxel_data.shape
2860
-
2861
- # Get or create cached ray tracer
2862
- cache = _get_or_create_gpu_ray_tracer(voxel_data, meshsize, tree_lad)
2863
-
2864
- # Run ray tracing with current sun direction
2865
- sun_dir_x = float(sun_direction[0])
2866
- sun_dir_y = float(sun_direction[1])
2867
- sun_dir_z = float(sun_direction[2])
2868
- view_height_k = max(1, int(view_point_height / meshsize))
2869
- step_size = meshsize * 0.5
2870
- max_trace_dist = float(max(nx, ny, nz) * meshsize * 2)
2871
-
2872
- cache.trace_rays_kernel(
2873
- cache.is_solid_field,
2874
- cache.lad_field,
2875
- cache.topo_top_field,
2876
- cache.transmittance_field,
2877
- sun_dir_x, sun_dir_y, sun_dir_z,
2878
- view_height_k, tree_k,
2879
- meshsize, step_size, max_trace_dist,
2880
- nx, ny, nz # Grid dimensions as parameters
2881
- )
2882
-
2883
- return cache.transmittance_field.to_numpy()
2884
-
2885
-
2886
- def _get_solar_positions_astral(times, lon: float, lat: float):
2887
- """
2888
- Compute solar azimuth and elevation using Astral library.
2889
- """
2890
- import pandas as pd
2891
- try:
2892
- from astral import Observer
2893
- from astral.sun import elevation, azimuth
2894
-
2895
- observer = Observer(latitude=lat, longitude=lon)
2896
- df_pos = pd.DataFrame(index=times, columns=['azimuth', 'elevation'], dtype=float)
2897
- for t in times:
2898
- el = elevation(observer=observer, dateandtime=t)
2899
- az = azimuth(observer=observer, dateandtime=t)
2900
- df_pos.at[t, 'elevation'] = el
2901
- df_pos.at[t, 'azimuth'] = az
2902
- return df_pos
2903
- except ImportError:
2904
- raise ImportError("Astral library required for solar position calculation. Install with: pip install astral")
2905
-
2906
-
2907
- # Public alias for VoxCity API compatibility
2908
- def get_solar_positions_astral(times, lon: float, lat: float):
2909
- """
2910
- Compute solar azimuth and elevation for given times and location using Astral.
2911
-
2912
- This function matches the signature of voxcity.simulator.solar.get_solar_positions_astral.
2913
-
2914
- Args:
2915
- times: Pandas DatetimeIndex of times (should be timezone-aware, preferably UTC)
2916
- lon: Longitude in degrees
2917
- lat: Latitude in degrees
2918
-
2919
- Returns:
2920
- DataFrame indexed by times with columns ['azimuth', 'elevation'] in degrees
2921
- """
2922
- return _get_solar_positions_astral(times, lon, lat)
2923
-
2924
-
2925
- def _export_irradiance_to_obj(voxcity, irradiance_map: np.ndarray, output_name: str = 'irradiance', **kwargs):
2926
- """Export irradiance map to OBJ file using VoxCity utilities."""
2927
- try:
2928
- from voxcity.exporter.obj import grid_to_obj
2929
- meshsize = voxcity.voxels.meta.meshsize
2930
- dem_grid = voxcity.dem.elevation if hasattr(voxcity, 'dem') and voxcity.dem else np.zeros_like(irradiance_map)
2931
- output_dir = kwargs.get('output_directory', 'output')
2932
- view_point_height = kwargs.get('view_point_height', 1.5)
2933
- colormap = kwargs.get('colormap', 'magma')
2934
- vmin = kwargs.get('vmin', 0.0)
2935
- vmax = kwargs.get('vmax', float(np.nanmax(irradiance_map)) if not np.all(np.isnan(irradiance_map)) else 1.0)
2936
- num_colors = kwargs.get('num_colors', 10)
2937
- alpha = kwargs.get('alpha', 1.0)
2938
-
2939
- grid_to_obj(
2940
- irradiance_map,
2941
- dem_grid,
2942
- output_dir,
2943
- output_name,
2944
- meshsize,
2945
- view_point_height,
2946
- colormap_name=colormap,
2947
- num_colors=num_colors,
2948
- alpha=alpha,
2949
- vmin=vmin,
2950
- vmax=vmax
2951
- )
2952
- except ImportError:
2953
- print("VoxCity exporter.obj required for OBJ export")