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.
- voxcity/simulator/solar/__init__.py +13 -0
- voxcity/simulator_gpu/__init__.py +73 -98
- voxcity/simulator_gpu/domain.py +30 -256
- voxcity/simulator_gpu/raytracing.py +153 -0
- voxcity/simulator_gpu/solar/__init__.py +45 -1
- voxcity/simulator_gpu/solar/domain.py +57 -0
- voxcity/simulator_gpu/solar/integration.py +1622 -253
- voxcity/simulator_gpu/solar/mask.py +459 -0
- voxcity/simulator_gpu/solar/raytracing.py +28 -532
- voxcity/simulator_gpu/solar/volumetric.py +962 -14
- {voxcity-1.0.13.dist-info → voxcity-1.0.15.dist-info}/METADATA +1 -1
- {voxcity-1.0.13.dist-info → voxcity-1.0.15.dist-info}/RECORD +15 -25
- voxcity/simulator_gpu/common/__init__.py +0 -9
- voxcity/simulator_gpu/common/geometry.py +0 -11
- voxcity/simulator_gpu/environment.yml +0 -11
- voxcity/simulator_gpu/integration.py +0 -15
- voxcity/simulator_gpu/kernels.py +0 -56
- voxcity/simulator_gpu/radiation.py +0 -28
- voxcity/simulator_gpu/sky.py +0 -9
- voxcity/simulator_gpu/solar/voxcity.py +0 -2953
- voxcity/simulator_gpu/temporal.py +0 -13
- voxcity/simulator_gpu/utils.py +0 -25
- voxcity/simulator_gpu/view.py +0 -32
- {voxcity-1.0.13.dist-info → voxcity-1.0.15.dist-info}/WHEEL +0 -0
- {voxcity-1.0.13.dist-info → voxcity-1.0.15.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-1.0.13.dist-info → voxcity-1.0.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -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")
|