voxcity 1.0.2__py3-none-any.whl → 1.0.15__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voxcity/downloader/ocean.py +559 -0
- voxcity/generator/api.py +6 -0
- voxcity/generator/grids.py +45 -32
- voxcity/generator/pipeline.py +327 -27
- voxcity/geoprocessor/draw.py +14 -8
- voxcity/geoprocessor/raster/__init__.py +2 -0
- voxcity/geoprocessor/raster/core.py +31 -0
- voxcity/geoprocessor/raster/landcover.py +173 -49
- voxcity/geoprocessor/raster/raster.py +1 -1
- voxcity/models.py +2 -0
- voxcity/simulator/solar/__init__.py +13 -0
- voxcity/simulator_gpu/__init__.py +90 -0
- voxcity/simulator_gpu/core.py +322 -0
- voxcity/simulator_gpu/domain.py +36 -0
- voxcity/simulator_gpu/init_taichi.py +154 -0
- voxcity/simulator_gpu/raytracing.py +776 -0
- voxcity/simulator_gpu/solar/__init__.py +222 -0
- voxcity/simulator_gpu/solar/core.py +66 -0
- voxcity/simulator_gpu/solar/csf.py +1249 -0
- voxcity/simulator_gpu/solar/domain.py +618 -0
- voxcity/simulator_gpu/solar/epw.py +421 -0
- voxcity/simulator_gpu/solar/integration.py +4322 -0
- voxcity/simulator_gpu/solar/mask.py +459 -0
- voxcity/simulator_gpu/solar/radiation.py +3019 -0
- voxcity/simulator_gpu/solar/raytracing.py +182 -0
- voxcity/simulator_gpu/solar/reflection.py +533 -0
- voxcity/simulator_gpu/solar/sky.py +907 -0
- voxcity/simulator_gpu/solar/solar.py +337 -0
- voxcity/simulator_gpu/solar/svf.py +446 -0
- voxcity/simulator_gpu/solar/volumetric.py +2099 -0
- voxcity/simulator_gpu/visibility/__init__.py +109 -0
- voxcity/simulator_gpu/visibility/geometry.py +278 -0
- voxcity/simulator_gpu/visibility/integration.py +808 -0
- voxcity/simulator_gpu/visibility/landmark.py +753 -0
- voxcity/simulator_gpu/visibility/view.py +944 -0
- voxcity/visualizer/renderer.py +2 -1
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/METADATA +16 -53
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/RECORD +41 -16
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/WHEEL +0 -0
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,4322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VoxCity Integration Module for palm_solar
|
|
3
|
+
|
|
4
|
+
This module provides utilities for loading VoxCity models and converting them
|
|
5
|
+
to palm_solar Domain objects with proper material-specific albedo values.
|
|
6
|
+
|
|
7
|
+
VoxCity models contain:
|
|
8
|
+
- 3D voxel grids with building, tree, and ground information
|
|
9
|
+
- Land cover classification codes
|
|
10
|
+
- DEM (Digital Elevation Model) for terrain
|
|
11
|
+
- Building heights and IDs
|
|
12
|
+
- Tree canopy data
|
|
13
|
+
|
|
14
|
+
This module handles:
|
|
15
|
+
- Loading VoxCity pickle files
|
|
16
|
+
- Converting voxel grids to palm_solar Domain
|
|
17
|
+
- Mapping land cover classes to surface albedo values
|
|
18
|
+
- Creating surface material types for accurate radiation simulation
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
from typing import Dict, Optional, Tuple, Union
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from .domain import Domain
|
|
27
|
+
from .radiation import RadiationConfig
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# VoxCity voxel class codes (from voxcity/generator/voxelizer.py)
|
|
31
|
+
VOXCITY_GROUND_CODE = -1
|
|
32
|
+
VOXCITY_TREE_CODE = -2
|
|
33
|
+
VOXCITY_BUILDING_CODE = -3
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# =============================================================================
|
|
37
|
+
# Common Helper Functions (reduces code duplication)
|
|
38
|
+
# =============================================================================
|
|
39
|
+
|
|
40
|
+
def _get_location_from_voxcity(voxcity, default_lat: float = 1.35, default_lon: float = 103.82) -> Tuple[float, float]:
|
|
41
|
+
"""
|
|
42
|
+
Extract latitude/longitude from VoxCity object or return defaults.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
voxcity: VoxCity object with extras containing rectangle_vertices
|
|
46
|
+
default_lat: Default latitude if not found (Singapore)
|
|
47
|
+
default_lon: Default longitude if not found (Singapore)
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
Tuple of (origin_lat, origin_lon)
|
|
51
|
+
"""
|
|
52
|
+
extras = getattr(voxcity, 'extras', None)
|
|
53
|
+
if isinstance(extras, dict):
|
|
54
|
+
rectangle_vertices = extras.get('rectangle_vertices', None)
|
|
55
|
+
else:
|
|
56
|
+
rectangle_vertices = None
|
|
57
|
+
|
|
58
|
+
if rectangle_vertices is not None and len(rectangle_vertices) > 0:
|
|
59
|
+
lons = [v[0] for v in rectangle_vertices]
|
|
60
|
+
lats = [v[1] for v in rectangle_vertices]
|
|
61
|
+
return np.mean(lats), np.mean(lons)
|
|
62
|
+
|
|
63
|
+
return default_lat, default_lon
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _convert_voxel_data_to_arrays(
|
|
67
|
+
voxel_data: np.ndarray,
|
|
68
|
+
default_lad: float = 1.0
|
|
69
|
+
) -> Tuple[np.ndarray, np.ndarray]:
|
|
70
|
+
"""
|
|
71
|
+
Convert VoxCity voxel codes to is_solid and LAD arrays using vectorized operations.
|
|
72
|
+
|
|
73
|
+
This is 10-100x faster than triple-nested Python loops for large grids.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
voxel_data: 3D array of VoxCity voxel class codes
|
|
77
|
+
default_lad: Default Leaf Area Density for tree voxels (m²/m³)
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Tuple of (is_solid, lad) numpy arrays with same shape as voxel_data
|
|
81
|
+
"""
|
|
82
|
+
# Vectorized solid detection: buildings (-3), ground (-1), or positive land cover codes
|
|
83
|
+
is_solid = (
|
|
84
|
+
(voxel_data == VOXCITY_BUILDING_CODE) |
|
|
85
|
+
(voxel_data == VOXCITY_GROUND_CODE) |
|
|
86
|
+
(voxel_data > 0)
|
|
87
|
+
).astype(np.int32)
|
|
88
|
+
|
|
89
|
+
# Vectorized LAD assignment: only tree voxels (-2) have LAD
|
|
90
|
+
lad = np.where(voxel_data == VOXCITY_TREE_CODE, default_lad, 0.0).astype(np.float32)
|
|
91
|
+
|
|
92
|
+
return is_solid, lad
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _compute_valid_ground_vectorized(voxel_data: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
96
|
+
"""
|
|
97
|
+
Compute valid ground mask and ground k-levels using vectorized operations.
|
|
98
|
+
|
|
99
|
+
Valid ground cells are those where:
|
|
100
|
+
- The transition from solid to air/tree occurs
|
|
101
|
+
- The solid below is not water (7,8,9) or building/underground (negative codes)
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
voxel_data: 3D array of VoxCity voxel class codes (ni, nj, nk)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Tuple of (valid_ground 2D bool array, ground_k 2D int array)
|
|
108
|
+
ground_k[i,j] = -1 means no valid ground found
|
|
109
|
+
"""
|
|
110
|
+
ni, nj, nk = voxel_data.shape
|
|
111
|
+
|
|
112
|
+
# Water/special class codes to exclude
|
|
113
|
+
WATER_CLASSES = {7, 8, 9}
|
|
114
|
+
AIR_OR_TREE = {0, VOXCITY_TREE_CODE}
|
|
115
|
+
|
|
116
|
+
valid_ground = np.zeros((ni, nj), dtype=bool)
|
|
117
|
+
ground_k = np.full((ni, nj), -1, dtype=np.int32)
|
|
118
|
+
|
|
119
|
+
# Vectorize over k: find first transition from solid to air/tree
|
|
120
|
+
# For each (i,j), scan upward to find the first air/tree cell above a solid cell
|
|
121
|
+
for k in range(1, nk):
|
|
122
|
+
# Current cell is air (0) or tree (-2)
|
|
123
|
+
curr_is_air_or_tree = (voxel_data[:, :, k] == 0) | (voxel_data[:, :, k] == VOXCITY_TREE_CODE)
|
|
124
|
+
|
|
125
|
+
# Cell below is NOT air or tree (i.e., it's solid)
|
|
126
|
+
below_val = voxel_data[:, :, k - 1]
|
|
127
|
+
below_is_solid = (below_val != 0) & (below_val != VOXCITY_TREE_CODE)
|
|
128
|
+
|
|
129
|
+
# This is a transition point
|
|
130
|
+
is_transition = curr_is_air_or_tree & below_is_solid
|
|
131
|
+
|
|
132
|
+
# Only process cells that haven't been assigned yet
|
|
133
|
+
unassigned = (ground_k == -1)
|
|
134
|
+
new_transitions = is_transition & unassigned
|
|
135
|
+
|
|
136
|
+
if not np.any(new_transitions):
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Check validity: below is not water (7,8,9) and not negative (building/underground)
|
|
140
|
+
below_is_water = (below_val == 7) | (below_val == 8) | (below_val == 9)
|
|
141
|
+
below_is_negative = (below_val < 0)
|
|
142
|
+
below_is_invalid = below_is_water | below_is_negative
|
|
143
|
+
|
|
144
|
+
# Valid ground: transition point where below is valid
|
|
145
|
+
valid_new = new_transitions & ~below_is_invalid
|
|
146
|
+
invalid_new = new_transitions & below_is_invalid
|
|
147
|
+
|
|
148
|
+
# Assign ground_k for valid transitions
|
|
149
|
+
ground_k[valid_new] = k
|
|
150
|
+
valid_ground[valid_new] = True
|
|
151
|
+
|
|
152
|
+
# Mark invalid transitions so we don't process them again
|
|
153
|
+
# (set ground_k to -2 temporarily to distinguish from unassigned)
|
|
154
|
+
ground_k[invalid_new] = -2
|
|
155
|
+
|
|
156
|
+
# Reset -2 markers back to -1 (no valid ground)
|
|
157
|
+
ground_k[ground_k == -2] = -1
|
|
158
|
+
|
|
159
|
+
return valid_ground, ground_k
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _filter_df_to_period(df, start_time: str, end_time: str, tz: float):
|
|
163
|
+
"""
|
|
164
|
+
Filter weather DataFrame to specified time period and convert to UTC.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
df: pandas DataFrame with datetime index
|
|
168
|
+
start_time: Start time in format 'MM-DD HH:MM:SS'
|
|
169
|
+
end_time: End time in format 'MM-DD HH:MM:SS'
|
|
170
|
+
tz: Timezone offset in hours
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
Tuple of (df_period_utc, df with hour_of_year column)
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
ValueError: If time format is invalid or no data in period
|
|
177
|
+
"""
|
|
178
|
+
from datetime import datetime
|
|
179
|
+
import pytz
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
start_dt = datetime.strptime(start_time, '%m-%d %H:%M:%S')
|
|
183
|
+
end_dt = datetime.strptime(end_time, '%m-%d %H:%M:%S')
|
|
184
|
+
except ValueError as ve:
|
|
185
|
+
raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
|
|
186
|
+
|
|
187
|
+
# Add hour_of_year column
|
|
188
|
+
df = df.copy()
|
|
189
|
+
df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
|
|
190
|
+
|
|
191
|
+
# Calculate start/end hours
|
|
192
|
+
start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
|
|
193
|
+
end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
|
|
194
|
+
start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
|
|
195
|
+
end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
|
|
196
|
+
|
|
197
|
+
# Filter to period
|
|
198
|
+
if start_hour <= end_hour:
|
|
199
|
+
df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
|
|
200
|
+
else:
|
|
201
|
+
df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
|
|
202
|
+
|
|
203
|
+
if df_period.empty:
|
|
204
|
+
raise ValueError("No weather data in the specified period.")
|
|
205
|
+
|
|
206
|
+
# Localize and convert to UTC
|
|
207
|
+
offset_minutes = int(tz * 60)
|
|
208
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
209
|
+
df_period_local = df_period.copy()
|
|
210
|
+
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
211
|
+
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
212
|
+
|
|
213
|
+
return df_period_utc
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _load_epw_data(
|
|
217
|
+
epw_file_path: Optional[str] = None,
|
|
218
|
+
download_nearest_epw: bool = False,
|
|
219
|
+
voxcity = None,
|
|
220
|
+
**kwargs
|
|
221
|
+
) -> Tuple:
|
|
222
|
+
"""
|
|
223
|
+
Load EPW weather data, optionally downloading the nearest file.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
epw_file_path: Path to EPW file (required if download_nearest_epw=False)
|
|
227
|
+
download_nearest_epw: If True, download nearest EPW based on location
|
|
228
|
+
voxcity: VoxCity object (needed for location when downloading)
|
|
229
|
+
**kwargs: Additional parameters (output_dir, max_distance, rectangle_vertices)
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Tuple of (df, lon, lat, tz) where df is the weather DataFrame
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
ValueError: If EPW file not provided and download_nearest_epw=False
|
|
236
|
+
ImportError: If required modules not available
|
|
237
|
+
"""
|
|
238
|
+
rectangle_vertices = kwargs.get('rectangle_vertices', None)
|
|
239
|
+
if rectangle_vertices is None and voxcity is not None:
|
|
240
|
+
extras = getattr(voxcity, 'extras', None)
|
|
241
|
+
if isinstance(extras, dict):
|
|
242
|
+
rectangle_vertices = extras.get('rectangle_vertices', None)
|
|
243
|
+
|
|
244
|
+
if download_nearest_epw:
|
|
245
|
+
if rectangle_vertices is None:
|
|
246
|
+
raise ValueError("rectangle_vertices required to download nearest EPW file")
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
from voxcity.utils.weather import get_nearest_epw_from_climate_onebuilding
|
|
250
|
+
lons = [coord[0] for coord in rectangle_vertices]
|
|
251
|
+
lats = [coord[1] for coord in rectangle_vertices]
|
|
252
|
+
center_lon = (min(lons) + max(lons)) / 2
|
|
253
|
+
center_lat = (min(lats) + max(lats)) / 2
|
|
254
|
+
output_dir = kwargs.get('output_dir', 'output')
|
|
255
|
+
max_distance = kwargs.get('max_distance', 100)
|
|
256
|
+
|
|
257
|
+
epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
|
|
258
|
+
longitude=center_lon,
|
|
259
|
+
latitude=center_lat,
|
|
260
|
+
output_dir=output_dir,
|
|
261
|
+
max_distance=max_distance,
|
|
262
|
+
extract_zip=True,
|
|
263
|
+
load_data=True
|
|
264
|
+
)
|
|
265
|
+
except ImportError:
|
|
266
|
+
raise ImportError("VoxCity weather utilities required for EPW download")
|
|
267
|
+
|
|
268
|
+
if not epw_file_path:
|
|
269
|
+
raise ValueError("epw_file_path must be provided when download_nearest_epw is False")
|
|
270
|
+
|
|
271
|
+
# Read EPW file
|
|
272
|
+
try:
|
|
273
|
+
from voxcity.utils.weather import read_epw_for_solar_simulation
|
|
274
|
+
df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
|
|
275
|
+
except ImportError:
|
|
276
|
+
# Fallback to our EPW reader
|
|
277
|
+
from .epw import read_epw_header, read_epw_solar_data
|
|
278
|
+
location = read_epw_header(epw_file_path)
|
|
279
|
+
df = read_epw_solar_data(epw_file_path)
|
|
280
|
+
lon, lat, tz = location.longitude, location.latitude, location.timezone
|
|
281
|
+
|
|
282
|
+
if df.empty:
|
|
283
|
+
raise ValueError("No data in EPW file.")
|
|
284
|
+
|
|
285
|
+
return df, lon, lat, tz
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _compute_sun_direction(azimuth_degrees_ori: float, elevation_degrees: float) -> Tuple[float, float, float, float]:
|
|
289
|
+
"""
|
|
290
|
+
Compute sun direction vector from azimuth and elevation angles.
|
|
291
|
+
|
|
292
|
+
Args:
|
|
293
|
+
azimuth_degrees_ori: Solar azimuth in VoxCity convention (0=North, clockwise)
|
|
294
|
+
elevation_degrees: Solar elevation in degrees above horizon
|
|
295
|
+
|
|
296
|
+
Returns:
|
|
297
|
+
Tuple of (sun_dir_x, sun_dir_y, sun_dir_z, cos_zenith)
|
|
298
|
+
"""
|
|
299
|
+
# Convert from VoxCity convention to model coordinates
|
|
300
|
+
azimuth_degrees = 180 - azimuth_degrees_ori
|
|
301
|
+
azimuth_radians = np.deg2rad(azimuth_degrees)
|
|
302
|
+
elevation_radians = np.deg2rad(elevation_degrees)
|
|
303
|
+
|
|
304
|
+
cos_elev = np.cos(elevation_radians)
|
|
305
|
+
sin_elev = np.sin(elevation_radians)
|
|
306
|
+
|
|
307
|
+
sun_dir_x = cos_elev * np.cos(azimuth_radians)
|
|
308
|
+
sun_dir_y = cos_elev * np.sin(azimuth_radians)
|
|
309
|
+
sun_dir_z = sin_elev
|
|
310
|
+
cos_zenith = sin_elev # cos(zenith) = sin(elevation)
|
|
311
|
+
|
|
312
|
+
return sun_dir_x, sun_dir_y, sun_dir_z, cos_zenith
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# =============================================================================
|
|
316
|
+
# Data Classes
|
|
317
|
+
# =============================================================================
|
|
318
|
+
|
|
319
|
+
@dataclass
|
|
320
|
+
class LandCoverAlbedo:
|
|
321
|
+
"""
|
|
322
|
+
Mapping of land cover classes to albedo values.
|
|
323
|
+
|
|
324
|
+
Default values are based on literature values for typical urban materials.
|
|
325
|
+
References:
|
|
326
|
+
- Oke, T.R. (1987) Boundary Layer Climates
|
|
327
|
+
- Sailor, D.J. (1995) Simulated urban climate response to modifications
|
|
328
|
+
"""
|
|
329
|
+
# OpenStreetMap / Standard land cover classes (0-indexed after +1 in voxelizer)
|
|
330
|
+
# These map to land_cover_grid values in VoxCity
|
|
331
|
+
bareland: float = 0.20 # Class 0: Bare soil/dirt
|
|
332
|
+
rangeland: float = 0.25 # Class 1: Grassland/rangeland
|
|
333
|
+
shrub: float = 0.20 # Class 2: Shrubland
|
|
334
|
+
agriculture: float = 0.20 # Class 3: Agricultural land
|
|
335
|
+
tree: float = 0.15 # Class 4: Tree cover (ground under canopy)
|
|
336
|
+
wetland: float = 0.12 # Class 5: Wetland
|
|
337
|
+
mangrove: float = 0.12 # Class 6: Mangrove
|
|
338
|
+
water: float = 0.06 # Class 7: Water bodies
|
|
339
|
+
snow_ice: float = 0.80 # Class 8: Snow and ice
|
|
340
|
+
developed: float = 0.20 # Class 9: Developed/paved areas
|
|
341
|
+
road: float = 0.12 # Class 10: Roads (asphalt)
|
|
342
|
+
building_ground: float = 0.20 # Class 11: Building footprint area
|
|
343
|
+
|
|
344
|
+
# Building surfaces (walls and roofs)
|
|
345
|
+
building_wall: float = 0.30 # Vertical building surfaces
|
|
346
|
+
building_roof: float = 0.25 # Building rooftops
|
|
347
|
+
|
|
348
|
+
# Vegetation
|
|
349
|
+
leaf: float = 0.15 # Plant canopy (PALM default)
|
|
350
|
+
|
|
351
|
+
def get_land_cover_albedo(self, class_code: int) -> float:
|
|
352
|
+
"""
|
|
353
|
+
Get albedo value for a land cover class code.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
class_code: Land cover class code (0-11 for standard classes)
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Albedo value for the class
|
|
360
|
+
"""
|
|
361
|
+
albedo_map = {
|
|
362
|
+
0: self.bareland,
|
|
363
|
+
1: self.rangeland,
|
|
364
|
+
2: self.shrub,
|
|
365
|
+
3: self.agriculture,
|
|
366
|
+
4: self.tree,
|
|
367
|
+
5: self.wetland,
|
|
368
|
+
6: self.mangrove,
|
|
369
|
+
7: self.water,
|
|
370
|
+
8: self.snow_ice,
|
|
371
|
+
9: self.developed,
|
|
372
|
+
10: self.road,
|
|
373
|
+
11: self.building_ground,
|
|
374
|
+
}
|
|
375
|
+
return albedo_map.get(class_code, self.developed) # Default to developed
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass
|
|
379
|
+
class VoxCityDomainResult:
|
|
380
|
+
"""Result of VoxCity to palm_solar conversion."""
|
|
381
|
+
domain: Domain
|
|
382
|
+
surface_land_cover: Optional[np.ndarray] = None # Land cover code per surface
|
|
383
|
+
surface_material_type: Optional[np.ndarray] = None # 0=ground, 1=wall, 2=roof
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
# =============================================================================
|
|
387
|
+
# RadiationModel Caching for Cumulative Calculations
|
|
388
|
+
# =============================================================================
|
|
389
|
+
# The SVF and CSF matrices are geometry-dependent and expensive to compute.
|
|
390
|
+
# We cache the RadiationModel so it can be reused across multiple solar positions.
|
|
391
|
+
|
|
392
|
+
@dataclass
|
|
393
|
+
class _CachedRadiationModel:
|
|
394
|
+
"""Cached RadiationModel with associated metadata."""
|
|
395
|
+
model: object # RadiationModel instance
|
|
396
|
+
valid_ground: np.ndarray # Valid ground mask
|
|
397
|
+
ground_k: np.ndarray # Ground level k indices
|
|
398
|
+
voxcity_shape: Tuple[int, int, int] # Shape of voxel data for cache validation
|
|
399
|
+
meshsize: float # Meshsize for cache validation
|
|
400
|
+
n_reflection_steps: int # Number of reflection steps used
|
|
401
|
+
# Performance optimization: pre-computed surface-to-grid mapping
|
|
402
|
+
grid_indices: Optional[np.ndarray] = None # (N, 2) array of (i, j) grid coords for valid ground surfaces
|
|
403
|
+
surface_indices: Optional[np.ndarray] = None # (N,) array of surface indices matching grid_indices
|
|
404
|
+
# Cached numpy arrays (positions/directions don't change)
|
|
405
|
+
positions_np: Optional[np.ndarray] = None # Cached positions array
|
|
406
|
+
directions_np: Optional[np.ndarray] = None # Cached directions array
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# Module-level cache for RadiationModel
|
|
410
|
+
_radiation_model_cache: Optional[_CachedRadiationModel] = None
|
|
411
|
+
|
|
412
|
+
# Module-level cache for GPU ray tracer (forward declaration, actual class defined later)
|
|
413
|
+
_gpu_ray_tracer_cache = None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _get_or_create_radiation_model(
|
|
417
|
+
voxcity,
|
|
418
|
+
n_reflection_steps: int = 2,
|
|
419
|
+
progress_report: bool = False,
|
|
420
|
+
**kwargs
|
|
421
|
+
) -> Tuple[object, np.ndarray, np.ndarray]:
|
|
422
|
+
"""
|
|
423
|
+
Get cached RadiationModel or create a new one if cache is invalid.
|
|
424
|
+
|
|
425
|
+
The SVF and CSF matrices are O(n²) to compute and only depend on geometry,
|
|
426
|
+
not solar position. This function caches the model for reuse.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
voxcity: VoxCity object
|
|
430
|
+
n_reflection_steps: Number of reflection bounces
|
|
431
|
+
progress_report: Print progress messages
|
|
432
|
+
**kwargs: Additional RadiationConfig parameters
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Tuple of (RadiationModel, valid_ground array, ground_k array)
|
|
436
|
+
"""
|
|
437
|
+
global _radiation_model_cache
|
|
438
|
+
|
|
439
|
+
from .radiation import RadiationModel, RadiationConfig
|
|
440
|
+
from .domain import IUP
|
|
441
|
+
|
|
442
|
+
voxel_data = voxcity.voxels.classes
|
|
443
|
+
meshsize = voxcity.voxels.meta.meshsize
|
|
444
|
+
ni, nj, nk = voxel_data.shape
|
|
445
|
+
|
|
446
|
+
# Check if cache is valid
|
|
447
|
+
cache_valid = False
|
|
448
|
+
if _radiation_model_cache is not None:
|
|
449
|
+
cache = _radiation_model_cache
|
|
450
|
+
if (cache.voxcity_shape == voxel_data.shape and
|
|
451
|
+
cache.meshsize == meshsize and
|
|
452
|
+
cache.n_reflection_steps == n_reflection_steps):
|
|
453
|
+
cache_valid = True
|
|
454
|
+
if progress_report:
|
|
455
|
+
print("Using cached RadiationModel (SVF/CSF already computed)")
|
|
456
|
+
|
|
457
|
+
if cache_valid:
|
|
458
|
+
return (_radiation_model_cache.model,
|
|
459
|
+
_radiation_model_cache.valid_ground,
|
|
460
|
+
_radiation_model_cache.ground_k)
|
|
461
|
+
|
|
462
|
+
# Need to create new model
|
|
463
|
+
if progress_report:
|
|
464
|
+
print("Creating new RadiationModel (computing SVF/CSF matrices)...")
|
|
465
|
+
|
|
466
|
+
# Get location using helper function
|
|
467
|
+
origin_lat, origin_lon = _get_location_from_voxcity(voxcity)
|
|
468
|
+
|
|
469
|
+
# Create domain
|
|
470
|
+
domain = Domain(
|
|
471
|
+
nx=ni, ny=nj, nz=nk,
|
|
472
|
+
dx=meshsize, dy=meshsize, dz=meshsize,
|
|
473
|
+
origin_lat=origin_lat,
|
|
474
|
+
origin_lon=origin_lon
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Convert VoxCity voxel data to domain arrays using vectorized helper
|
|
478
|
+
default_lad = kwargs.get('default_lad', 1.0)
|
|
479
|
+
is_solid_np, lad_np = _convert_voxel_data_to_arrays(voxel_data, default_lad)
|
|
480
|
+
|
|
481
|
+
# Compute valid ground cells using vectorized helper
|
|
482
|
+
valid_ground, _ = _compute_valid_ground_vectorized(voxel_data)
|
|
483
|
+
|
|
484
|
+
# Set domain arrays
|
|
485
|
+
_set_solid_array(domain, is_solid_np)
|
|
486
|
+
domain.set_lad_from_array(lad_np)
|
|
487
|
+
_update_topo_from_solid(domain)
|
|
488
|
+
|
|
489
|
+
# Create RadiationModel
|
|
490
|
+
config = RadiationConfig(
|
|
491
|
+
n_reflection_steps=n_reflection_steps,
|
|
492
|
+
n_azimuth=kwargs.get('n_azimuth', 40),
|
|
493
|
+
n_elevation=kwargs.get('n_elevation', 10)
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
model = RadiationModel(domain, config)
|
|
497
|
+
|
|
498
|
+
# Compute SVF (this is the expensive part)
|
|
499
|
+
if progress_report:
|
|
500
|
+
print("Computing Sky View Factors...")
|
|
501
|
+
model.compute_svf()
|
|
502
|
+
|
|
503
|
+
# Pre-compute ground_k for surface mapping
|
|
504
|
+
n_surfaces = model.surfaces.count
|
|
505
|
+
positions = model.surfaces.position.to_numpy()[:n_surfaces]
|
|
506
|
+
directions = model.surfaces.direction.to_numpy()[:n_surfaces]
|
|
507
|
+
|
|
508
|
+
ground_k = np.full((ni, nj), -1, dtype=np.int32)
|
|
509
|
+
for idx in range(n_surfaces):
|
|
510
|
+
pos_i, pos_j, k = positions[idx]
|
|
511
|
+
direction = directions[idx]
|
|
512
|
+
if direction == IUP:
|
|
513
|
+
ii, jj = int(pos_i), int(pos_j)
|
|
514
|
+
if 0 <= ii < ni and 0 <= jj < nj:
|
|
515
|
+
if not valid_ground[ii, jj]:
|
|
516
|
+
continue
|
|
517
|
+
if ground_k[ii, jj] < 0 or k < ground_k[ii, jj]:
|
|
518
|
+
ground_k[ii, jj] = int(k)
|
|
519
|
+
|
|
520
|
+
# Pre-compute surface-to-grid mapping for fast vectorized extraction
|
|
521
|
+
# This maps which surface indices correspond to which grid cells
|
|
522
|
+
if progress_report:
|
|
523
|
+
print("Pre-computing surface-to-grid mapping...")
|
|
524
|
+
surface_to_grid_map = {} # (i, j) -> surface_idx
|
|
525
|
+
for idx in range(n_surfaces):
|
|
526
|
+
direction = directions[idx]
|
|
527
|
+
if direction == IUP:
|
|
528
|
+
pi = int(positions[idx, 0])
|
|
529
|
+
pj = int(positions[idx, 1])
|
|
530
|
+
pk = int(positions[idx, 2])
|
|
531
|
+
if 0 <= pi < ni and 0 <= pj < nj:
|
|
532
|
+
if valid_ground[pi, pj] and pk == ground_k[pi, pj]:
|
|
533
|
+
surface_to_grid_map[(pi, pj)] = idx
|
|
534
|
+
|
|
535
|
+
# Convert to arrays for vectorized access
|
|
536
|
+
if surface_to_grid_map:
|
|
537
|
+
grid_indices = np.array(list(surface_to_grid_map.keys()), dtype=np.int32)
|
|
538
|
+
surface_indices = np.array(list(surface_to_grid_map.values()), dtype=np.int32)
|
|
539
|
+
else:
|
|
540
|
+
grid_indices = np.empty((0, 2), dtype=np.int32)
|
|
541
|
+
surface_indices = np.empty((0,), dtype=np.int32)
|
|
542
|
+
|
|
543
|
+
# Cache the model with pre-computed mappings
|
|
544
|
+
_radiation_model_cache = _CachedRadiationModel(
|
|
545
|
+
model=model,
|
|
546
|
+
valid_ground=valid_ground,
|
|
547
|
+
ground_k=ground_k,
|
|
548
|
+
voxcity_shape=voxel_data.shape,
|
|
549
|
+
meshsize=meshsize,
|
|
550
|
+
n_reflection_steps=n_reflection_steps,
|
|
551
|
+
grid_indices=grid_indices,
|
|
552
|
+
surface_indices=surface_indices,
|
|
553
|
+
positions_np=positions,
|
|
554
|
+
directions_np=directions
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if progress_report:
|
|
558
|
+
print(f"RadiationModel cached. Valid ground cells: {np.sum(valid_ground)}, mapped surfaces: {len(surface_indices)}")
|
|
559
|
+
|
|
560
|
+
return model, valid_ground, ground_k
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def clear_radiation_model_cache():
|
|
564
|
+
"""Clear the cached RadiationModel to free memory or force recomputation."""
|
|
565
|
+
global _radiation_model_cache
|
|
566
|
+
_radiation_model_cache = None
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _compute_ground_k_from_voxels(voxel_data: np.ndarray) -> np.ndarray:
|
|
570
|
+
"""
|
|
571
|
+
Compute ground surface k-level for each (i,j) cell from voxel data.
|
|
572
|
+
|
|
573
|
+
This finds the terrain top - the highest k where the cell below the first air
|
|
574
|
+
cell is solid ground (not building). This is used for terrain-following
|
|
575
|
+
height extraction in volumetric calculations.
|
|
576
|
+
|
|
577
|
+
Water areas (voxel classes 7, 8, 9) and building/underground cells (negative codes)
|
|
578
|
+
are excluded and marked as -1. This uses the same logic as the with_reflections=True
|
|
579
|
+
path in _get_or_create_radiation_model for consistency.
|
|
580
|
+
|
|
581
|
+
Args:
|
|
582
|
+
voxel_data: 3D array of voxel class codes
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
2D array of ground k-levels (ni, nj). -1 means no valid ground found.
|
|
586
|
+
"""
|
|
587
|
+
# Use the vectorized helper for consistency
|
|
588
|
+
_, ground_k = _compute_valid_ground_vectorized(voxel_data)
|
|
589
|
+
return ground_k
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _extract_terrain_following_slice(
|
|
593
|
+
flux_3d: np.ndarray,
|
|
594
|
+
ground_k: np.ndarray,
|
|
595
|
+
height_offset_k: int,
|
|
596
|
+
is_solid: np.ndarray
|
|
597
|
+
) -> np.ndarray:
|
|
598
|
+
"""
|
|
599
|
+
Extract a terrain-following 2D slice from a 3D flux field (vectorized).
|
|
600
|
+
|
|
601
|
+
For each (i,j), extracts the value at ground_k[i,j] + height_offset_k.
|
|
602
|
+
Cells that are solid at the extraction point, have no valid ground,
|
|
603
|
+
or are above the domain are marked as NaN.
|
|
604
|
+
|
|
605
|
+
Args:
|
|
606
|
+
flux_3d: 3D array of flux values (ni, nj, nk)
|
|
607
|
+
ground_k: 2D array of ground k-levels (ni, nj), -1 means no valid ground
|
|
608
|
+
height_offset_k: Number of cells above ground to extract
|
|
609
|
+
is_solid: 3D array marking solid cells (ni, nj, nk)
|
|
610
|
+
|
|
611
|
+
Returns:
|
|
612
|
+
2D array of extracted values (ni, nj) with NaN for invalid cells
|
|
613
|
+
"""
|
|
614
|
+
ni, nj, nk = flux_3d.shape
|
|
615
|
+
result = np.full((ni, nj), np.nan, dtype=np.float64)
|
|
616
|
+
|
|
617
|
+
# Calculate extraction k-levels
|
|
618
|
+
k_extract = ground_k + height_offset_k
|
|
619
|
+
|
|
620
|
+
# Create valid mask: ground exists, within bounds, not solid
|
|
621
|
+
valid_ground = ground_k >= 0
|
|
622
|
+
within_bounds = k_extract < nk
|
|
623
|
+
valid_mask = valid_ground & within_bounds
|
|
624
|
+
|
|
625
|
+
# Get indices for valid cells
|
|
626
|
+
ii, jj = np.where(valid_mask)
|
|
627
|
+
kk = k_extract[valid_mask]
|
|
628
|
+
|
|
629
|
+
# Check solid cells at extraction points
|
|
630
|
+
not_solid = is_solid[ii, jj, kk] != 1
|
|
631
|
+
|
|
632
|
+
# Extract values for non-solid cells
|
|
633
|
+
ii_valid = ii[not_solid]
|
|
634
|
+
jj_valid = jj[not_solid]
|
|
635
|
+
kk_valid = kk[not_solid]
|
|
636
|
+
|
|
637
|
+
result[ii_valid, jj_valid] = flux_3d[ii_valid, jj_valid, kk_valid]
|
|
638
|
+
|
|
639
|
+
return result
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _accumulate_terrain_following_slice(
|
|
643
|
+
cumulative_map: np.ndarray,
|
|
644
|
+
flux_3d: np.ndarray,
|
|
645
|
+
ground_k: np.ndarray,
|
|
646
|
+
height_offset_k: int,
|
|
647
|
+
is_solid: np.ndarray,
|
|
648
|
+
weight: float = 1.0
|
|
649
|
+
) -> None:
|
|
650
|
+
"""
|
|
651
|
+
Accumulate terrain-following values from a 3D flux field into a 2D map (vectorized, in-place).
|
|
652
|
+
|
|
653
|
+
For each (i,j), adds flux_3d[i,j,k_extract] * weight to cumulative_map[i,j]
|
|
654
|
+
where k_extract = ground_k[i,j] + height_offset_k.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
cumulative_map: 2D array to accumulate into (ni, nj), modified in-place
|
|
658
|
+
flux_3d: 3D array of flux values (ni, nj, nk)
|
|
659
|
+
ground_k: 2D array of ground k-levels (ni, nj), -1 means no valid ground
|
|
660
|
+
height_offset_k: Number of cells above ground to extract
|
|
661
|
+
is_solid: 3D array marking solid cells (ni, nj, nk)
|
|
662
|
+
weight: Multiplier for values before accumulating (e.g., time_step_hours)
|
|
663
|
+
"""
|
|
664
|
+
ni, nj, nk = flux_3d.shape
|
|
665
|
+
|
|
666
|
+
# Calculate extraction k-levels
|
|
667
|
+
k_extract = ground_k + height_offset_k
|
|
668
|
+
|
|
669
|
+
# Create valid mask: ground exists, within bounds
|
|
670
|
+
valid_ground = ground_k >= 0
|
|
671
|
+
within_bounds = k_extract < nk
|
|
672
|
+
valid_mask = valid_ground & within_bounds
|
|
673
|
+
|
|
674
|
+
# Get indices for valid cells
|
|
675
|
+
ii, jj = np.where(valid_mask)
|
|
676
|
+
kk = k_extract[valid_mask]
|
|
677
|
+
|
|
678
|
+
# Check solid cells at extraction points
|
|
679
|
+
not_solid = is_solid[ii, jj, kk] != 1
|
|
680
|
+
|
|
681
|
+
# Accumulate for non-solid cells
|
|
682
|
+
ii_valid = ii[not_solid]
|
|
683
|
+
jj_valid = jj[not_solid]
|
|
684
|
+
kk_valid = kk[not_solid]
|
|
685
|
+
|
|
686
|
+
# Use np.add.at for proper in-place accumulation (handles duplicate indices)
|
|
687
|
+
np.add.at(cumulative_map, (ii_valid, jj_valid), flux_3d[ii_valid, jj_valid, kk_valid] * weight)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def clear_gpu_ray_tracer_cache():
|
|
691
|
+
"""Clear the cached GPU ray tracer fields to free memory or force recomputation."""
|
|
692
|
+
global _gpu_ray_tracer_cache
|
|
693
|
+
_gpu_ray_tracer_cache = None
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def clear_all_caches():
|
|
697
|
+
"""Clear all GPU caches (RadiationModel, Building RadiationModel, GPU ray tracer)."""
|
|
698
|
+
global _radiation_model_cache, _building_radiation_model_cache, _gpu_ray_tracer_cache
|
|
699
|
+
_radiation_model_cache = None
|
|
700
|
+
_building_radiation_model_cache = None
|
|
701
|
+
_gpu_ray_tracer_cache = None
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
# =============================================================================
|
|
705
|
+
# Building RadiationModel Caching
|
|
706
|
+
# =============================================================================
|
|
707
|
+
# Separate cache for building solar irradiance calculations
|
|
708
|
+
|
|
709
|
+
@dataclass
|
|
710
|
+
class _CachedBuildingRadiationModel:
|
|
711
|
+
"""Cached RadiationModel for building surface calculations."""
|
|
712
|
+
model: object # RadiationModel instance
|
|
713
|
+
voxcity_shape: Tuple[int, int, int] # Shape of voxel data for cache validation
|
|
714
|
+
meshsize: float # Meshsize for cache validation
|
|
715
|
+
n_reflection_steps: int # Number of reflection steps used
|
|
716
|
+
is_building_surf: np.ndarray # Boolean mask for building surfaces
|
|
717
|
+
building_svf_mesh: object # Building mesh (can be None)
|
|
718
|
+
# Performance optimization: pre-computed mesh face to surface mapping
|
|
719
|
+
bldg_indices: Optional[np.ndarray] = None # Indices of building surfaces
|
|
720
|
+
mesh_to_surface_idx: Optional[np.ndarray] = None # Direct mapping: mesh face -> surface index
|
|
721
|
+
# Cached mesh geometry to avoid recomputing each call
|
|
722
|
+
mesh_face_centers: Optional[np.ndarray] = None # Pre-computed triangles_center
|
|
723
|
+
mesh_face_normals: Optional[np.ndarray] = None # Pre-computed face_normals
|
|
724
|
+
boundary_mask: Optional[np.ndarray] = None # Pre-computed boundary vertical face mask
|
|
725
|
+
# Cached building mesh (expensive to create, ~2.4s)
|
|
726
|
+
cached_building_mesh: object = None # Pre-computed building mesh from create_voxel_mesh
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
# Module-level cache for Building RadiationModel
|
|
730
|
+
_building_radiation_model_cache: Optional[_CachedBuildingRadiationModel] = None
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _get_or_create_building_radiation_model(
|
|
734
|
+
voxcity,
|
|
735
|
+
n_reflection_steps: int = 2,
|
|
736
|
+
progress_report: bool = False,
|
|
737
|
+
building_class_id: int = -3,
|
|
738
|
+
**kwargs
|
|
739
|
+
) -> Tuple[object, np.ndarray]:
|
|
740
|
+
"""
|
|
741
|
+
Get cached RadiationModel for building surfaces or create a new one.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
voxcity: VoxCity object
|
|
745
|
+
n_reflection_steps: Number of reflection bounces
|
|
746
|
+
progress_report: Print progress messages
|
|
747
|
+
building_class_id: Building voxel class code
|
|
748
|
+
**kwargs: Additional RadiationConfig parameters
|
|
749
|
+
|
|
750
|
+
Returns:
|
|
751
|
+
Tuple of (RadiationModel, is_building_surf boolean array)
|
|
752
|
+
"""
|
|
753
|
+
global _building_radiation_model_cache
|
|
754
|
+
|
|
755
|
+
from .radiation import RadiationModel, RadiationConfig
|
|
756
|
+
|
|
757
|
+
voxel_data = voxcity.voxels.classes
|
|
758
|
+
meshsize = voxcity.voxels.meta.meshsize
|
|
759
|
+
ny_vc, nx_vc, nz = voxel_data.shape
|
|
760
|
+
|
|
761
|
+
# Check if cache is valid
|
|
762
|
+
# A cached model with reflections (n_reflection_steps > 0) can be reused for non-reflection calls
|
|
763
|
+
# But a cached model without reflections cannot be used for reflection calls
|
|
764
|
+
cache_valid = False
|
|
765
|
+
if _building_radiation_model_cache is not None:
|
|
766
|
+
cache = _building_radiation_model_cache
|
|
767
|
+
if (cache.voxcity_shape == voxel_data.shape and
|
|
768
|
+
cache.meshsize == meshsize):
|
|
769
|
+
# Cache is valid if:
|
|
770
|
+
# 1. We don't need reflections (n_reflection_steps=0), OR
|
|
771
|
+
# 2. Cached model has reflections enabled (can handle any n_reflection_steps)
|
|
772
|
+
if n_reflection_steps == 0 or cache.n_reflection_steps > 0:
|
|
773
|
+
cache_valid = True
|
|
774
|
+
if progress_report:
|
|
775
|
+
print("Using cached Building RadiationModel (SVF/CSF already computed)")
|
|
776
|
+
|
|
777
|
+
if cache_valid:
|
|
778
|
+
return (_building_radiation_model_cache.model,
|
|
779
|
+
_building_radiation_model_cache.is_building_surf)
|
|
780
|
+
|
|
781
|
+
# Need to create new model
|
|
782
|
+
if progress_report:
|
|
783
|
+
print("Creating new Building RadiationModel (computing SVF/CSF matrices)...")
|
|
784
|
+
|
|
785
|
+
# Get location using helper function
|
|
786
|
+
origin_lat, origin_lon = _get_location_from_voxcity(voxcity)
|
|
787
|
+
|
|
788
|
+
# Create domain - consistent with ground-level model
|
|
789
|
+
# VoxCity uses [row, col, z] = [i, j, k] convention
|
|
790
|
+
# We create domain with nx=ny_vc, ny=nx_vc to match the palm_solar convention
|
|
791
|
+
# but keep the same indexing as the ground model for consistency
|
|
792
|
+
ni, nj, nk = ny_vc, nx_vc, nz # Rename for clarity (matches ground model naming)
|
|
793
|
+
|
|
794
|
+
domain = Domain(
|
|
795
|
+
nx=ni, ny=nj, nz=nk,
|
|
796
|
+
dx=meshsize, dy=meshsize, dz=meshsize,
|
|
797
|
+
origin_lat=origin_lat,
|
|
798
|
+
origin_lon=origin_lon
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
# Convert VoxCity voxel data to domain arrays using vectorized helper
|
|
802
|
+
default_lad = kwargs.get('default_lad', 2.0)
|
|
803
|
+
is_solid_np, lad_np = _convert_voxel_data_to_arrays(voxel_data, default_lad)
|
|
804
|
+
|
|
805
|
+
# Set domain arrays
|
|
806
|
+
_set_solid_array(domain, is_solid_np)
|
|
807
|
+
domain.set_lad_from_array(lad_np)
|
|
808
|
+
_update_topo_from_solid(domain)
|
|
809
|
+
|
|
810
|
+
# When n_reflection_steps=0, disable surface reflections to skip expensive SVF matrix computation
|
|
811
|
+
surface_reflections = n_reflection_steps > 0
|
|
812
|
+
|
|
813
|
+
config = RadiationConfig(
|
|
814
|
+
n_reflection_steps=n_reflection_steps,
|
|
815
|
+
n_azimuth=40,
|
|
816
|
+
n_elevation=10,
|
|
817
|
+
surface_reflections=surface_reflections, # Disable when no reflections needed
|
|
818
|
+
cache_svf_matrix=surface_reflections, # Skip SVF matrix when reflections disabled
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
model = RadiationModel(domain, config)
|
|
822
|
+
|
|
823
|
+
# Compute SVF (expensive! but only for sky view, not surface-to-surface when disabled)
|
|
824
|
+
if progress_report:
|
|
825
|
+
print("Computing Sky View Factors...")
|
|
826
|
+
model.compute_svf()
|
|
827
|
+
|
|
828
|
+
# Pre-compute building surface mask
|
|
829
|
+
n_surfaces = model.surfaces.count
|
|
830
|
+
surf_positions_all = model.surfaces.position.to_numpy()[:n_surfaces]
|
|
831
|
+
|
|
832
|
+
is_building_surf = np.zeros(n_surfaces, dtype=bool)
|
|
833
|
+
for s_idx in range(n_surfaces):
|
|
834
|
+
i_idx, j_idx, z_idx = surf_positions_all[s_idx]
|
|
835
|
+
i, j, z = int(i_idx), int(j_idx), int(z_idx)
|
|
836
|
+
if 0 <= i < ni and 0 <= j < nj and 0 <= z < nk:
|
|
837
|
+
if voxel_data[i, j, z] == building_class_id:
|
|
838
|
+
is_building_surf[s_idx] = True
|
|
839
|
+
|
|
840
|
+
if progress_report:
|
|
841
|
+
print(f"Building RadiationModel cached. Building surfaces: {np.sum(is_building_surf)}/{n_surfaces}")
|
|
842
|
+
|
|
843
|
+
# Pre-compute bldg_indices for caching
|
|
844
|
+
bldg_indices = np.where(is_building_surf)[0]
|
|
845
|
+
|
|
846
|
+
# Cache the model
|
|
847
|
+
_building_radiation_model_cache = _CachedBuildingRadiationModel(
|
|
848
|
+
model=model,
|
|
849
|
+
voxcity_shape=voxel_data.shape,
|
|
850
|
+
meshsize=meshsize,
|
|
851
|
+
n_reflection_steps=n_reflection_steps,
|
|
852
|
+
is_building_surf=is_building_surf,
|
|
853
|
+
building_svf_mesh=None,
|
|
854
|
+
bldg_indices=bldg_indices,
|
|
855
|
+
mesh_to_surface_idx=None # Will be computed on first use with a specific mesh
|
|
856
|
+
)
|
|
857
|
+
|
|
858
|
+
return model, is_building_surf
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def clear_building_radiation_model_cache():
|
|
862
|
+
"""Clear the cached Building RadiationModel to free memory."""
|
|
863
|
+
global _building_radiation_model_cache
|
|
864
|
+
_building_radiation_model_cache = None
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def clear_all_radiation_caches():
|
|
868
|
+
"""Clear all cached RadiationModels to free GPU memory."""
|
|
869
|
+
clear_radiation_model_cache()
|
|
870
|
+
clear_building_radiation_model_cache()
|
|
871
|
+
land_cover_albedo: Optional[LandCoverAlbedo] = None
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def load_voxcity(filepath: Union[str, Path]):
|
|
875
|
+
"""
|
|
876
|
+
Load VoxCity data from pickle file.
|
|
877
|
+
|
|
878
|
+
Attempts to use the voxcity package if available, otherwise
|
|
879
|
+
loads as raw pickle with fallback handling.
|
|
880
|
+
|
|
881
|
+
Args:
|
|
882
|
+
filepath: Path to the VoxCity pickle file
|
|
883
|
+
|
|
884
|
+
Returns:
|
|
885
|
+
VoxCity object or dict containing the model data
|
|
886
|
+
"""
|
|
887
|
+
import pickle
|
|
888
|
+
|
|
889
|
+
filepath = Path(filepath)
|
|
890
|
+
|
|
891
|
+
try:
|
|
892
|
+
# Try using voxcity package loader
|
|
893
|
+
from voxcity.generator.io import load_voxcity as voxcity_load
|
|
894
|
+
return voxcity_load(str(filepath))
|
|
895
|
+
except ImportError:
|
|
896
|
+
# Fallback: load as raw pickle
|
|
897
|
+
with open(filepath, 'rb') as f:
|
|
898
|
+
data = pickle.load(f)
|
|
899
|
+
|
|
900
|
+
# Handle wrapper dict format (has 'voxcity' key)
|
|
901
|
+
if isinstance(data, dict) and 'voxcity' in data:
|
|
902
|
+
return data['voxcity']
|
|
903
|
+
|
|
904
|
+
return data
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def convert_voxcity_to_domain(
|
|
908
|
+
voxcity_data,
|
|
909
|
+
default_lad: float = 2.0,
|
|
910
|
+
land_cover_albedo: Optional[LandCoverAlbedo] = None,
|
|
911
|
+
origin_lat: Optional[float] = None,
|
|
912
|
+
origin_lon: Optional[float] = None
|
|
913
|
+
) -> VoxCityDomainResult:
|
|
914
|
+
"""
|
|
915
|
+
Convert VoxCity voxel grid to palm_solar Domain with material properties.
|
|
916
|
+
|
|
917
|
+
This function:
|
|
918
|
+
1. Extracts voxel grid, dimensions, and location from VoxCity data
|
|
919
|
+
2. Creates a palm_solar Domain with solid cells and LAD
|
|
920
|
+
3. Tracks land cover information for surface albedo assignment
|
|
921
|
+
|
|
922
|
+
Args:
|
|
923
|
+
voxcity_data: VoxCity object or dict from load_voxcity()
|
|
924
|
+
default_lad: Default Leaf Area Density for tree voxels (m²/m³)
|
|
925
|
+
land_cover_albedo: Custom land cover to albedo mapping
|
|
926
|
+
origin_lat: Override latitude (degrees)
|
|
927
|
+
origin_lon: Override longitude (degrees)
|
|
928
|
+
|
|
929
|
+
Returns:
|
|
930
|
+
VoxCityDomainResult with Domain and material information
|
|
931
|
+
"""
|
|
932
|
+
if land_cover_albedo is None:
|
|
933
|
+
land_cover_albedo = LandCoverAlbedo()
|
|
934
|
+
|
|
935
|
+
# Extract data from VoxCity object or dict
|
|
936
|
+
if hasattr(voxcity_data, 'voxels'):
|
|
937
|
+
# New VoxCity dataclass format
|
|
938
|
+
voxel_grid = voxcity_data.voxels.classes
|
|
939
|
+
meshsize = voxcity_data.voxels.meta.meshsize
|
|
940
|
+
land_cover_grid = voxcity_data.land_cover.classes
|
|
941
|
+
dem_grid = voxcity_data.dem.elevation
|
|
942
|
+
extras = getattr(voxcity_data, 'extras', {})
|
|
943
|
+
rectangle_vertices = extras.get('rectangle_vertices', None)
|
|
944
|
+
else:
|
|
945
|
+
# Legacy dict format
|
|
946
|
+
voxel_grid = voxcity_data['voxcity_grid']
|
|
947
|
+
meshsize = voxcity_data['meshsize']
|
|
948
|
+
land_cover_grid = voxcity_data.get('land_cover_grid', None)
|
|
949
|
+
dem_grid = voxcity_data.get('dem_grid', None)
|
|
950
|
+
rectangle_vertices = voxcity_data.get('rectangle_vertices', None)
|
|
951
|
+
|
|
952
|
+
# Get grid dimensions (VoxCity is [row, col, z] = [y, x, z])
|
|
953
|
+
ny, nx, nz = voxel_grid.shape
|
|
954
|
+
|
|
955
|
+
# Use meshsize as voxel size
|
|
956
|
+
dx = dy = dz = float(meshsize)
|
|
957
|
+
|
|
958
|
+
# Determine location
|
|
959
|
+
if origin_lat is None or origin_lon is None:
|
|
960
|
+
if rectangle_vertices is not None and len(rectangle_vertices) > 0:
|
|
961
|
+
lons = [v[0] for v in rectangle_vertices]
|
|
962
|
+
lats = [v[1] for v in rectangle_vertices]
|
|
963
|
+
if origin_lon is None:
|
|
964
|
+
origin_lon = np.mean(lons)
|
|
965
|
+
if origin_lat is None:
|
|
966
|
+
origin_lat = np.mean(lats)
|
|
967
|
+
else:
|
|
968
|
+
# Default to Singapore
|
|
969
|
+
if origin_lat is None:
|
|
970
|
+
origin_lat = 1.35
|
|
971
|
+
if origin_lon is None:
|
|
972
|
+
origin_lon = 103.82
|
|
973
|
+
|
|
974
|
+
print(f"VoxCity grid shape: ({ny}, {nx}, {nz})")
|
|
975
|
+
print(f"Voxel size: {dx} m")
|
|
976
|
+
print(f"Domain size: {nx*dx:.1f} x {ny*dy:.1f} x {nz*dz:.1f} m")
|
|
977
|
+
print(f"Location: lat={origin_lat:.4f}, lon={origin_lon:.4f}")
|
|
978
|
+
|
|
979
|
+
# Create palm_solar Domain
|
|
980
|
+
domain = Domain(
|
|
981
|
+
nx=nx, ny=ny, nz=nz,
|
|
982
|
+
dx=dx, dy=dy, dz=dz,
|
|
983
|
+
origin=(0.0, 0.0, 0.0),
|
|
984
|
+
origin_lat=origin_lat,
|
|
985
|
+
origin_lon=origin_lon
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
# Create arrays for conversion
|
|
989
|
+
is_solid_np = np.zeros((nx, ny, nz), dtype=np.int32)
|
|
990
|
+
lad_np = np.zeros((nx, ny, nz), dtype=np.float32)
|
|
991
|
+
|
|
992
|
+
# Surface land cover tracking (indexed by grid position)
|
|
993
|
+
# This will store the land cover code for ground-level surfaces
|
|
994
|
+
surface_land_cover_grid = np.full((nx, ny), -1, dtype=np.int32)
|
|
995
|
+
|
|
996
|
+
# Convert from VoxCity [row, col, z] to palm_solar [x, y, z]
|
|
997
|
+
for row in range(ny):
|
|
998
|
+
for col in range(nx):
|
|
999
|
+
x_idx = col
|
|
1000
|
+
y_idx = row
|
|
1001
|
+
|
|
1002
|
+
# Get land cover for this column (from ground surface)
|
|
1003
|
+
if land_cover_grid is not None:
|
|
1004
|
+
# Land cover grid is [row, col], values are class codes
|
|
1005
|
+
lc_val = land_cover_grid[row, col]
|
|
1006
|
+
if lc_val > 0:
|
|
1007
|
+
# VoxCity adds +1 to land cover codes, so subtract 1
|
|
1008
|
+
surface_land_cover_grid[x_idx, y_idx] = int(lc_val) - 1
|
|
1009
|
+
else:
|
|
1010
|
+
surface_land_cover_grid[x_idx, y_idx] = 9 # Default: developed
|
|
1011
|
+
|
|
1012
|
+
for z in range(nz):
|
|
1013
|
+
voxel_val = voxel_grid[row, col, z]
|
|
1014
|
+
|
|
1015
|
+
if voxel_val == VOXCITY_BUILDING_CODE:
|
|
1016
|
+
is_solid_np[x_idx, y_idx, z] = 1
|
|
1017
|
+
elif voxel_val == VOXCITY_GROUND_CODE:
|
|
1018
|
+
is_solid_np[x_idx, y_idx, z] = 1
|
|
1019
|
+
elif voxel_val == VOXCITY_TREE_CODE:
|
|
1020
|
+
lad_np[x_idx, y_idx, z] = default_lad
|
|
1021
|
+
elif voxel_val > 0:
|
|
1022
|
+
# Positive values are land cover codes on ground
|
|
1023
|
+
is_solid_np[x_idx, y_idx, z] = 1
|
|
1024
|
+
|
|
1025
|
+
# Set domain arrays
|
|
1026
|
+
_set_solid_array(domain, is_solid_np)
|
|
1027
|
+
domain.set_lad_from_array(lad_np)
|
|
1028
|
+
_update_topo_from_solid(domain)
|
|
1029
|
+
|
|
1030
|
+
# Count statistics
|
|
1031
|
+
solid_count = is_solid_np.sum()
|
|
1032
|
+
lad_count = (lad_np > 0).sum()
|
|
1033
|
+
print(f"Solid voxels: {solid_count:,}")
|
|
1034
|
+
print(f"Vegetation voxels (LAD > 0): {lad_count:,}")
|
|
1035
|
+
|
|
1036
|
+
return VoxCityDomainResult(
|
|
1037
|
+
domain=domain,
|
|
1038
|
+
surface_land_cover=surface_land_cover_grid,
|
|
1039
|
+
land_cover_albedo=land_cover_albedo
|
|
1040
|
+
)
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def apply_voxcity_albedo(
|
|
1044
|
+
model,
|
|
1045
|
+
voxcity_result: VoxCityDomainResult
|
|
1046
|
+
) -> None:
|
|
1047
|
+
"""
|
|
1048
|
+
Apply VoxCity land cover-based albedo values to radiation model surfaces.
|
|
1049
|
+
|
|
1050
|
+
This function sets surface albedo values based on:
|
|
1051
|
+
- Land cover class for ground surfaces
|
|
1052
|
+
- Building wall/roof albedo for building surfaces
|
|
1053
|
+
|
|
1054
|
+
Args:
|
|
1055
|
+
model: RadiationModel instance (after surface extraction)
|
|
1056
|
+
voxcity_result: Result from convert_voxcity_to_domain()
|
|
1057
|
+
"""
|
|
1058
|
+
import taichi as ti
|
|
1059
|
+
from ..init_taichi import ensure_initialized
|
|
1060
|
+
ensure_initialized()
|
|
1061
|
+
|
|
1062
|
+
if voxcity_result.surface_land_cover is None:
|
|
1063
|
+
print("Warning: No land cover data available, using default albedos")
|
|
1064
|
+
return
|
|
1065
|
+
|
|
1066
|
+
domain = voxcity_result.domain
|
|
1067
|
+
lc_grid = voxcity_result.surface_land_cover
|
|
1068
|
+
lc_albedo = voxcity_result.land_cover_albedo
|
|
1069
|
+
|
|
1070
|
+
# Get surface data
|
|
1071
|
+
n_surfaces = model.surfaces.n_surfaces[None]
|
|
1072
|
+
max_surfaces = model.surfaces.max_surfaces
|
|
1073
|
+
positions = model.surfaces.position.to_numpy()[:n_surfaces]
|
|
1074
|
+
directions = model.surfaces.direction.to_numpy()[:n_surfaces]
|
|
1075
|
+
|
|
1076
|
+
# Create albedo array with full size (must match Taichi field shape)
|
|
1077
|
+
albedo_values = np.zeros(max_surfaces, dtype=np.float32)
|
|
1078
|
+
|
|
1079
|
+
# Direction codes
|
|
1080
|
+
IUP = 0
|
|
1081
|
+
IDOWN = 1
|
|
1082
|
+
|
|
1083
|
+
for idx in range(n_surfaces):
|
|
1084
|
+
i, j, k = positions[idx]
|
|
1085
|
+
direction = directions[idx]
|
|
1086
|
+
|
|
1087
|
+
if direction == IUP: # Upward facing
|
|
1088
|
+
if k == 0 or k == 1:
|
|
1089
|
+
# Ground level - use land cover albedo
|
|
1090
|
+
lc_code = lc_grid[i, j]
|
|
1091
|
+
if lc_code >= 0:
|
|
1092
|
+
albedo_values[idx] = lc_albedo.get_land_cover_albedo(lc_code)
|
|
1093
|
+
else:
|
|
1094
|
+
albedo_values[idx] = lc_albedo.developed
|
|
1095
|
+
else:
|
|
1096
|
+
# Roof
|
|
1097
|
+
albedo_values[idx] = lc_albedo.building_roof
|
|
1098
|
+
elif direction == IDOWN: # Downward facing
|
|
1099
|
+
albedo_values[idx] = lc_albedo.building_wall
|
|
1100
|
+
else: # Walls (N, S, E, W)
|
|
1101
|
+
albedo_values[idx] = lc_albedo.building_wall
|
|
1102
|
+
|
|
1103
|
+
# Apply albedo values to surfaces
|
|
1104
|
+
model.surfaces.albedo.from_numpy(albedo_values)
|
|
1105
|
+
|
|
1106
|
+
# Print summary
|
|
1107
|
+
unique_albedos = np.unique(albedo_values[:n_surfaces])
|
|
1108
|
+
print(f"Applied {len(unique_albedos)} unique albedo values to {n_surfaces} surfaces")
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _set_solid_array(domain: Domain, solid_array: np.ndarray) -> None:
|
|
1112
|
+
"""Set domain solid cells from numpy array."""
|
|
1113
|
+
import taichi as ti
|
|
1114
|
+
from ..init_taichi import ensure_initialized
|
|
1115
|
+
ensure_initialized()
|
|
1116
|
+
|
|
1117
|
+
@ti.kernel
|
|
1118
|
+
def _set_solid_kernel(domain: ti.template(), solid: ti.types.ndarray()):
|
|
1119
|
+
for i, j, k in domain.is_solid:
|
|
1120
|
+
domain.is_solid[i, j, k] = solid[i, j, k]
|
|
1121
|
+
|
|
1122
|
+
_set_solid_kernel(domain, solid_array)
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
def _update_topo_from_solid(domain: Domain) -> None:
|
|
1126
|
+
"""Update topography field from solid array."""
|
|
1127
|
+
import taichi as ti
|
|
1128
|
+
from ..init_taichi import ensure_initialized
|
|
1129
|
+
ensure_initialized()
|
|
1130
|
+
|
|
1131
|
+
@ti.kernel
|
|
1132
|
+
def _update_topo_kernel(domain: ti.template()):
|
|
1133
|
+
for i, j in domain.topo_top:
|
|
1134
|
+
max_k = 0
|
|
1135
|
+
for k in range(domain.nz):
|
|
1136
|
+
if domain.is_solid[i, j, k] == 1:
|
|
1137
|
+
max_k = k
|
|
1138
|
+
domain.topo_top[i, j] = max_k
|
|
1139
|
+
|
|
1140
|
+
_update_topo_kernel(domain)
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
def create_radiation_config_for_voxcity(
|
|
1144
|
+
land_cover_albedo: Optional[LandCoverAlbedo] = None,
|
|
1145
|
+
**kwargs
|
|
1146
|
+
) -> RadiationConfig:
|
|
1147
|
+
"""
|
|
1148
|
+
Create a RadiationConfig suitable for VoxCity simulations.
|
|
1149
|
+
|
|
1150
|
+
This sets appropriate default values for urban environments.
|
|
1151
|
+
|
|
1152
|
+
Args:
|
|
1153
|
+
land_cover_albedo: Land cover albedo mapping (for reference)
|
|
1154
|
+
**kwargs: Additional RadiationConfig parameters
|
|
1155
|
+
|
|
1156
|
+
Returns:
|
|
1157
|
+
RadiationConfig instance
|
|
1158
|
+
"""
|
|
1159
|
+
if land_cover_albedo is None:
|
|
1160
|
+
land_cover_albedo = LandCoverAlbedo()
|
|
1161
|
+
|
|
1162
|
+
# Set defaults suitable for urban environments
|
|
1163
|
+
defaults = {
|
|
1164
|
+
'albedo_ground': land_cover_albedo.developed,
|
|
1165
|
+
'albedo_wall': land_cover_albedo.building_wall,
|
|
1166
|
+
'albedo_roof': land_cover_albedo.building_roof,
|
|
1167
|
+
'albedo_leaf': land_cover_albedo.leaf,
|
|
1168
|
+
'n_azimuth': 40, # Reduced for faster computation
|
|
1169
|
+
'n_elevation': 10,
|
|
1170
|
+
'n_reflection_steps': 2,
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
# Override with user-provided values
|
|
1174
|
+
defaults.update(kwargs)
|
|
1175
|
+
|
|
1176
|
+
return RadiationConfig(**defaults)
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _compute_ground_irradiance_with_reflections(
|
|
1180
|
+
voxcity,
|
|
1181
|
+
azimuth_degrees_ori: float,
|
|
1182
|
+
elevation_degrees: float,
|
|
1183
|
+
direct_normal_irradiance: float,
|
|
1184
|
+
diffuse_irradiance: float,
|
|
1185
|
+
view_point_height: float = 1.5,
|
|
1186
|
+
n_reflection_steps: int = 2,
|
|
1187
|
+
progress_report: bool = False,
|
|
1188
|
+
**kwargs
|
|
1189
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
1190
|
+
"""
|
|
1191
|
+
Compute ground-level irradiance using full RadiationModel with reflections.
|
|
1192
|
+
|
|
1193
|
+
Uses a cached RadiationModel to avoid recomputing SVF/CSF matrices for each
|
|
1194
|
+
solar position. The geometry-dependent matrices are computed once and reused.
|
|
1195
|
+
|
|
1196
|
+
Note: The diffuse component includes sky diffuse + multi-bounce surface reflections +
|
|
1197
|
+
canopy scattering, as computed by the RadiationModel.
|
|
1198
|
+
|
|
1199
|
+
Args:
|
|
1200
|
+
voxcity: VoxCity object
|
|
1201
|
+
azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
|
|
1202
|
+
elevation_degrees: Solar elevation in degrees above horizon
|
|
1203
|
+
direct_normal_irradiance: DNI in W/m²
|
|
1204
|
+
diffuse_irradiance: DHI in W/m²
|
|
1205
|
+
view_point_height: Observer height above ground (default: 1.5)
|
|
1206
|
+
n_reflection_steps: Number of reflection bounces (default: 2)
|
|
1207
|
+
progress_report: Print progress (default: False)
|
|
1208
|
+
**kwargs: Additional parameters
|
|
1209
|
+
|
|
1210
|
+
Returns:
|
|
1211
|
+
Tuple of (direct_map, diffuse_map, reflected_map) as 2D numpy arrays
|
|
1212
|
+
"""
|
|
1213
|
+
from .domain import IUP
|
|
1214
|
+
|
|
1215
|
+
voxel_data = voxcity.voxels.classes
|
|
1216
|
+
ni, nj, nk = voxel_data.shape
|
|
1217
|
+
|
|
1218
|
+
# Remove parameters that we pass explicitly to avoid duplicates
|
|
1219
|
+
filtered_kwargs = {k: v for k, v in kwargs.items()
|
|
1220
|
+
if k not in ('n_reflection_steps', 'progress_report', 'view_point_height')}
|
|
1221
|
+
|
|
1222
|
+
# Get or create cached RadiationModel (SVF/CSF only computed once)
|
|
1223
|
+
model, valid_ground, ground_k = _get_or_create_radiation_model(
|
|
1224
|
+
voxcity,
|
|
1225
|
+
n_reflection_steps=n_reflection_steps,
|
|
1226
|
+
progress_report=progress_report,
|
|
1227
|
+
**filtered_kwargs
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
# Set solar position for this timestep
|
|
1231
|
+
azimuth_degrees = 180 - azimuth_degrees_ori
|
|
1232
|
+
azimuth_radians = np.deg2rad(azimuth_degrees)
|
|
1233
|
+
elevation_radians = np.deg2rad(elevation_degrees)
|
|
1234
|
+
|
|
1235
|
+
sun_dir_x = np.cos(elevation_radians) * np.cos(azimuth_radians)
|
|
1236
|
+
sun_dir_y = np.cos(elevation_radians) * np.sin(azimuth_radians)
|
|
1237
|
+
sun_dir_z = np.sin(elevation_radians)
|
|
1238
|
+
|
|
1239
|
+
# Set sun direction and cos_zenith directly on the SolarCalculator fields
|
|
1240
|
+
model.solar_calc.sun_direction[None] = (sun_dir_x, sun_dir_y, sun_dir_z)
|
|
1241
|
+
model.solar_calc.cos_zenith[None] = np.sin(elevation_radians) # cos(zenith) = sin(elevation)
|
|
1242
|
+
model.solar_calc.sun_up[None] = 1 if elevation_degrees > 0 else 0
|
|
1243
|
+
|
|
1244
|
+
# Compute shortwave radiation (uses cached SVF/CSF matrices)
|
|
1245
|
+
model.compute_shortwave_radiation(
|
|
1246
|
+
sw_direct=direct_normal_irradiance,
|
|
1247
|
+
sw_diffuse=diffuse_irradiance
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
# Extract surface irradiance using cached mapping for vectorized extraction
|
|
1251
|
+
# This is much faster than iterating through all surfaces
|
|
1252
|
+
n_surfaces = model.surfaces.count
|
|
1253
|
+
|
|
1254
|
+
# Initialize output arrays
|
|
1255
|
+
direct_map = np.full((ni, nj), np.nan, dtype=np.float32)
|
|
1256
|
+
diffuse_map = np.full((ni, nj), np.nan, dtype=np.float32)
|
|
1257
|
+
reflected_map = np.zeros((ni, nj), dtype=np.float32)
|
|
1258
|
+
|
|
1259
|
+
# Use pre-computed surface-to-grid mapping if available (from cache)
|
|
1260
|
+
if (_radiation_model_cache is not None and
|
|
1261
|
+
_radiation_model_cache.grid_indices is not None and
|
|
1262
|
+
len(_radiation_model_cache.grid_indices) > 0):
|
|
1263
|
+
|
|
1264
|
+
grid_indices = _radiation_model_cache.grid_indices
|
|
1265
|
+
surface_indices = _radiation_model_cache.surface_indices
|
|
1266
|
+
|
|
1267
|
+
# Extract only the irradiance values we need (vectorized)
|
|
1268
|
+
sw_in_direct = model.surfaces.sw_in_direct.to_numpy()
|
|
1269
|
+
sw_in_diffuse = model.surfaces.sw_in_diffuse.to_numpy()
|
|
1270
|
+
|
|
1271
|
+
# Vectorized assignment using pre-computed indices
|
|
1272
|
+
direct_map[grid_indices[:, 0], grid_indices[:, 1]] = sw_in_direct[surface_indices]
|
|
1273
|
+
diffuse_map[grid_indices[:, 0], grid_indices[:, 1]] = sw_in_diffuse[surface_indices]
|
|
1274
|
+
else:
|
|
1275
|
+
# Fallback to original loop if no cached mapping
|
|
1276
|
+
from .domain import IUP
|
|
1277
|
+
positions = model.surfaces.position.to_numpy()[:n_surfaces]
|
|
1278
|
+
directions = model.surfaces.direction.to_numpy()[:n_surfaces]
|
|
1279
|
+
sw_in_direct = model.surfaces.sw_in_direct.to_numpy()[:n_surfaces]
|
|
1280
|
+
sw_in_diffuse = model.surfaces.sw_in_diffuse.to_numpy()[:n_surfaces]
|
|
1281
|
+
|
|
1282
|
+
for idx in range(n_surfaces):
|
|
1283
|
+
pos_i, pos_j, k = positions[idx]
|
|
1284
|
+
direction = directions[idx]
|
|
1285
|
+
|
|
1286
|
+
if direction == IUP:
|
|
1287
|
+
ii, jj = int(pos_i), int(pos_j)
|
|
1288
|
+
if 0 <= ii < ni and 0 <= jj < nj:
|
|
1289
|
+
if not valid_ground[ii, jj]:
|
|
1290
|
+
continue
|
|
1291
|
+
if int(k) == ground_k[ii, jj]:
|
|
1292
|
+
if np.isnan(direct_map[ii, jj]):
|
|
1293
|
+
direct_map[ii, jj] = sw_in_direct[idx]
|
|
1294
|
+
diffuse_map[ii, jj] = sw_in_diffuse[idx]
|
|
1295
|
+
|
|
1296
|
+
# Flip to match VoxCity coordinate system
|
|
1297
|
+
direct_map = np.flipud(direct_map)
|
|
1298
|
+
diffuse_map = np.flipud(diffuse_map)
|
|
1299
|
+
reflected_map = np.flipud(reflected_map)
|
|
1300
|
+
|
|
1301
|
+
return direct_map, diffuse_map, reflected_map
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
# =============================================================================
|
|
1305
|
+
# VoxCity API-Compatible Solar Irradiance Functions
|
|
1306
|
+
# =============================================================================
|
|
1307
|
+
# These functions match the voxcity.simulator.solar API signatures for
|
|
1308
|
+
# drop-in replacement with GPU acceleration.
|
|
1309
|
+
|
|
1310
|
+
def get_direct_solar_irradiance_map(
|
|
1311
|
+
voxcity,
|
|
1312
|
+
azimuth_degrees_ori: float,
|
|
1313
|
+
elevation_degrees: float,
|
|
1314
|
+
direct_normal_irradiance: float,
|
|
1315
|
+
show_plot: bool = False,
|
|
1316
|
+
with_reflections: bool = False,
|
|
1317
|
+
**kwargs
|
|
1318
|
+
) -> np.ndarray:
|
|
1319
|
+
"""
|
|
1320
|
+
GPU-accelerated direct horizontal irradiance map computation.
|
|
1321
|
+
|
|
1322
|
+
This function matches the signature of voxcity.simulator.solar.get_direct_solar_irradiance_map
|
|
1323
|
+
using Taichi GPU acceleration.
|
|
1324
|
+
|
|
1325
|
+
Args:
|
|
1326
|
+
voxcity: VoxCity object
|
|
1327
|
+
azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
|
|
1328
|
+
elevation_degrees: Solar elevation in degrees above horizon
|
|
1329
|
+
direct_normal_irradiance: DNI in W/m²
|
|
1330
|
+
show_plot: Whether to display a matplotlib plot
|
|
1331
|
+
with_reflections: If True, use full RadiationModel with multi-bounce
|
|
1332
|
+
reflections. If False (default), use simple ray-tracing for
|
|
1333
|
+
faster but less accurate results.
|
|
1334
|
+
**kwargs: Additional parameters including:
|
|
1335
|
+
- view_point_height (float): Observer height above ground (default: 1.5)
|
|
1336
|
+
- tree_k (float): Tree extinction coefficient (default: 0.6)
|
|
1337
|
+
- tree_lad (float): Leaf area density (default: 1.0)
|
|
1338
|
+
- colormap (str): Matplotlib colormap name (default: 'magma')
|
|
1339
|
+
- vmin, vmax (float): Colormap limits
|
|
1340
|
+
- obj_export (bool): Export to OBJ file (default: False)
|
|
1341
|
+
- n_reflection_steps (int): Number of reflection bounces when
|
|
1342
|
+
with_reflections=True (default: 2)
|
|
1343
|
+
- progress_report (bool): Print progress (default: False)
|
|
1344
|
+
|
|
1345
|
+
Returns:
|
|
1346
|
+
2D numpy array of direct horizontal irradiance (W/m²)
|
|
1347
|
+
"""
|
|
1348
|
+
import taichi as ti
|
|
1349
|
+
from ..init_taichi import ensure_initialized
|
|
1350
|
+
ensure_initialized()
|
|
1351
|
+
|
|
1352
|
+
colormap = kwargs.get('colormap', 'magma')
|
|
1353
|
+
vmin = kwargs.get('vmin', 0.0)
|
|
1354
|
+
vmax = kwargs.get('vmax', direct_normal_irradiance)
|
|
1355
|
+
|
|
1356
|
+
if with_reflections:
|
|
1357
|
+
# Use full RadiationModel with reflections
|
|
1358
|
+
direct_map, _, _ = _compute_ground_irradiance_with_reflections(
|
|
1359
|
+
voxcity=voxcity,
|
|
1360
|
+
azimuth_degrees_ori=azimuth_degrees_ori,
|
|
1361
|
+
elevation_degrees=elevation_degrees,
|
|
1362
|
+
direct_normal_irradiance=direct_normal_irradiance,
|
|
1363
|
+
diffuse_irradiance=0.0, # Only compute direct component
|
|
1364
|
+
**kwargs
|
|
1365
|
+
)
|
|
1366
|
+
else:
|
|
1367
|
+
# Use simple ray-tracing (faster but no reflections)
|
|
1368
|
+
voxel_data = voxcity.voxels.classes
|
|
1369
|
+
meshsize = voxcity.voxels.meta.meshsize
|
|
1370
|
+
|
|
1371
|
+
view_point_height = kwargs.get('view_point_height', 1.5)
|
|
1372
|
+
tree_k = kwargs.get('tree_k', 0.6)
|
|
1373
|
+
tree_lad = kwargs.get('tree_lad', 1.0)
|
|
1374
|
+
|
|
1375
|
+
# Convert to sun direction vector
|
|
1376
|
+
# VoxCity convention: azimuth 0=North, clockwise
|
|
1377
|
+
# Convert to standard: 180 - azimuth
|
|
1378
|
+
azimuth_degrees = 180 - azimuth_degrees_ori
|
|
1379
|
+
azimuth_radians = np.deg2rad(azimuth_degrees)
|
|
1380
|
+
elevation_radians = np.deg2rad(elevation_degrees)
|
|
1381
|
+
|
|
1382
|
+
dx_dir = np.cos(elevation_radians) * np.cos(azimuth_radians)
|
|
1383
|
+
dy_dir = np.cos(elevation_radians) * np.sin(azimuth_radians)
|
|
1384
|
+
dz_dir = np.sin(elevation_radians)
|
|
1385
|
+
|
|
1386
|
+
# Compute transmittance map using ray tracing
|
|
1387
|
+
transmittance_map = _compute_direct_transmittance_map_gpu(
|
|
1388
|
+
voxel_data=voxel_data,
|
|
1389
|
+
sun_direction=(dx_dir, dy_dir, dz_dir),
|
|
1390
|
+
view_point_height=view_point_height,
|
|
1391
|
+
meshsize=meshsize,
|
|
1392
|
+
tree_k=tree_k,
|
|
1393
|
+
tree_lad=tree_lad
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
# Convert to horizontal irradiance
|
|
1397
|
+
sin_elev = np.sin(elevation_radians)
|
|
1398
|
+
direct_map = transmittance_map * direct_normal_irradiance * sin_elev
|
|
1399
|
+
|
|
1400
|
+
# Flip to match VoxCity coordinate system
|
|
1401
|
+
direct_map = np.flipud(direct_map)
|
|
1402
|
+
|
|
1403
|
+
if show_plot:
|
|
1404
|
+
try:
|
|
1405
|
+
import matplotlib.pyplot as plt
|
|
1406
|
+
cmap = plt.cm.get_cmap(colormap).copy()
|
|
1407
|
+
cmap.set_bad(color='lightgray')
|
|
1408
|
+
plt.figure(figsize=(10, 8))
|
|
1409
|
+
plt.imshow(direct_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
1410
|
+
plt.colorbar(label='Direct Solar Irradiance (W/m²)')
|
|
1411
|
+
plt.axis('off')
|
|
1412
|
+
plt.show()
|
|
1413
|
+
except ImportError:
|
|
1414
|
+
pass
|
|
1415
|
+
|
|
1416
|
+
if kwargs.get('obj_export', False):
|
|
1417
|
+
_export_irradiance_to_obj(
|
|
1418
|
+
voxcity, direct_map,
|
|
1419
|
+
output_name=kwargs.get('output_file_name', 'direct_solar_irradiance'),
|
|
1420
|
+
**kwargs
|
|
1421
|
+
)
|
|
1422
|
+
|
|
1423
|
+
return direct_map
|
|
1424
|
+
|
|
1425
|
+
|
|
1426
|
+
def get_diffuse_solar_irradiance_map(
|
|
1427
|
+
voxcity,
|
|
1428
|
+
diffuse_irradiance: float = 1.0,
|
|
1429
|
+
show_plot: bool = False,
|
|
1430
|
+
with_reflections: bool = False,
|
|
1431
|
+
azimuth_degrees_ori: float = 180.0,
|
|
1432
|
+
elevation_degrees: float = 45.0,
|
|
1433
|
+
**kwargs
|
|
1434
|
+
) -> np.ndarray:
|
|
1435
|
+
"""
|
|
1436
|
+
GPU-accelerated diffuse horizontal irradiance map computation using SVF.
|
|
1437
|
+
|
|
1438
|
+
This function matches the signature of voxcity.simulator.solar.get_diffuse_solar_irradiance_map
|
|
1439
|
+
using Taichi GPU acceleration.
|
|
1440
|
+
|
|
1441
|
+
Args:
|
|
1442
|
+
voxcity: VoxCity object
|
|
1443
|
+
diffuse_irradiance: Diffuse horizontal irradiance in W/m²
|
|
1444
|
+
show_plot: Whether to display a matplotlib plot
|
|
1445
|
+
with_reflections: If True, use full RadiationModel with multi-bounce
|
|
1446
|
+
reflections (requires azimuth_degrees_ori and elevation_degrees).
|
|
1447
|
+
If False (default), use simple SVF-based computation.
|
|
1448
|
+
azimuth_degrees_ori: Solar azimuth in degrees (only used when with_reflections=True)
|
|
1449
|
+
elevation_degrees: Solar elevation in degrees (only used when with_reflections=True)
|
|
1450
|
+
**kwargs: Additional parameters including:
|
|
1451
|
+
- view_point_height (float): Observer height above ground (default: 1.5)
|
|
1452
|
+
- N_azimuth (int): Number of azimuthal divisions (default: 120)
|
|
1453
|
+
- N_elevation (int): Number of elevation divisions (default: 20)
|
|
1454
|
+
- tree_k (float): Tree extinction coefficient (default: 0.6)
|
|
1455
|
+
- tree_lad (float): Leaf area density (default: 1.0)
|
|
1456
|
+
- colormap (str): Matplotlib colormap name (default: 'magma')
|
|
1457
|
+
- vmin, vmax (float): Colormap limits
|
|
1458
|
+
- obj_export (bool): Export to OBJ file (default: False)
|
|
1459
|
+
- n_reflection_steps (int): Number of reflection bounces when
|
|
1460
|
+
with_reflections=True (default: 2)
|
|
1461
|
+
- progress_report (bool): Print progress (default: False)
|
|
1462
|
+
|
|
1463
|
+
Returns:
|
|
1464
|
+
2D numpy array of diffuse horizontal irradiance (W/m²)
|
|
1465
|
+
"""
|
|
1466
|
+
colormap = kwargs.get('colormap', 'magma')
|
|
1467
|
+
vmin = kwargs.get('vmin', 0.0)
|
|
1468
|
+
vmax = kwargs.get('vmax', diffuse_irradiance)
|
|
1469
|
+
|
|
1470
|
+
if with_reflections:
|
|
1471
|
+
# Use full RadiationModel with reflections
|
|
1472
|
+
# Remove parameters we explicitly set to avoid conflicts
|
|
1473
|
+
refl_kwargs = {k: v for k, v in kwargs.items()
|
|
1474
|
+
if k not in ('direct_normal_irradiance', 'diffuse_irradiance')}
|
|
1475
|
+
_, diffuse_map, reflected_map = _compute_ground_irradiance_with_reflections(
|
|
1476
|
+
voxcity=voxcity,
|
|
1477
|
+
azimuth_degrees_ori=azimuth_degrees_ori,
|
|
1478
|
+
elevation_degrees=elevation_degrees,
|
|
1479
|
+
direct_normal_irradiance=kwargs.get('direct_normal_irradiance', 0.0),
|
|
1480
|
+
diffuse_irradiance=diffuse_irradiance,
|
|
1481
|
+
**refl_kwargs
|
|
1482
|
+
)
|
|
1483
|
+
# Include reflected component in diffuse when using reflection model
|
|
1484
|
+
diffuse_map = np.where(np.isnan(diffuse_map), np.nan, diffuse_map + reflected_map)
|
|
1485
|
+
else:
|
|
1486
|
+
# Use simple SVF-based computation (faster but no reflections)
|
|
1487
|
+
# Import the visibility SVF function
|
|
1488
|
+
from ..visibility.integration import get_sky_view_factor_map as get_svf_map
|
|
1489
|
+
|
|
1490
|
+
# Get SVF map using GPU-accelerated visibility module
|
|
1491
|
+
svf_kwargs = kwargs.copy()
|
|
1492
|
+
svf_kwargs['colormap'] = 'BuPu_r'
|
|
1493
|
+
svf_kwargs['vmin'] = 0
|
|
1494
|
+
svf_kwargs['vmax'] = 1
|
|
1495
|
+
|
|
1496
|
+
SVF_map = get_svf_map(voxcity, show_plot=False, **svf_kwargs)
|
|
1497
|
+
diffuse_map = SVF_map * diffuse_irradiance
|
|
1498
|
+
|
|
1499
|
+
if show_plot:
|
|
1500
|
+
try:
|
|
1501
|
+
import matplotlib.pyplot as plt
|
|
1502
|
+
cmap = plt.cm.get_cmap(colormap).copy()
|
|
1503
|
+
cmap.set_bad(color='lightgray')
|
|
1504
|
+
plt.figure(figsize=(10, 8))
|
|
1505
|
+
plt.imshow(diffuse_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
1506
|
+
plt.colorbar(label='Diffuse Solar Irradiance (W/m²)')
|
|
1507
|
+
plt.axis('off')
|
|
1508
|
+
plt.show()
|
|
1509
|
+
except ImportError:
|
|
1510
|
+
pass
|
|
1511
|
+
|
|
1512
|
+
if kwargs.get('obj_export', False):
|
|
1513
|
+
_export_irradiance_to_obj(
|
|
1514
|
+
voxcity, diffuse_map,
|
|
1515
|
+
output_name=kwargs.get('output_file_name', 'diffuse_solar_irradiance'),
|
|
1516
|
+
**kwargs
|
|
1517
|
+
)
|
|
1518
|
+
|
|
1519
|
+
return diffuse_map
|
|
1520
|
+
|
|
1521
|
+
|
|
1522
|
+
def get_global_solar_irradiance_map(
|
|
1523
|
+
voxcity,
|
|
1524
|
+
azimuth_degrees_ori: float,
|
|
1525
|
+
elevation_degrees: float,
|
|
1526
|
+
direct_normal_irradiance: float,
|
|
1527
|
+
diffuse_irradiance: float,
|
|
1528
|
+
show_plot: bool = False,
|
|
1529
|
+
with_reflections: bool = False,
|
|
1530
|
+
**kwargs
|
|
1531
|
+
) -> np.ndarray:
|
|
1532
|
+
"""
|
|
1533
|
+
GPU-accelerated global (direct + diffuse) horizontal irradiance map.
|
|
1534
|
+
|
|
1535
|
+
This function matches the signature of voxcity.simulator.solar.get_global_solar_irradiance_map
|
|
1536
|
+
using Taichi GPU acceleration.
|
|
1537
|
+
|
|
1538
|
+
Args:
|
|
1539
|
+
voxcity: VoxCity object
|
|
1540
|
+
azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
|
|
1541
|
+
elevation_degrees: Solar elevation in degrees above horizon
|
|
1542
|
+
direct_normal_irradiance: DNI in W/m²
|
|
1543
|
+
diffuse_irradiance: DHI in W/m²
|
|
1544
|
+
show_plot: Whether to display a matplotlib plot
|
|
1545
|
+
with_reflections: If True, use full RadiationModel with multi-bounce
|
|
1546
|
+
reflections. If False (default), use simple ray-tracing/SVF for
|
|
1547
|
+
faster but less accurate results.
|
|
1548
|
+
**kwargs: Additional parameters (see get_direct_solar_irradiance_map)
|
|
1549
|
+
- computation_mask (np.ndarray): Optional 2D boolean mask for sub-area computation
|
|
1550
|
+
- n_reflection_steps (int): Number of reflection bounces when
|
|
1551
|
+
with_reflections=True (default: 2)
|
|
1552
|
+
- progress_report (bool): Print progress (default: False)
|
|
1553
|
+
|
|
1554
|
+
Returns:
|
|
1555
|
+
2D numpy array of global horizontal irradiance (W/m²)
|
|
1556
|
+
"""
|
|
1557
|
+
# Extract computation_mask from kwargs
|
|
1558
|
+
computation_mask = kwargs.pop('computation_mask', None)
|
|
1559
|
+
|
|
1560
|
+
if with_reflections:
|
|
1561
|
+
# Use full RadiationModel with reflections (single call for all components)
|
|
1562
|
+
direct_map, diffuse_map, reflected_map = _compute_ground_irradiance_with_reflections(
|
|
1563
|
+
voxcity=voxcity,
|
|
1564
|
+
azimuth_degrees_ori=azimuth_degrees_ori,
|
|
1565
|
+
elevation_degrees=elevation_degrees,
|
|
1566
|
+
direct_normal_irradiance=direct_normal_irradiance,
|
|
1567
|
+
diffuse_irradiance=diffuse_irradiance,
|
|
1568
|
+
**kwargs
|
|
1569
|
+
)
|
|
1570
|
+
# Combine all components: direct + diffuse + reflected
|
|
1571
|
+
global_map = np.where(
|
|
1572
|
+
np.isnan(direct_map),
|
|
1573
|
+
np.nan,
|
|
1574
|
+
direct_map + diffuse_map + reflected_map
|
|
1575
|
+
)
|
|
1576
|
+
else:
|
|
1577
|
+
# Compute direct and diffuse components separately (no reflections)
|
|
1578
|
+
direct_map = get_direct_solar_irradiance_map(
|
|
1579
|
+
voxcity,
|
|
1580
|
+
azimuth_degrees_ori,
|
|
1581
|
+
elevation_degrees,
|
|
1582
|
+
direct_normal_irradiance,
|
|
1583
|
+
show_plot=False,
|
|
1584
|
+
with_reflections=False,
|
|
1585
|
+
**kwargs
|
|
1586
|
+
)
|
|
1587
|
+
|
|
1588
|
+
diffuse_map = get_diffuse_solar_irradiance_map(
|
|
1589
|
+
voxcity,
|
|
1590
|
+
diffuse_irradiance=diffuse_irradiance,
|
|
1591
|
+
show_plot=False,
|
|
1592
|
+
with_reflections=False,
|
|
1593
|
+
**kwargs
|
|
1594
|
+
)
|
|
1595
|
+
|
|
1596
|
+
# Combine: where direct is NaN, use only diffuse
|
|
1597
|
+
global_map = np.where(np.isnan(direct_map), diffuse_map, direct_map + diffuse_map)
|
|
1598
|
+
|
|
1599
|
+
if show_plot:
|
|
1600
|
+
colormap = kwargs.get('colormap', 'magma')
|
|
1601
|
+
vmin = kwargs.get('vmin', 0.0)
|
|
1602
|
+
vmax = kwargs.get('vmax', max(float(np.nanmax(global_map)), 1.0))
|
|
1603
|
+
try:
|
|
1604
|
+
import matplotlib.pyplot as plt
|
|
1605
|
+
cmap = plt.cm.get_cmap(colormap).copy()
|
|
1606
|
+
cmap.set_bad(color='lightgray')
|
|
1607
|
+
plt.figure(figsize=(10, 8))
|
|
1608
|
+
plt.imshow(global_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
1609
|
+
plt.colorbar(label='Global Solar Irradiance (W/m²)')
|
|
1610
|
+
plt.axis('off')
|
|
1611
|
+
plt.show()
|
|
1612
|
+
except ImportError:
|
|
1613
|
+
pass
|
|
1614
|
+
|
|
1615
|
+
if kwargs.get('obj_export', False):
|
|
1616
|
+
_export_irradiance_to_obj(
|
|
1617
|
+
voxcity, global_map,
|
|
1618
|
+
output_name=kwargs.get('output_file_name', 'global_solar_irradiance'),
|
|
1619
|
+
**kwargs
|
|
1620
|
+
)
|
|
1621
|
+
|
|
1622
|
+
# Apply computation mask if provided
|
|
1623
|
+
if computation_mask is not None:
|
|
1624
|
+
# Ensure mask shape matches output shape (note: output is flipped)
|
|
1625
|
+
if computation_mask.shape == global_map.shape:
|
|
1626
|
+
global_map = np.where(np.flipud(computation_mask), global_map, np.nan)
|
|
1627
|
+
elif computation_mask.T.shape == global_map.shape:
|
|
1628
|
+
global_map = np.where(np.flipud(computation_mask.T), global_map, np.nan)
|
|
1629
|
+
else:
|
|
1630
|
+
# Try to match without flip
|
|
1631
|
+
if computation_mask.shape == global_map.shape:
|
|
1632
|
+
global_map = np.where(computation_mask, global_map, np.nan)
|
|
1633
|
+
|
|
1634
|
+
return global_map
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def get_cumulative_global_solar_irradiance(
|
|
1638
|
+
voxcity,
|
|
1639
|
+
df,
|
|
1640
|
+
lon: float,
|
|
1641
|
+
lat: float,
|
|
1642
|
+
tz: float,
|
|
1643
|
+
direct_normal_irradiance_scaling: float = 1.0,
|
|
1644
|
+
diffuse_irradiance_scaling: float = 1.0,
|
|
1645
|
+
show_plot: bool = False,
|
|
1646
|
+
with_reflections: bool = False,
|
|
1647
|
+
**kwargs
|
|
1648
|
+
) -> np.ndarray:
|
|
1649
|
+
"""
|
|
1650
|
+
GPU-accelerated cumulative global solar irradiance over a period.
|
|
1651
|
+
|
|
1652
|
+
This function matches the signature of voxcity.simulator.solar.get_cumulative_global_solar_irradiance
|
|
1653
|
+
using Taichi GPU acceleration with sky patch optimization.
|
|
1654
|
+
|
|
1655
|
+
OPTIMIZATIONS IMPLEMENTED:
|
|
1656
|
+
1. Vectorized sun position binning using bin_sun_positions_to_tregenza_fast
|
|
1657
|
+
2. Pre-allocated output arrays for patch loop
|
|
1658
|
+
3. Cached model reuse across patches (SVF/CSF computed only once)
|
|
1659
|
+
4. Efficient array extraction with pre-computed surface-to-grid mapping
|
|
1660
|
+
|
|
1661
|
+
Args:
|
|
1662
|
+
voxcity: VoxCity object
|
|
1663
|
+
df: pandas DataFrame with 'DNI' and 'DHI' columns, datetime-indexed
|
|
1664
|
+
lon: Longitude in degrees
|
|
1665
|
+
lat: Latitude in degrees
|
|
1666
|
+
tz: Timezone offset in hours
|
|
1667
|
+
direct_normal_irradiance_scaling: Scaling factor for DNI
|
|
1668
|
+
diffuse_irradiance_scaling: Scaling factor for DHI
|
|
1669
|
+
show_plot: Whether to display a matplotlib plot
|
|
1670
|
+
with_reflections: If True, use full RadiationModel with multi-bounce
|
|
1671
|
+
reflections for each timestep/patch. If False (default), use simple
|
|
1672
|
+
ray-tracing/SVF for faster computation.
|
|
1673
|
+
**kwargs: Additional parameters including:
|
|
1674
|
+
- computation_mask (np.ndarray): Optional 2D boolean mask for sub-area computation
|
|
1675
|
+
- start_time (str): Start time 'MM-DD HH:MM:SS' (default: '01-01 05:00:00')
|
|
1676
|
+
- end_time (str): End time 'MM-DD HH:MM:SS' (default: '01-01 20:00:00')
|
|
1677
|
+
- view_point_height (float): Observer height (default: 1.5)
|
|
1678
|
+
- use_sky_patches (bool): Use sky patch optimization (default: True)
|
|
1679
|
+
- sky_discretization (str): 'tregenza', 'reinhart', 'uniform', 'fibonacci'
|
|
1680
|
+
- progress_report (bool): Print progress (default: False)
|
|
1681
|
+
- colormap (str): Colormap name (default: 'magma')
|
|
1682
|
+
- n_reflection_steps (int): Number of reflection bounces when
|
|
1683
|
+
with_reflections=True (default: 2)
|
|
1684
|
+
|
|
1685
|
+
Returns:
|
|
1686
|
+
2D numpy array of cumulative irradiance (Wh/m²)
|
|
1687
|
+
"""
|
|
1688
|
+
import time
|
|
1689
|
+
from datetime import datetime
|
|
1690
|
+
import pytz
|
|
1691
|
+
|
|
1692
|
+
# Extract parameters that we pass explicitly (use pop to avoid duplicate kwargs)
|
|
1693
|
+
kwargs = kwargs.copy() # Don't modify the original
|
|
1694
|
+
computation_mask = kwargs.pop('computation_mask', None)
|
|
1695
|
+
view_point_height = kwargs.pop('view_point_height', 1.5)
|
|
1696
|
+
colormap = kwargs.pop('colormap', 'magma')
|
|
1697
|
+
start_time = kwargs.pop('start_time', '01-01 05:00:00')
|
|
1698
|
+
end_time = kwargs.pop('end_time', '01-01 20:00:00')
|
|
1699
|
+
progress_report = kwargs.pop('progress_report', False)
|
|
1700
|
+
use_sky_patches = kwargs.pop('use_sky_patches', True)
|
|
1701
|
+
sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
|
|
1702
|
+
|
|
1703
|
+
if df.empty:
|
|
1704
|
+
raise ValueError("No data in EPW dataframe.")
|
|
1705
|
+
|
|
1706
|
+
# Parse time range
|
|
1707
|
+
try:
|
|
1708
|
+
start_dt = datetime.strptime(start_time, '%m-%d %H:%M:%S')
|
|
1709
|
+
end_dt = datetime.strptime(end_time, '%m-%d %H:%M:%S')
|
|
1710
|
+
except ValueError as ve:
|
|
1711
|
+
raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
|
|
1712
|
+
|
|
1713
|
+
# Filter dataframe to period
|
|
1714
|
+
df = df.copy()
|
|
1715
|
+
df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
|
|
1716
|
+
start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
|
|
1717
|
+
end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
|
|
1718
|
+
start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
|
|
1719
|
+
end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
|
|
1720
|
+
|
|
1721
|
+
if start_hour <= end_hour:
|
|
1722
|
+
df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
|
|
1723
|
+
else:
|
|
1724
|
+
df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
|
|
1725
|
+
|
|
1726
|
+
if df_period.empty:
|
|
1727
|
+
raise ValueError("No EPW data in the specified period.")
|
|
1728
|
+
|
|
1729
|
+
# Localize and convert to UTC
|
|
1730
|
+
offset_minutes = int(tz * 60)
|
|
1731
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
1732
|
+
df_period_local = df_period.copy()
|
|
1733
|
+
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
1734
|
+
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
1735
|
+
|
|
1736
|
+
# Get solar positions
|
|
1737
|
+
solar_positions = _get_solar_positions_astral(df_period_utc.index, lon, lat)
|
|
1738
|
+
|
|
1739
|
+
# Compute base diffuse map (SVF-based for efficiency, or with reflections if requested)
|
|
1740
|
+
# Note: For cumulative with_reflections, we still use SVF-based base for diffuse sky contribution
|
|
1741
|
+
# The reflection component is computed per timestep when with_reflections=True
|
|
1742
|
+
diffuse_kwargs = kwargs.copy()
|
|
1743
|
+
diffuse_kwargs.update({'show_plot': False, 'obj_export': False})
|
|
1744
|
+
base_diffuse_map = get_diffuse_solar_irradiance_map(
|
|
1745
|
+
voxcity,
|
|
1746
|
+
diffuse_irradiance=1.0,
|
|
1747
|
+
with_reflections=False, # Always use SVF for base diffuse in cumulative mode
|
|
1748
|
+
**diffuse_kwargs
|
|
1749
|
+
)
|
|
1750
|
+
|
|
1751
|
+
voxel_data = voxcity.voxels.classes
|
|
1752
|
+
nx, ny, _ = voxel_data.shape
|
|
1753
|
+
cumulative_map = np.zeros((nx, ny))
|
|
1754
|
+
mask_map = np.ones((nx, ny), dtype=bool)
|
|
1755
|
+
|
|
1756
|
+
direct_kwargs = kwargs.copy()
|
|
1757
|
+
direct_kwargs.update({
|
|
1758
|
+
'show_plot': False,
|
|
1759
|
+
'view_point_height': view_point_height,
|
|
1760
|
+
'obj_export': False,
|
|
1761
|
+
'with_reflections': with_reflections # Pass through to direct/global map calls
|
|
1762
|
+
})
|
|
1763
|
+
|
|
1764
|
+
if use_sky_patches:
|
|
1765
|
+
# Use sky patch aggregation for efficiency
|
|
1766
|
+
from .sky import (
|
|
1767
|
+
generate_tregenza_patches,
|
|
1768
|
+
generate_reinhart_patches,
|
|
1769
|
+
generate_uniform_grid_patches,
|
|
1770
|
+
generate_fibonacci_patches,
|
|
1771
|
+
get_tregenza_patch_index
|
|
1772
|
+
)
|
|
1773
|
+
|
|
1774
|
+
t0 = time.perf_counter() if progress_report else 0
|
|
1775
|
+
|
|
1776
|
+
# Extract arrays
|
|
1777
|
+
azimuth_arr = solar_positions['azimuth'].to_numpy()
|
|
1778
|
+
elevation_arr = solar_positions['elevation'].to_numpy()
|
|
1779
|
+
dni_arr = df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling
|
|
1780
|
+
dhi_arr = df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling
|
|
1781
|
+
time_step_hours = kwargs.get('time_step_hours', 1.0)
|
|
1782
|
+
|
|
1783
|
+
# Generate sky patches
|
|
1784
|
+
if sky_discretization.lower() == 'tregenza':
|
|
1785
|
+
patches, directions, solid_angles = generate_tregenza_patches()
|
|
1786
|
+
elif sky_discretization.lower() == 'reinhart':
|
|
1787
|
+
mf = kwargs.get('reinhart_mf', kwargs.get('mf', 4))
|
|
1788
|
+
patches, directions, solid_angles = generate_reinhart_patches(mf=mf)
|
|
1789
|
+
elif sky_discretization.lower() == 'uniform':
|
|
1790
|
+
n_az = kwargs.get('sky_n_azimuth', kwargs.get('n_azimuth', 36))
|
|
1791
|
+
n_el = kwargs.get('sky_n_elevation', kwargs.get('n_elevation', 9))
|
|
1792
|
+
patches, directions, solid_angles = generate_uniform_grid_patches(n_az, n_el)
|
|
1793
|
+
elif sky_discretization.lower() == 'fibonacci':
|
|
1794
|
+
n_patches = kwargs.get('sky_n_patches', kwargs.get('n_patches', 145))
|
|
1795
|
+
patches, directions, solid_angles = generate_fibonacci_patches(n_patches=n_patches)
|
|
1796
|
+
else:
|
|
1797
|
+
raise ValueError(f"Unknown sky discretization method: {sky_discretization}")
|
|
1798
|
+
|
|
1799
|
+
n_patches = len(patches)
|
|
1800
|
+
n_timesteps = len(azimuth_arr)
|
|
1801
|
+
cumulative_dni = np.zeros(n_patches, dtype=np.float64)
|
|
1802
|
+
|
|
1803
|
+
# OPTIMIZATION: Vectorized DHI accumulation (only for positive values)
|
|
1804
|
+
# This replaces the loop-based accumulation
|
|
1805
|
+
valid_dhi_mask = dhi_arr > 0
|
|
1806
|
+
total_cumulative_dhi = np.sum(dhi_arr[valid_dhi_mask]) * time_step_hours
|
|
1807
|
+
|
|
1808
|
+
# DNI binning - loop is already fast (~7ms for 731 timesteps)
|
|
1809
|
+
# The loop is necessary because patch assignment depends on sun position
|
|
1810
|
+
for i in range(n_timesteps):
|
|
1811
|
+
elev = elevation_arr[i]
|
|
1812
|
+
if elev <= 0:
|
|
1813
|
+
continue
|
|
1814
|
+
|
|
1815
|
+
az = azimuth_arr[i]
|
|
1816
|
+
dni = dni_arr[i]
|
|
1817
|
+
|
|
1818
|
+
if dni <= 0:
|
|
1819
|
+
continue
|
|
1820
|
+
|
|
1821
|
+
patch_idx = int(get_tregenza_patch_index(float(az), float(elev)))
|
|
1822
|
+
if patch_idx >= 0 and patch_idx < n_patches:
|
|
1823
|
+
cumulative_dni[patch_idx] += dni * time_step_hours
|
|
1824
|
+
|
|
1825
|
+
active_mask = cumulative_dni > 0
|
|
1826
|
+
n_active = int(np.sum(active_mask))
|
|
1827
|
+
|
|
1828
|
+
if progress_report:
|
|
1829
|
+
bin_time = time.perf_counter() - t0
|
|
1830
|
+
print(f"Sky patch optimization: {n_timesteps} timesteps -> {n_active} active patches ({sky_discretization})")
|
|
1831
|
+
print(f" Sun position binning: {bin_time:.3f}s")
|
|
1832
|
+
print(f" Total cumulative DHI: {total_cumulative_dhi:.1f} Wh/m²")
|
|
1833
|
+
if with_reflections:
|
|
1834
|
+
print(" Using RadiationModel with multi-bounce reflections")
|
|
1835
|
+
|
|
1836
|
+
# Diffuse component
|
|
1837
|
+
cumulative_diffuse = base_diffuse_map * total_cumulative_dhi
|
|
1838
|
+
cumulative_map += np.nan_to_num(cumulative_diffuse, nan=0.0)
|
|
1839
|
+
mask_map &= ~np.isnan(cumulative_diffuse)
|
|
1840
|
+
|
|
1841
|
+
# Direct component - loop over active patches
|
|
1842
|
+
# When with_reflections=True, use get_global_solar_irradiance_map to include
|
|
1843
|
+
# reflections for each patch direction
|
|
1844
|
+
active_indices = np.where(active_mask)[0]
|
|
1845
|
+
|
|
1846
|
+
# OPTIMIZATION: Pre-warm the model (ensures JIT compilation is done)
|
|
1847
|
+
if with_reflections and len(active_indices) > 0:
|
|
1848
|
+
# Ensure model is created and cached before timing
|
|
1849
|
+
n_reflection_steps = kwargs.get('n_reflection_steps', 2)
|
|
1850
|
+
_ = _get_or_create_radiation_model(
|
|
1851
|
+
voxcity,
|
|
1852
|
+
n_reflection_steps=n_reflection_steps,
|
|
1853
|
+
progress_report=progress_report
|
|
1854
|
+
)
|
|
1855
|
+
|
|
1856
|
+
if progress_report:
|
|
1857
|
+
t_patch_start = time.perf_counter()
|
|
1858
|
+
|
|
1859
|
+
for i, patch_idx in enumerate(active_indices):
|
|
1860
|
+
az_deg = patches[patch_idx, 0]
|
|
1861
|
+
el_deg = patches[patch_idx, 1]
|
|
1862
|
+
cumulative_dni_patch = cumulative_dni[patch_idx]
|
|
1863
|
+
|
|
1864
|
+
if with_reflections:
|
|
1865
|
+
# Use full RadiationModel: compute direct + reflected for this direction
|
|
1866
|
+
# We set diffuse_irradiance=0 since we handle diffuse separately
|
|
1867
|
+
direct_map, _, reflected_map = _compute_ground_irradiance_with_reflections(
|
|
1868
|
+
voxcity=voxcity,
|
|
1869
|
+
azimuth_degrees_ori=az_deg,
|
|
1870
|
+
elevation_degrees=el_deg,
|
|
1871
|
+
direct_normal_irradiance=1.0,
|
|
1872
|
+
diffuse_irradiance=0.0,
|
|
1873
|
+
view_point_height=view_point_height,
|
|
1874
|
+
**kwargs
|
|
1875
|
+
)
|
|
1876
|
+
# Include reflections in patch contribution
|
|
1877
|
+
patch_contribution = (direct_map + np.nan_to_num(reflected_map, nan=0.0)) * cumulative_dni_patch
|
|
1878
|
+
else:
|
|
1879
|
+
# Simple ray tracing (no reflections)
|
|
1880
|
+
direct_map = get_direct_solar_irradiance_map(
|
|
1881
|
+
voxcity,
|
|
1882
|
+
az_deg,
|
|
1883
|
+
el_deg,
|
|
1884
|
+
direct_normal_irradiance=1.0,
|
|
1885
|
+
**direct_kwargs
|
|
1886
|
+
)
|
|
1887
|
+
patch_contribution = direct_map * cumulative_dni_patch
|
|
1888
|
+
|
|
1889
|
+
mask_map &= ~np.isnan(patch_contribution)
|
|
1890
|
+
cumulative_map += np.nan_to_num(patch_contribution, nan=0.0)
|
|
1891
|
+
|
|
1892
|
+
if progress_report and ((i + 1) % max(1, len(active_indices) // 10) == 0 or i == len(active_indices) - 1):
|
|
1893
|
+
elapsed = time.perf_counter() - t_patch_start
|
|
1894
|
+
pct = (i + 1) * 100.0 / len(active_indices)
|
|
1895
|
+
avg_per_patch = elapsed / (i + 1)
|
|
1896
|
+
eta = avg_per_patch * (len(active_indices) - i - 1)
|
|
1897
|
+
print(f" Patch {i+1}/{len(active_indices)} ({pct:.1f}%) - elapsed: {elapsed:.1f}s, ETA: {eta:.1f}s, avg: {avg_per_patch*1000:.1f}ms/patch")
|
|
1898
|
+
|
|
1899
|
+
if progress_report:
|
|
1900
|
+
total_patch_time = time.perf_counter() - t_patch_start
|
|
1901
|
+
print(f" Total patch processing: {total_patch_time:.2f}s ({n_active} patches)")
|
|
1902
|
+
|
|
1903
|
+
else:
|
|
1904
|
+
# Per-timestep path
|
|
1905
|
+
if progress_report and with_reflections:
|
|
1906
|
+
print(" Using RadiationModel with multi-bounce reflections (per-timestep)")
|
|
1907
|
+
|
|
1908
|
+
for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
|
|
1909
|
+
DNI = float(row['DNI']) * direct_normal_irradiance_scaling
|
|
1910
|
+
DHI = float(row['DHI']) * diffuse_irradiance_scaling
|
|
1911
|
+
|
|
1912
|
+
solpos = solar_positions.loc[time_utc]
|
|
1913
|
+
azimuth_degrees = float(solpos['azimuth'])
|
|
1914
|
+
elevation_degrees_val = float(solpos['elevation'])
|
|
1915
|
+
|
|
1916
|
+
if with_reflections:
|
|
1917
|
+
# Use full RadiationModel for this timestep
|
|
1918
|
+
direct_map, diffuse_map_ts, reflected_map = _compute_ground_irradiance_with_reflections(
|
|
1919
|
+
voxcity=voxcity,
|
|
1920
|
+
azimuth_degrees_ori=azimuth_degrees,
|
|
1921
|
+
elevation_degrees=elevation_degrees_val,
|
|
1922
|
+
direct_normal_irradiance=DNI,
|
|
1923
|
+
diffuse_irradiance=DHI,
|
|
1924
|
+
view_point_height=view_point_height,
|
|
1925
|
+
**kwargs
|
|
1926
|
+
)
|
|
1927
|
+
# Combine all components
|
|
1928
|
+
combined = (np.nan_to_num(direct_map, nan=0.0) +
|
|
1929
|
+
np.nan_to_num(diffuse_map_ts, nan=0.0) +
|
|
1930
|
+
np.nan_to_num(reflected_map, nan=0.0))
|
|
1931
|
+
mask_map &= ~np.isnan(direct_map)
|
|
1932
|
+
else:
|
|
1933
|
+
# Simple ray tracing (no reflections)
|
|
1934
|
+
direct_map = get_direct_solar_irradiance_map(
|
|
1935
|
+
voxcity,
|
|
1936
|
+
azimuth_degrees,
|
|
1937
|
+
elevation_degrees_val,
|
|
1938
|
+
direct_normal_irradiance=DNI,
|
|
1939
|
+
**direct_kwargs # with_reflections already in direct_kwargs
|
|
1940
|
+
)
|
|
1941
|
+
|
|
1942
|
+
diffuse_contrib = base_diffuse_map * DHI
|
|
1943
|
+
combined = np.nan_to_num(direct_map, nan=0.0) + np.nan_to_num(diffuse_contrib, nan=0.0)
|
|
1944
|
+
mask_map &= ~np.isnan(direct_map) & ~np.isnan(diffuse_contrib)
|
|
1945
|
+
|
|
1946
|
+
cumulative_map += combined
|
|
1947
|
+
|
|
1948
|
+
if progress_report and (idx + 1) % max(1, len(df_period_utc) // 10) == 0:
|
|
1949
|
+
pct = (idx + 1) * 100.0 / len(df_period_utc)
|
|
1950
|
+
print(f" Timestep {idx+1}/{len(df_period_utc)} ({pct:.1f}%)")
|
|
1951
|
+
|
|
1952
|
+
# Apply mask for plotting
|
|
1953
|
+
cumulative_map = np.where(mask_map, cumulative_map, np.nan)
|
|
1954
|
+
|
|
1955
|
+
# Apply computation mask if provided
|
|
1956
|
+
if computation_mask is not None:
|
|
1957
|
+
# Handle different shape orientations
|
|
1958
|
+
if computation_mask.shape == cumulative_map.shape:
|
|
1959
|
+
cumulative_map = np.where(np.flipud(computation_mask), cumulative_map, np.nan)
|
|
1960
|
+
elif computation_mask.T.shape == cumulative_map.shape:
|
|
1961
|
+
cumulative_map = np.where(np.flipud(computation_mask.T), cumulative_map, np.nan)
|
|
1962
|
+
|
|
1963
|
+
if show_plot:
|
|
1964
|
+
vmax = kwargs.get('vmax', float(np.nanmax(cumulative_map)) if not np.all(np.isnan(cumulative_map)) else 1.0)
|
|
1965
|
+
vmin = kwargs.get('vmin', 0.0)
|
|
1966
|
+
try:
|
|
1967
|
+
import matplotlib.pyplot as plt
|
|
1968
|
+
cmap = plt.cm.get_cmap(colormap).copy()
|
|
1969
|
+
cmap.set_bad(color='lightgray')
|
|
1970
|
+
plt.figure(figsize=(10, 8))
|
|
1971
|
+
plt.imshow(cumulative_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
1972
|
+
plt.colorbar(label='Cumulative Global Solar Irradiance (Wh/m²)')
|
|
1973
|
+
plt.axis('off')
|
|
1974
|
+
plt.show()
|
|
1975
|
+
except ImportError:
|
|
1976
|
+
pass
|
|
1977
|
+
|
|
1978
|
+
return cumulative_map
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
def get_building_solar_irradiance(
|
|
1982
|
+
voxcity,
|
|
1983
|
+
building_svf_mesh=None,
|
|
1984
|
+
azimuth_degrees_ori: float = None,
|
|
1985
|
+
elevation_degrees: float = None,
|
|
1986
|
+
direct_normal_irradiance: float = None,
|
|
1987
|
+
diffuse_irradiance: float = None,
|
|
1988
|
+
**kwargs
|
|
1989
|
+
):
|
|
1990
|
+
"""
|
|
1991
|
+
GPU-accelerated building surface solar irradiance computation.
|
|
1992
|
+
|
|
1993
|
+
This function matches the signature of voxcity.simulator.solar.get_building_solar_irradiance
|
|
1994
|
+
using Taichi GPU acceleration with multi-bounce reflections.
|
|
1995
|
+
|
|
1996
|
+
Uses cached RadiationModel to avoid recomputing SVF/CSF matrices for each timestep.
|
|
1997
|
+
|
|
1998
|
+
Args:
|
|
1999
|
+
voxcity: VoxCity object
|
|
2000
|
+
building_svf_mesh: Pre-computed mesh with SVF values (optional, for VoxCity API compatibility)
|
|
2001
|
+
If provided, SVF values from mesh metadata will be used.
|
|
2002
|
+
If None, SVF will be computed internally.
|
|
2003
|
+
azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
|
|
2004
|
+
elevation_degrees: Solar elevation in degrees above horizon
|
|
2005
|
+
direct_normal_irradiance: DNI in W/m²
|
|
2006
|
+
diffuse_irradiance: DHI in W/m²
|
|
2007
|
+
**kwargs: Additional parameters including:
|
|
2008
|
+
- with_reflections (bool): Enable multi-bounce surface reflections (default: False).
|
|
2009
|
+
Set to True for more accurate results but slower computation.
|
|
2010
|
+
- n_reflection_steps (int): Number of reflection bounces when with_reflections=True (default: 2)
|
|
2011
|
+
- tree_k (float): Tree extinction coefficient (default: 0.6)
|
|
2012
|
+
- building_class_id (int): Building voxel class code (default: -3)
|
|
2013
|
+
- computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
|
|
2014
|
+
Faces whose XY centroid falls outside the masked region are set to NaN.
|
|
2015
|
+
Useful for focusing analysis on a sub-region of the domain.
|
|
2016
|
+
- progress_report (bool): Print progress (default: False)
|
|
2017
|
+
- colormap (str): Colormap name (default: 'magma')
|
|
2018
|
+
- obj_export (bool): Export mesh to OBJ (default: False)
|
|
2019
|
+
|
|
2020
|
+
Returns:
|
|
2021
|
+
Trimesh object with irradiance values in metadata
|
|
2022
|
+
"""
|
|
2023
|
+
# Handle positional argument order from VoxCity API:
|
|
2024
|
+
# VoxCity: get_building_solar_irradiance(voxcity, building_svf_mesh, azimuth, elevation, dni, dhi, **kwargs)
|
|
2025
|
+
# If building_svf_mesh is a number, assume old GPU-only API call where second arg is azimuth
|
|
2026
|
+
if isinstance(building_svf_mesh, (int, float)):
|
|
2027
|
+
# Old API: get_building_solar_irradiance(voxcity, azimuth, elevation, dni, dhi, ...)
|
|
2028
|
+
diffuse_irradiance = direct_normal_irradiance
|
|
2029
|
+
direct_normal_irradiance = elevation_degrees
|
|
2030
|
+
elevation_degrees = azimuth_degrees_ori
|
|
2031
|
+
azimuth_degrees_ori = building_svf_mesh
|
|
2032
|
+
building_svf_mesh = None
|
|
2033
|
+
|
|
2034
|
+
voxel_data = voxcity.voxels.classes
|
|
2035
|
+
meshsize = voxcity.voxels.meta.meshsize
|
|
2036
|
+
building_id_grid = voxcity.buildings.ids
|
|
2037
|
+
ny_vc, nx_vc, nz = voxel_data.shape
|
|
2038
|
+
|
|
2039
|
+
# Extract parameters that we pass explicitly (to avoid duplicate kwargs error)
|
|
2040
|
+
progress_report = kwargs.pop('progress_report', False)
|
|
2041
|
+
building_class_id = kwargs.pop('building_class_id', -3)
|
|
2042
|
+
n_reflection_steps = kwargs.pop('n_reflection_steps', 2)
|
|
2043
|
+
colormap = kwargs.pop('colormap', 'magma')
|
|
2044
|
+
with_reflections = kwargs.pop('with_reflections', False) # Default False for speed; set True for multi-bounce reflections
|
|
2045
|
+
computation_mask = kwargs.pop('computation_mask', None) # 2D boolean mask for sub-area filtering
|
|
2046
|
+
|
|
2047
|
+
# If with_reflections=False, set n_reflection_steps=0 to skip expensive SVF matrix computation
|
|
2048
|
+
if not with_reflections:
|
|
2049
|
+
n_reflection_steps = 0
|
|
2050
|
+
|
|
2051
|
+
# Get cached or create new RadiationModel (SVF/CSF computed only once)
|
|
2052
|
+
model, is_building_surf = _get_or_create_building_radiation_model(
|
|
2053
|
+
voxcity,
|
|
2054
|
+
n_reflection_steps=n_reflection_steps,
|
|
2055
|
+
progress_report=progress_report,
|
|
2056
|
+
building_class_id=building_class_id,
|
|
2057
|
+
**kwargs
|
|
2058
|
+
)
|
|
2059
|
+
|
|
2060
|
+
# Set solar position for this timestep
|
|
2061
|
+
azimuth_degrees = 180 - azimuth_degrees_ori
|
|
2062
|
+
azimuth_radians = np.deg2rad(azimuth_degrees)
|
|
2063
|
+
elevation_radians = np.deg2rad(elevation_degrees)
|
|
2064
|
+
|
|
2065
|
+
sun_dir_x = np.cos(elevation_radians) * np.cos(azimuth_radians)
|
|
2066
|
+
sun_dir_y = np.cos(elevation_radians) * np.sin(azimuth_radians)
|
|
2067
|
+
sun_dir_z = np.sin(elevation_radians)
|
|
2068
|
+
|
|
2069
|
+
# Set sun direction and cos_zenith directly on the SolarCalculator fields
|
|
2070
|
+
model.solar_calc.sun_direction[None] = (sun_dir_x, sun_dir_y, sun_dir_z)
|
|
2071
|
+
model.solar_calc.cos_zenith[None] = np.sin(elevation_radians)
|
|
2072
|
+
model.solar_calc.sun_up[None] = 1 if elevation_degrees > 0 else 0
|
|
2073
|
+
|
|
2074
|
+
# Compute shortwave radiation (uses cached SVF/CSF matrices)
|
|
2075
|
+
model.compute_shortwave_radiation(
|
|
2076
|
+
sw_direct=direct_normal_irradiance,
|
|
2077
|
+
sw_diffuse=diffuse_irradiance
|
|
2078
|
+
)
|
|
2079
|
+
|
|
2080
|
+
# Extract surface irradiance from palm_solar model
|
|
2081
|
+
# Note: Use to_numpy() without slicing - slicing is VERY slow on Taichi arrays
|
|
2082
|
+
# The mesh_to_surface_idx will handle extracting only the values we need
|
|
2083
|
+
n_surfaces = model.surfaces.count
|
|
2084
|
+
sw_in_direct_all = model.surfaces.sw_in_direct.to_numpy()
|
|
2085
|
+
sw_in_diffuse_all = model.surfaces.sw_in_diffuse.to_numpy()
|
|
2086
|
+
|
|
2087
|
+
if hasattr(model.surfaces, 'sw_in_reflected'):
|
|
2088
|
+
sw_in_reflected_all = model.surfaces.sw_in_reflected.to_numpy()
|
|
2089
|
+
else:
|
|
2090
|
+
sw_in_reflected_all = np.zeros_like(sw_in_direct_all)
|
|
2091
|
+
|
|
2092
|
+
total_sw_all = sw_in_direct_all + sw_in_diffuse_all + sw_in_reflected_all
|
|
2093
|
+
|
|
2094
|
+
# Get building indices from cache (avoids np.where every time)
|
|
2095
|
+
bldg_indices = _building_radiation_model_cache.bldg_indices if _building_radiation_model_cache else np.where(is_building_surf)[0]
|
|
2096
|
+
if progress_report:
|
|
2097
|
+
print(f" palm_solar surfaces: {n_surfaces}, building surfaces: {len(bldg_indices)}")
|
|
2098
|
+
|
|
2099
|
+
# Get or create building mesh - use cached mesh if available (expensive: ~2.4s)
|
|
2100
|
+
cache = _building_radiation_model_cache
|
|
2101
|
+
if building_svf_mesh is not None:
|
|
2102
|
+
# Use provided mesh directly (no copy needed - we only update metadata)
|
|
2103
|
+
building_mesh = building_svf_mesh
|
|
2104
|
+
# Extract SVF from mesh metadata if available
|
|
2105
|
+
if hasattr(building_mesh, 'metadata') and 'svf' in building_mesh.metadata:
|
|
2106
|
+
face_svf = building_mesh.metadata['svf']
|
|
2107
|
+
else:
|
|
2108
|
+
face_svf = None
|
|
2109
|
+
# Cache mesh geometry on first use (avoids recomputing triangles_center/face_normals)
|
|
2110
|
+
if cache is not None and cache.mesh_face_centers is None:
|
|
2111
|
+
cache.mesh_face_centers = building_mesh.triangles_center.copy()
|
|
2112
|
+
cache.mesh_face_normals = building_mesh.face_normals.copy()
|
|
2113
|
+
elif cache is not None and cache.cached_building_mesh is not None:
|
|
2114
|
+
# Use cached mesh (avoids expensive ~2.4s mesh creation each call)
|
|
2115
|
+
building_mesh = cache.cached_building_mesh
|
|
2116
|
+
face_svf = None
|
|
2117
|
+
else:
|
|
2118
|
+
# Create mesh for building surfaces (expensive, ~2.4s)
|
|
2119
|
+
try:
|
|
2120
|
+
from voxcity.geoprocessor.mesh import create_voxel_mesh
|
|
2121
|
+
if progress_report:
|
|
2122
|
+
print(" Creating building mesh (first call, will be cached)...")
|
|
2123
|
+
building_mesh = create_voxel_mesh(
|
|
2124
|
+
voxel_data,
|
|
2125
|
+
building_class_id,
|
|
2126
|
+
meshsize,
|
|
2127
|
+
building_id_grid=building_id_grid,
|
|
2128
|
+
mesh_type='open_air'
|
|
2129
|
+
)
|
|
2130
|
+
if building_mesh is None or len(building_mesh.faces) == 0:
|
|
2131
|
+
print("No building surfaces found.")
|
|
2132
|
+
return None
|
|
2133
|
+
# Cache the mesh for future calls
|
|
2134
|
+
if cache is not None:
|
|
2135
|
+
cache.cached_building_mesh = building_mesh
|
|
2136
|
+
if progress_report:
|
|
2137
|
+
print(f" Cached building mesh with {len(building_mesh.faces)} faces")
|
|
2138
|
+
except ImportError:
|
|
2139
|
+
print("VoxCity geoprocessor.mesh required for mesh creation")
|
|
2140
|
+
return None
|
|
2141
|
+
face_svf = None
|
|
2142
|
+
|
|
2143
|
+
n_mesh_faces = len(building_mesh.faces)
|
|
2144
|
+
|
|
2145
|
+
# Map palm_solar building surface values to building mesh faces.
|
|
2146
|
+
# Use cached mapping if available (avoids expensive KDTree query every call)
|
|
2147
|
+
if len(bldg_indices) > 0:
|
|
2148
|
+
# Check if we have cached mesh_to_surface_idx mapping
|
|
2149
|
+
if (cache is not None and
|
|
2150
|
+
cache.mesh_to_surface_idx is not None and
|
|
2151
|
+
len(cache.mesh_to_surface_idx) == n_mesh_faces):
|
|
2152
|
+
# Use cached direct mapping: mesh face -> surface index
|
|
2153
|
+
mesh_to_surface_idx = cache.mesh_to_surface_idx
|
|
2154
|
+
|
|
2155
|
+
# Fast vectorized indexing using pre-computed mapping
|
|
2156
|
+
sw_in_direct = sw_in_direct_all[mesh_to_surface_idx]
|
|
2157
|
+
sw_in_diffuse = sw_in_diffuse_all[mesh_to_surface_idx]
|
|
2158
|
+
sw_in_reflected = sw_in_reflected_all[mesh_to_surface_idx]
|
|
2159
|
+
total_sw = total_sw_all[mesh_to_surface_idx]
|
|
2160
|
+
else:
|
|
2161
|
+
# Need to compute mapping (first call with this mesh)
|
|
2162
|
+
from scipy.spatial import cKDTree
|
|
2163
|
+
|
|
2164
|
+
# Get surface centers (only needed for KDTree building)
|
|
2165
|
+
surf_centers_all = model.surfaces.center.to_numpy()[:n_surfaces]
|
|
2166
|
+
bldg_centers = surf_centers_all[bldg_indices]
|
|
2167
|
+
|
|
2168
|
+
# Use cached geometry if available, otherwise compute from mesh
|
|
2169
|
+
if cache is not None and cache.mesh_face_centers is not None:
|
|
2170
|
+
mesh_face_centers = cache.mesh_face_centers
|
|
2171
|
+
else:
|
|
2172
|
+
mesh_face_centers = building_mesh.triangles_center
|
|
2173
|
+
if cache is not None:
|
|
2174
|
+
cache.mesh_face_centers = mesh_face_centers.copy()
|
|
2175
|
+
cache.mesh_face_normals = building_mesh.face_normals.copy()
|
|
2176
|
+
|
|
2177
|
+
if progress_report:
|
|
2178
|
+
print(f" Computing mesh-to-surface mapping (first call)...")
|
|
2179
|
+
print(f" palm_solar bldg centers: x=[{bldg_centers[:,0].min():.1f}, {bldg_centers[:,0].max():.1f}], "
|
|
2180
|
+
f"y=[{bldg_centers[:,1].min():.1f}, {bldg_centers[:,1].max():.1f}], "
|
|
2181
|
+
f"z=[{bldg_centers[:,2].min():.1f}, {bldg_centers[:,2].max():.1f}]")
|
|
2182
|
+
print(f" mesh face centers: x=[{mesh_face_centers[:,0].min():.1f}, {mesh_face_centers[:,0].max():.1f}], "
|
|
2183
|
+
f"y=[{mesh_face_centers[:,1].min():.1f}, {mesh_face_centers[:,1].max():.1f}], "
|
|
2184
|
+
f"z=[{mesh_face_centers[:,2].min():.1f}, {mesh_face_centers[:,2].max():.1f}]")
|
|
2185
|
+
|
|
2186
|
+
tree = cKDTree(bldg_centers)
|
|
2187
|
+
distances, nearest_idx = tree.query(mesh_face_centers, k=1)
|
|
2188
|
+
|
|
2189
|
+
if progress_report:
|
|
2190
|
+
print(f" KDTree match distances: min={distances.min():.2f}, mean={distances.mean():.2f}, max={distances.max():.2f}")
|
|
2191
|
+
|
|
2192
|
+
# Create direct mapping: mesh face -> surface index
|
|
2193
|
+
# This combines bldg_indices[nearest_idx] into a single array
|
|
2194
|
+
mesh_to_surface_idx = bldg_indices[nearest_idx]
|
|
2195
|
+
|
|
2196
|
+
# Cache the mapping for subsequent calls
|
|
2197
|
+
if cache is not None:
|
|
2198
|
+
cache.mesh_to_surface_idx = mesh_to_surface_idx
|
|
2199
|
+
cache.bldg_indices = bldg_indices
|
|
2200
|
+
if progress_report:
|
|
2201
|
+
print(f" Cached mesh-to-surface mapping for {n_mesh_faces} faces")
|
|
2202
|
+
|
|
2203
|
+
# Map irradiance arrays
|
|
2204
|
+
sw_in_direct = sw_in_direct_all[mesh_to_surface_idx]
|
|
2205
|
+
sw_in_diffuse = sw_in_diffuse_all[mesh_to_surface_idx]
|
|
2206
|
+
sw_in_reflected = sw_in_reflected_all[mesh_to_surface_idx]
|
|
2207
|
+
total_sw = total_sw_all[mesh_to_surface_idx]
|
|
2208
|
+
else:
|
|
2209
|
+
# Fallback: no building surfaces in palm_solar model (edge case)
|
|
2210
|
+
sw_in_direct = np.zeros(n_mesh_faces, dtype=np.float32)
|
|
2211
|
+
sw_in_diffuse = np.zeros(n_mesh_faces, dtype=np.float32)
|
|
2212
|
+
sw_in_reflected = np.zeros(n_mesh_faces, dtype=np.float32)
|
|
2213
|
+
total_sw = np.zeros(n_mesh_faces, dtype=np.float32)
|
|
2214
|
+
|
|
2215
|
+
# -------------------------------------------------------------------------
|
|
2216
|
+
# Set vertical faces on domain perimeter to NaN (matching VoxCity behavior)
|
|
2217
|
+
# Use cached boundary mask if available to avoid expensive mesh ops
|
|
2218
|
+
# -------------------------------------------------------------------------
|
|
2219
|
+
cache = _building_radiation_model_cache
|
|
2220
|
+
if cache is not None and cache.boundary_mask is not None and len(cache.boundary_mask) == n_mesh_faces:
|
|
2221
|
+
# Use cached boundary mask
|
|
2222
|
+
is_boundary_vertical = cache.boundary_mask
|
|
2223
|
+
else:
|
|
2224
|
+
# Compute and cache boundary mask (first call)
|
|
2225
|
+
ny_vc, nx_vc, nz = voxel_data.shape
|
|
2226
|
+
grid_bounds_real = np.array([
|
|
2227
|
+
[0.0, 0.0, 0.0],
|
|
2228
|
+
[nx_vc * meshsize, ny_vc * meshsize, nz * meshsize]
|
|
2229
|
+
], dtype=np.float64)
|
|
2230
|
+
boundary_epsilon = meshsize * 0.05
|
|
2231
|
+
|
|
2232
|
+
# Use cached geometry if available, otherwise compute and cache
|
|
2233
|
+
if cache is not None and cache.mesh_face_centers is not None:
|
|
2234
|
+
mesh_face_centers = cache.mesh_face_centers
|
|
2235
|
+
mesh_face_normals = cache.mesh_face_normals
|
|
2236
|
+
else:
|
|
2237
|
+
mesh_face_centers = building_mesh.triangles_center
|
|
2238
|
+
mesh_face_normals = building_mesh.face_normals
|
|
2239
|
+
# Cache geometry for future calls
|
|
2240
|
+
if cache is not None:
|
|
2241
|
+
cache.mesh_face_centers = mesh_face_centers
|
|
2242
|
+
cache.mesh_face_normals = mesh_face_normals
|
|
2243
|
+
|
|
2244
|
+
# Detect vertical faces (normal z-component near zero)
|
|
2245
|
+
is_vertical = np.abs(mesh_face_normals[:, 2]) < 0.01
|
|
2246
|
+
|
|
2247
|
+
# Detect faces on domain boundary
|
|
2248
|
+
on_x_min = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[0, 0]) < boundary_epsilon
|
|
2249
|
+
on_y_min = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[0, 1]) < boundary_epsilon
|
|
2250
|
+
on_x_max = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[1, 0]) < boundary_epsilon
|
|
2251
|
+
on_y_max = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[1, 1]) < boundary_epsilon
|
|
2252
|
+
|
|
2253
|
+
is_boundary_vertical = is_vertical & (on_x_min | on_y_min | on_x_max | on_y_max)
|
|
2254
|
+
|
|
2255
|
+
# Cache the boundary mask
|
|
2256
|
+
if cache is not None:
|
|
2257
|
+
cache.boundary_mask = is_boundary_vertical
|
|
2258
|
+
|
|
2259
|
+
# Set boundary vertical faces to NaN using np.where (avoids expensive astype conversion)
|
|
2260
|
+
sw_in_direct = np.where(is_boundary_vertical, np.nan, sw_in_direct)
|
|
2261
|
+
sw_in_diffuse = np.where(is_boundary_vertical, np.nan, sw_in_diffuse)
|
|
2262
|
+
sw_in_reflected = np.where(is_boundary_vertical, np.nan, sw_in_reflected)
|
|
2263
|
+
total_sw = np.where(is_boundary_vertical, np.nan, total_sw)
|
|
2264
|
+
|
|
2265
|
+
if progress_report:
|
|
2266
|
+
n_boundary = np.sum(is_boundary_vertical)
|
|
2267
|
+
print(f" Boundary vertical faces set to NaN: {n_boundary}/{n_mesh_faces} ({100*n_boundary/n_mesh_faces:.1f}%)")
|
|
2268
|
+
|
|
2269
|
+
# -------------------------------------------------------------------------
|
|
2270
|
+
# Apply computation_mask: set faces outside masked XY region to NaN
|
|
2271
|
+
# -------------------------------------------------------------------------
|
|
2272
|
+
if computation_mask is not None:
|
|
2273
|
+
# Get mesh face centers (use cached if available)
|
|
2274
|
+
if cache is not None and cache.mesh_face_centers is not None:
|
|
2275
|
+
mesh_face_centers = cache.mesh_face_centers
|
|
2276
|
+
else:
|
|
2277
|
+
mesh_face_centers = building_mesh.triangles_center
|
|
2278
|
+
|
|
2279
|
+
# Convert face XY positions to grid indices
|
|
2280
|
+
face_x = mesh_face_centers[:, 0]
|
|
2281
|
+
face_y = mesh_face_centers[:, 1]
|
|
2282
|
+
|
|
2283
|
+
# Map to grid indices (face coords are in real-world units: 0 to nx*meshsize)
|
|
2284
|
+
grid_i = (face_y / meshsize).astype(int) # y -> i (row)
|
|
2285
|
+
grid_j = (face_x / meshsize).astype(int) # x -> j (col)
|
|
2286
|
+
|
|
2287
|
+
# Handle mask shape orientation
|
|
2288
|
+
if computation_mask.shape == (ny_vc, nx_vc):
|
|
2289
|
+
mask_shape = computation_mask.shape
|
|
2290
|
+
elif computation_mask.T.shape == (ny_vc, nx_vc):
|
|
2291
|
+
computation_mask = computation_mask.T
|
|
2292
|
+
mask_shape = computation_mask.shape
|
|
2293
|
+
else:
|
|
2294
|
+
# Best effort: assume it matches voxel grid
|
|
2295
|
+
mask_shape = computation_mask.shape
|
|
2296
|
+
|
|
2297
|
+
# Clamp indices to valid range
|
|
2298
|
+
grid_i = np.clip(grid_i, 0, mask_shape[0] - 1)
|
|
2299
|
+
grid_j = np.clip(grid_j, 0, mask_shape[1] - 1)
|
|
2300
|
+
|
|
2301
|
+
# Determine which faces are outside the mask
|
|
2302
|
+
# Flip mask to match coordinate system (same as ground-level functions)
|
|
2303
|
+
flipped_mask = np.flipud(computation_mask)
|
|
2304
|
+
outside_mask = ~flipped_mask[grid_i, grid_j]
|
|
2305
|
+
|
|
2306
|
+
# Set values outside mask to NaN
|
|
2307
|
+
sw_in_direct = np.where(outside_mask, np.nan, sw_in_direct)
|
|
2308
|
+
sw_in_diffuse = np.where(outside_mask, np.nan, sw_in_diffuse)
|
|
2309
|
+
sw_in_reflected = np.where(outside_mask, np.nan, sw_in_reflected)
|
|
2310
|
+
total_sw = np.where(outside_mask, np.nan, total_sw)
|
|
2311
|
+
|
|
2312
|
+
if progress_report:
|
|
2313
|
+
n_outside = np.sum(outside_mask)
|
|
2314
|
+
print(f" Faces outside computation_mask set to NaN: {n_outside}/{n_mesh_faces} ({100*n_outside/n_mesh_faces:.1f}%)")
|
|
2315
|
+
|
|
2316
|
+
building_mesh.metadata = {
|
|
2317
|
+
'irradiance_direct': sw_in_direct,
|
|
2318
|
+
'irradiance_diffuse': sw_in_diffuse,
|
|
2319
|
+
'irradiance_reflected': sw_in_reflected,
|
|
2320
|
+
'irradiance_total': total_sw,
|
|
2321
|
+
'direct': sw_in_direct, # VoxCity API compatibility alias
|
|
2322
|
+
'diffuse': sw_in_diffuse, # VoxCity API compatibility alias
|
|
2323
|
+
'global': total_sw, # VoxCity API compatibility alias
|
|
2324
|
+
}
|
|
2325
|
+
if face_svf is not None:
|
|
2326
|
+
building_mesh.metadata['svf'] = face_svf
|
|
2327
|
+
|
|
2328
|
+
if kwargs.get('obj_export', False):
|
|
2329
|
+
import os
|
|
2330
|
+
output_dir = kwargs.get('output_directory', 'output')
|
|
2331
|
+
output_file_name = kwargs.get('output_file_name', 'building_solar_irradiance')
|
|
2332
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
2333
|
+
try:
|
|
2334
|
+
building_mesh.export(f"{output_dir}/{output_file_name}.obj")
|
|
2335
|
+
if progress_report:
|
|
2336
|
+
print(f"Exported to {output_dir}/{output_file_name}.obj")
|
|
2337
|
+
except Exception as e:
|
|
2338
|
+
print(f"Error exporting mesh: {e}")
|
|
2339
|
+
|
|
2340
|
+
return building_mesh
|
|
2341
|
+
|
|
2342
|
+
|
|
2343
|
+
def get_cumulative_building_solar_irradiance(
|
|
2344
|
+
voxcity,
|
|
2345
|
+
building_svf_mesh,
|
|
2346
|
+
weather_df,
|
|
2347
|
+
lon: float,
|
|
2348
|
+
lat: float,
|
|
2349
|
+
tz: float,
|
|
2350
|
+
direct_normal_irradiance_scaling: float = 1.0,
|
|
2351
|
+
diffuse_irradiance_scaling: float = 1.0,
|
|
2352
|
+
**kwargs
|
|
2353
|
+
):
|
|
2354
|
+
"""
|
|
2355
|
+
GPU-accelerated cumulative solar irradiance on building surfaces.
|
|
2356
|
+
|
|
2357
|
+
This function matches the signature of voxcity.simulator.solar.get_cumulative_building_solar_irradiance
|
|
2358
|
+
using Taichi GPU acceleration.
|
|
2359
|
+
|
|
2360
|
+
Integrates solar irradiance over a time period from weather data,
|
|
2361
|
+
returning cumulative Wh/m² on building faces.
|
|
2362
|
+
|
|
2363
|
+
Args:
|
|
2364
|
+
voxcity: VoxCity object
|
|
2365
|
+
building_svf_mesh: Trimesh object with SVF in metadata
|
|
2366
|
+
weather_df: pandas DataFrame with 'DNI' and 'DHI' columns
|
|
2367
|
+
lon: Longitude in degrees
|
|
2368
|
+
lat: Latitude in degrees
|
|
2369
|
+
tz: Timezone offset in hours
|
|
2370
|
+
direct_normal_irradiance_scaling: Scaling factor for DNI
|
|
2371
|
+
diffuse_irradiance_scaling: Scaling factor for DHI
|
|
2372
|
+
**kwargs: Additional parameters including:
|
|
2373
|
+
- period_start (str): Start time 'MM-DD HH:MM:SS' (default: '01-01 00:00:00')
|
|
2374
|
+
- period_end (str): End time 'MM-DD HH:MM:SS' (default: '12-31 23:59:59')
|
|
2375
|
+
- time_step_hours (float): Time step in hours (default: 1.0)
|
|
2376
|
+
- use_sky_patches (bool): Use sky patch optimization (default: True)
|
|
2377
|
+
- sky_discretization (str): 'tregenza', 'reinhart', etc.
|
|
2378
|
+
- computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
|
|
2379
|
+
Faces whose XY centroid falls outside the masked region are set to NaN.
|
|
2380
|
+
- progress_report (bool): Print progress (default: False)
|
|
2381
|
+
- with_reflections (bool): Enable multi-bounce surface reflections (default: False).
|
|
2382
|
+
Set to True for more accurate results but slower computation.
|
|
2383
|
+
|
|
2384
|
+
Returns:
|
|
2385
|
+
Trimesh object with cumulative irradiance (Wh/m²) in metadata
|
|
2386
|
+
"""
|
|
2387
|
+
from datetime import datetime
|
|
2388
|
+
import pytz
|
|
2389
|
+
|
|
2390
|
+
# Extract parameters that we pass explicitly (use pop to avoid duplicate kwargs)
|
|
2391
|
+
kwargs = dict(kwargs) # Copy to avoid modifying original
|
|
2392
|
+
period_start = kwargs.pop('period_start', '01-01 00:00:00')
|
|
2393
|
+
period_end = kwargs.pop('period_end', '12-31 23:59:59')
|
|
2394
|
+
time_step_hours = float(kwargs.pop('time_step_hours', 1.0))
|
|
2395
|
+
progress_report = kwargs.pop('progress_report', False)
|
|
2396
|
+
use_sky_patches = kwargs.pop('use_sky_patches', False) # Default False for accuracy, True for speed
|
|
2397
|
+
computation_mask = kwargs.pop('computation_mask', None) # 2D boolean mask for sub-area filtering
|
|
2398
|
+
|
|
2399
|
+
if weather_df.empty:
|
|
2400
|
+
raise ValueError("No data in weather dataframe.")
|
|
2401
|
+
|
|
2402
|
+
# Parse period
|
|
2403
|
+
try:
|
|
2404
|
+
start_dt = datetime.strptime(period_start, '%m-%d %H:%M:%S')
|
|
2405
|
+
end_dt = datetime.strptime(period_end, '%m-%d %H:%M:%S')
|
|
2406
|
+
except ValueError:
|
|
2407
|
+
raise ValueError("period_start and period_end must be in format 'MM-DD HH:MM:SS'")
|
|
2408
|
+
|
|
2409
|
+
# Filter dataframe to period
|
|
2410
|
+
df = weather_df.copy()
|
|
2411
|
+
df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
|
|
2412
|
+
start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
|
|
2413
|
+
end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
|
|
2414
|
+
start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
|
|
2415
|
+
end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
|
|
2416
|
+
|
|
2417
|
+
if start_hour <= end_hour:
|
|
2418
|
+
df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
|
|
2419
|
+
else:
|
|
2420
|
+
df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
|
|
2421
|
+
|
|
2422
|
+
if df_period.empty:
|
|
2423
|
+
raise ValueError("No weather data in the specified period.")
|
|
2424
|
+
|
|
2425
|
+
# Localize and convert to UTC
|
|
2426
|
+
offset_minutes = int(tz * 60)
|
|
2427
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
2428
|
+
df_period_local = df_period.copy()
|
|
2429
|
+
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
2430
|
+
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
2431
|
+
|
|
2432
|
+
# Get solar positions
|
|
2433
|
+
solar_positions = _get_solar_positions_astral(df_period_utc.index, lon, lat)
|
|
2434
|
+
|
|
2435
|
+
# Initialize cumulative arrays
|
|
2436
|
+
result_mesh = building_svf_mesh.copy() if hasattr(building_svf_mesh, 'copy') else building_svf_mesh
|
|
2437
|
+
n_faces = len(result_mesh.faces) if hasattr(result_mesh, 'faces') else 0
|
|
2438
|
+
|
|
2439
|
+
if n_faces == 0:
|
|
2440
|
+
raise ValueError("Building mesh has no faces")
|
|
2441
|
+
|
|
2442
|
+
cumulative_direct = np.zeros(n_faces, dtype=np.float64)
|
|
2443
|
+
cumulative_diffuse = np.zeros(n_faces, dtype=np.float64)
|
|
2444
|
+
cumulative_global = np.zeros(n_faces, dtype=np.float64)
|
|
2445
|
+
|
|
2446
|
+
# Get SVF from mesh if available
|
|
2447
|
+
face_svf = None
|
|
2448
|
+
if hasattr(result_mesh, 'metadata') and 'svf' in result_mesh.metadata:
|
|
2449
|
+
face_svf = result_mesh.metadata['svf']
|
|
2450
|
+
|
|
2451
|
+
if progress_report:
|
|
2452
|
+
print(f"Computing cumulative irradiance for {n_faces} faces...")
|
|
2453
|
+
|
|
2454
|
+
# Extract arrays for processing
|
|
2455
|
+
azimuth_arr = solar_positions['azimuth'].to_numpy()
|
|
2456
|
+
elevation_arr = solar_positions['elevation'].to_numpy()
|
|
2457
|
+
dni_arr = df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling
|
|
2458
|
+
dhi_arr = df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling
|
|
2459
|
+
n_timesteps = len(azimuth_arr)
|
|
2460
|
+
|
|
2461
|
+
if use_sky_patches:
|
|
2462
|
+
# Use sky patch aggregation for efficiency (same as ground-level)
|
|
2463
|
+
from .sky import generate_sky_patches, get_tregenza_patch_index
|
|
2464
|
+
|
|
2465
|
+
sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
|
|
2466
|
+
|
|
2467
|
+
# Get method-specific parameters
|
|
2468
|
+
sky_kwargs = {}
|
|
2469
|
+
if sky_discretization.lower() == 'reinhart':
|
|
2470
|
+
sky_kwargs['mf'] = kwargs.pop('reinhart_mf', kwargs.pop('mf', 4))
|
|
2471
|
+
elif sky_discretization.lower() == 'uniform':
|
|
2472
|
+
sky_kwargs['n_azimuth'] = kwargs.pop('sky_n_azimuth', kwargs.pop('n_azimuth', 36))
|
|
2473
|
+
sky_kwargs['n_elevation'] = kwargs.pop('sky_n_elevation', kwargs.pop('n_elevation', 9))
|
|
2474
|
+
elif sky_discretization.lower() == 'fibonacci':
|
|
2475
|
+
sky_kwargs['n_patches'] = kwargs.pop('sky_n_patches', kwargs.pop('n_patches', 145))
|
|
2476
|
+
|
|
2477
|
+
# Generate sky patches using unified interface
|
|
2478
|
+
sky_patches = generate_sky_patches(sky_discretization, **sky_kwargs)
|
|
2479
|
+
patches = sky_patches.patches # (N, 2) azimuth, elevation
|
|
2480
|
+
directions = sky_patches.directions # (N, 3) unit vectors
|
|
2481
|
+
|
|
2482
|
+
n_patches = sky_patches.n_patches
|
|
2483
|
+
cumulative_dni_per_patch = np.zeros(n_patches, dtype=np.float64)
|
|
2484
|
+
total_cumulative_dhi = 0.0
|
|
2485
|
+
|
|
2486
|
+
# Bin sun positions to patches
|
|
2487
|
+
for i in range(n_timesteps):
|
|
2488
|
+
elev = elevation_arr[i]
|
|
2489
|
+
dhi = dhi_arr[i]
|
|
2490
|
+
|
|
2491
|
+
if dhi > 0:
|
|
2492
|
+
total_cumulative_dhi += dhi * time_step_hours
|
|
2493
|
+
|
|
2494
|
+
if elev <= 0:
|
|
2495
|
+
continue
|
|
2496
|
+
|
|
2497
|
+
az = azimuth_arr[i]
|
|
2498
|
+
dni = dni_arr[i]
|
|
2499
|
+
|
|
2500
|
+
if dni <= 0:
|
|
2501
|
+
continue
|
|
2502
|
+
|
|
2503
|
+
# Find nearest patch based on method
|
|
2504
|
+
if sky_discretization.lower() == 'tregenza':
|
|
2505
|
+
patch_idx = int(get_tregenza_patch_index(float(az), float(elev)))
|
|
2506
|
+
else:
|
|
2507
|
+
# For other methods, find nearest patch by direction vector
|
|
2508
|
+
elev_rad = np.deg2rad(elev)
|
|
2509
|
+
az_rad = np.deg2rad(az)
|
|
2510
|
+
sun_dir = np.array([
|
|
2511
|
+
np.cos(elev_rad) * np.sin(az_rad), # East
|
|
2512
|
+
np.cos(elev_rad) * np.cos(az_rad), # North
|
|
2513
|
+
np.sin(elev_rad) # Up
|
|
2514
|
+
])
|
|
2515
|
+
dots = np.sum(directions * sun_dir, axis=1)
|
|
2516
|
+
patch_idx = int(np.argmax(dots))
|
|
2517
|
+
|
|
2518
|
+
if 0 <= patch_idx < n_patches:
|
|
2519
|
+
cumulative_dni_per_patch[patch_idx] += dni * time_step_hours
|
|
2520
|
+
|
|
2521
|
+
active_mask = cumulative_dni_per_patch > 0
|
|
2522
|
+
n_active = int(np.sum(active_mask))
|
|
2523
|
+
|
|
2524
|
+
if progress_report:
|
|
2525
|
+
print(f" Sky patch optimization: {n_timesteps} timesteps -> {n_active} active patches ({sky_discretization})")
|
|
2526
|
+
print(f" Total cumulative DHI: {total_cumulative_dhi:.1f} Wh/m²")
|
|
2527
|
+
|
|
2528
|
+
# First pass: compute diffuse component using SVF (if available) or a single call
|
|
2529
|
+
if face_svf is not None and len(face_svf) == n_faces:
|
|
2530
|
+
cumulative_diffuse = face_svf * total_cumulative_dhi
|
|
2531
|
+
else:
|
|
2532
|
+
# Compute diffuse using a single call with sun at zenith
|
|
2533
|
+
diffuse_mesh = get_building_solar_irradiance(
|
|
2534
|
+
voxcity,
|
|
2535
|
+
building_svf_mesh=building_svf_mesh,
|
|
2536
|
+
azimuth_degrees_ori=180.0,
|
|
2537
|
+
elevation_degrees=45.0,
|
|
2538
|
+
direct_normal_irradiance=0.0,
|
|
2539
|
+
diffuse_irradiance=1.0,
|
|
2540
|
+
progress_report=False,
|
|
2541
|
+
**kwargs
|
|
2542
|
+
)
|
|
2543
|
+
if diffuse_mesh is not None and 'diffuse' in diffuse_mesh.metadata:
|
|
2544
|
+
base_diffuse = diffuse_mesh.metadata['diffuse']
|
|
2545
|
+
cumulative_diffuse = np.nan_to_num(base_diffuse, nan=0.0) * total_cumulative_dhi
|
|
2546
|
+
|
|
2547
|
+
# Second pass: loop over active patches for direct component
|
|
2548
|
+
active_indices = np.where(active_mask)[0]
|
|
2549
|
+
for i, patch_idx in enumerate(active_indices):
|
|
2550
|
+
az_deg = patches[patch_idx, 0]
|
|
2551
|
+
el_deg = patches[patch_idx, 1]
|
|
2552
|
+
cumulative_dni_patch = cumulative_dni_per_patch[patch_idx]
|
|
2553
|
+
|
|
2554
|
+
irradiance_mesh = get_building_solar_irradiance(
|
|
2555
|
+
voxcity,
|
|
2556
|
+
building_svf_mesh=building_svf_mesh,
|
|
2557
|
+
azimuth_degrees_ori=az_deg,
|
|
2558
|
+
elevation_degrees=el_deg,
|
|
2559
|
+
direct_normal_irradiance=1.0, # Unit irradiance, scale by cumulative
|
|
2560
|
+
diffuse_irradiance=0.0, # Diffuse handled separately
|
|
2561
|
+
progress_report=False,
|
|
2562
|
+
**kwargs
|
|
2563
|
+
)
|
|
2564
|
+
|
|
2565
|
+
if irradiance_mesh is not None and hasattr(irradiance_mesh, 'metadata'):
|
|
2566
|
+
if 'direct' in irradiance_mesh.metadata:
|
|
2567
|
+
direct_vals = irradiance_mesh.metadata['direct']
|
|
2568
|
+
if len(direct_vals) == n_faces:
|
|
2569
|
+
cumulative_direct += np.nan_to_num(direct_vals, nan=0.0) * cumulative_dni_patch
|
|
2570
|
+
|
|
2571
|
+
if progress_report and ((i + 1) % max(1, len(active_indices) // 10) == 0 or i == len(active_indices) - 1):
|
|
2572
|
+
pct = (i + 1) * 100.0 / len(active_indices)
|
|
2573
|
+
print(f" Patch {i+1}/{len(active_indices)} ({pct:.1f}%)")
|
|
2574
|
+
|
|
2575
|
+
# Combine direct and diffuse
|
|
2576
|
+
cumulative_global = cumulative_direct + cumulative_diffuse
|
|
2577
|
+
|
|
2578
|
+
else:
|
|
2579
|
+
# Per-timestep path (no optimization)
|
|
2580
|
+
if progress_report:
|
|
2581
|
+
print(f" Processing {n_timesteps} timesteps (no sky patch optimization)...")
|
|
2582
|
+
|
|
2583
|
+
for t_idx, (timestamp, row) in enumerate(df_period_utc.iterrows()):
|
|
2584
|
+
dni = float(row['DNI']) * direct_normal_irradiance_scaling
|
|
2585
|
+
dhi = float(row['DHI']) * diffuse_irradiance_scaling
|
|
2586
|
+
|
|
2587
|
+
elevation = float(solar_positions.loc[timestamp, 'elevation'])
|
|
2588
|
+
azimuth = float(solar_positions.loc[timestamp, 'azimuth'])
|
|
2589
|
+
|
|
2590
|
+
# Skip nighttime
|
|
2591
|
+
if elevation <= 0 or (dni <= 0 and dhi <= 0):
|
|
2592
|
+
continue
|
|
2593
|
+
|
|
2594
|
+
# Compute instantaneous irradiance for this timestep
|
|
2595
|
+
irradiance_mesh = get_building_solar_irradiance(
|
|
2596
|
+
voxcity,
|
|
2597
|
+
building_svf_mesh=building_svf_mesh,
|
|
2598
|
+
azimuth_degrees_ori=azimuth,
|
|
2599
|
+
elevation_degrees=elevation,
|
|
2600
|
+
direct_normal_irradiance=dni,
|
|
2601
|
+
diffuse_irradiance=dhi,
|
|
2602
|
+
progress_report=False,
|
|
2603
|
+
**kwargs
|
|
2604
|
+
)
|
|
2605
|
+
|
|
2606
|
+
if irradiance_mesh is not None and hasattr(irradiance_mesh, 'metadata'):
|
|
2607
|
+
# Accumulate (convert W/m² to Wh/m² by multiplying by time_step_hours)
|
|
2608
|
+
if 'direct' in irradiance_mesh.metadata:
|
|
2609
|
+
direct_vals = irradiance_mesh.metadata['direct']
|
|
2610
|
+
if len(direct_vals) == n_faces:
|
|
2611
|
+
cumulative_direct += np.nan_to_num(direct_vals, nan=0.0) * time_step_hours
|
|
2612
|
+
if 'diffuse' in irradiance_mesh.metadata:
|
|
2613
|
+
diffuse_vals = irradiance_mesh.metadata['diffuse']
|
|
2614
|
+
if len(diffuse_vals) == n_faces:
|
|
2615
|
+
cumulative_diffuse += np.nan_to_num(diffuse_vals, nan=0.0) * time_step_hours
|
|
2616
|
+
if 'global' in irradiance_mesh.metadata:
|
|
2617
|
+
global_vals = irradiance_mesh.metadata['global']
|
|
2618
|
+
if len(global_vals) == n_faces:
|
|
2619
|
+
cumulative_global += np.nan_to_num(global_vals, nan=0.0) * time_step_hours
|
|
2620
|
+
|
|
2621
|
+
if progress_report and (t_idx + 1) % max(1, n_timesteps // 10) == 0:
|
|
2622
|
+
print(f" Processed {t_idx + 1}/{n_timesteps} timesteps ({100*(t_idx+1)/n_timesteps:.1f}%)")
|
|
2623
|
+
|
|
2624
|
+
# -------------------------------------------------------------------------
|
|
2625
|
+
# Set vertical faces on domain perimeter to NaN (matching VoxCity behavior)
|
|
2626
|
+
# -------------------------------------------------------------------------
|
|
2627
|
+
voxel_data = voxcity.voxels.classes
|
|
2628
|
+
meshsize = voxcity.voxels.meta.meshsize
|
|
2629
|
+
ny_vc, nx_vc, nz = voxel_data.shape
|
|
2630
|
+
grid_bounds_real = np.array([
|
|
2631
|
+
[0.0, 0.0, 0.0],
|
|
2632
|
+
[ny_vc * meshsize, nx_vc * meshsize, nz * meshsize]
|
|
2633
|
+
], dtype=np.float64)
|
|
2634
|
+
boundary_epsilon = meshsize * 0.05
|
|
2635
|
+
|
|
2636
|
+
mesh_face_centers = result_mesh.triangles_center
|
|
2637
|
+
mesh_face_normals = result_mesh.face_normals
|
|
2638
|
+
|
|
2639
|
+
# Detect vertical faces (normal z-component near zero)
|
|
2640
|
+
is_vertical = np.abs(mesh_face_normals[:, 2]) < 0.01
|
|
2641
|
+
|
|
2642
|
+
# Detect faces on domain boundary
|
|
2643
|
+
on_x_min = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[0, 0]) < boundary_epsilon
|
|
2644
|
+
on_y_min = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[0, 1]) < boundary_epsilon
|
|
2645
|
+
on_x_max = np.abs(mesh_face_centers[:, 0] - grid_bounds_real[1, 0]) < boundary_epsilon
|
|
2646
|
+
on_y_max = np.abs(mesh_face_centers[:, 1] - grid_bounds_real[1, 1]) < boundary_epsilon
|
|
2647
|
+
|
|
2648
|
+
is_boundary_vertical = is_vertical & (on_x_min | on_y_min | on_x_max | on_y_max)
|
|
2649
|
+
|
|
2650
|
+
# Set boundary vertical faces to NaN
|
|
2651
|
+
cumulative_direct[is_boundary_vertical] = np.nan
|
|
2652
|
+
cumulative_diffuse[is_boundary_vertical] = np.nan
|
|
2653
|
+
cumulative_global[is_boundary_vertical] = np.nan
|
|
2654
|
+
|
|
2655
|
+
if progress_report:
|
|
2656
|
+
n_boundary = np.sum(is_boundary_vertical)
|
|
2657
|
+
print(f" Boundary vertical faces set to NaN: {n_boundary}/{n_faces} ({100*n_boundary/n_faces:.1f}%)")
|
|
2658
|
+
|
|
2659
|
+
# -------------------------------------------------------------------------
|
|
2660
|
+
# Apply computation_mask: set faces outside masked XY region to NaN
|
|
2661
|
+
# -------------------------------------------------------------------------
|
|
2662
|
+
if computation_mask is not None:
|
|
2663
|
+
# Convert face XY positions to grid indices
|
|
2664
|
+
face_x = mesh_face_centers[:, 0]
|
|
2665
|
+
face_y = mesh_face_centers[:, 1]
|
|
2666
|
+
|
|
2667
|
+
# Map to grid indices (face coords are in real-world units: 0 to nx*meshsize)
|
|
2668
|
+
grid_i = (face_y / meshsize).astype(int) # y -> i (row)
|
|
2669
|
+
grid_j = (face_x / meshsize).astype(int) # x -> j (col)
|
|
2670
|
+
|
|
2671
|
+
# Handle mask shape orientation
|
|
2672
|
+
if computation_mask.shape == (ny_vc, nx_vc):
|
|
2673
|
+
mask_shape = computation_mask.shape
|
|
2674
|
+
elif computation_mask.T.shape == (ny_vc, nx_vc):
|
|
2675
|
+
computation_mask = computation_mask.T
|
|
2676
|
+
mask_shape = computation_mask.shape
|
|
2677
|
+
else:
|
|
2678
|
+
# Best effort: assume it matches voxel grid
|
|
2679
|
+
mask_shape = computation_mask.shape
|
|
2680
|
+
|
|
2681
|
+
# Clamp indices to valid range
|
|
2682
|
+
grid_i = np.clip(grid_i, 0, mask_shape[0] - 1)
|
|
2683
|
+
grid_j = np.clip(grid_j, 0, mask_shape[1] - 1)
|
|
2684
|
+
|
|
2685
|
+
# Determine which faces are outside the mask
|
|
2686
|
+
# Flip mask to match coordinate system (same as ground-level functions)
|
|
2687
|
+
flipped_mask = np.flipud(computation_mask)
|
|
2688
|
+
outside_mask = ~flipped_mask[grid_i, grid_j]
|
|
2689
|
+
|
|
2690
|
+
# Set values outside mask to NaN
|
|
2691
|
+
cumulative_direct[outside_mask] = np.nan
|
|
2692
|
+
cumulative_diffuse[outside_mask] = np.nan
|
|
2693
|
+
cumulative_global[outside_mask] = np.nan
|
|
2694
|
+
|
|
2695
|
+
if progress_report:
|
|
2696
|
+
n_outside = np.sum(outside_mask)
|
|
2697
|
+
print(f" Faces outside computation_mask set to NaN: {n_outside}/{n_faces} ({100*n_outside/n_faces:.1f}%)")
|
|
2698
|
+
|
|
2699
|
+
# Store results in mesh metadata
|
|
2700
|
+
result_mesh.metadata = getattr(result_mesh, 'metadata', {})
|
|
2701
|
+
result_mesh.metadata['cumulative_direct'] = cumulative_direct
|
|
2702
|
+
result_mesh.metadata['cumulative_diffuse'] = cumulative_diffuse
|
|
2703
|
+
result_mesh.metadata['cumulative_global'] = cumulative_global
|
|
2704
|
+
result_mesh.metadata['direct'] = cumulative_direct # VoxCity API alias
|
|
2705
|
+
result_mesh.metadata['diffuse'] = cumulative_diffuse # VoxCity API alias
|
|
2706
|
+
result_mesh.metadata['global'] = cumulative_global # VoxCity API alias
|
|
2707
|
+
if face_svf is not None:
|
|
2708
|
+
result_mesh.metadata['svf'] = face_svf
|
|
2709
|
+
|
|
2710
|
+
if progress_report:
|
|
2711
|
+
valid_mask = ~np.isnan(cumulative_global)
|
|
2712
|
+
total_irradiance = np.nansum(cumulative_global)
|
|
2713
|
+
print(f"Cumulative irradiance computation complete:")
|
|
2714
|
+
print(f" Total faces: {n_faces}, Valid: {np.sum(valid_mask)}")
|
|
2715
|
+
print(f" Mean cumulative: {np.nanmean(cumulative_global):.1f} Wh/m²")
|
|
2716
|
+
print(f" Max cumulative: {np.nanmax(cumulative_global):.1f} Wh/m²")
|
|
2717
|
+
|
|
2718
|
+
# Export if requested
|
|
2719
|
+
if kwargs.get('obj_export', False):
|
|
2720
|
+
import os
|
|
2721
|
+
output_dir = kwargs.get('output_directory', 'output')
|
|
2722
|
+
output_file_name = kwargs.get('output_file_name', 'cumulative_building_irradiance')
|
|
2723
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
2724
|
+
try:
|
|
2725
|
+
result_mesh.export(f"{output_dir}/{output_file_name}.obj")
|
|
2726
|
+
if progress_report:
|
|
2727
|
+
print(f"Exported to {output_dir}/{output_file_name}.obj")
|
|
2728
|
+
except Exception as e:
|
|
2729
|
+
print(f"Error exporting mesh: {e}")
|
|
2730
|
+
|
|
2731
|
+
return result_mesh
|
|
2732
|
+
|
|
2733
|
+
|
|
2734
|
+
def get_building_global_solar_irradiance_using_epw(
|
|
2735
|
+
voxcity,
|
|
2736
|
+
calc_type: str = 'instantaneous',
|
|
2737
|
+
direct_normal_irradiance_scaling: float = 1.0,
|
|
2738
|
+
diffuse_irradiance_scaling: float = 1.0,
|
|
2739
|
+
building_svf_mesh=None,
|
|
2740
|
+
**kwargs
|
|
2741
|
+
):
|
|
2742
|
+
"""
|
|
2743
|
+
GPU-accelerated building surface irradiance using EPW weather data.
|
|
2744
|
+
|
|
2745
|
+
This function matches the signature of voxcity.simulator.solar.get_building_global_solar_irradiance_using_epw
|
|
2746
|
+
using Taichi GPU acceleration.
|
|
2747
|
+
|
|
2748
|
+
Args:
|
|
2749
|
+
voxcity: VoxCity object
|
|
2750
|
+
calc_type: 'instantaneous' or 'cumulative'
|
|
2751
|
+
direct_normal_irradiance_scaling: Scaling factor for DNI
|
|
2752
|
+
diffuse_irradiance_scaling: Scaling factor for DHI
|
|
2753
|
+
building_svf_mesh: Pre-computed building mesh with SVF (optional)
|
|
2754
|
+
**kwargs: Additional parameters including:
|
|
2755
|
+
- epw_file_path (str): Path to EPW file
|
|
2756
|
+
- download_nearest_epw (bool): Download nearest EPW (default: False)
|
|
2757
|
+
- calc_time (str): For instantaneous: 'MM-DD HH:MM:SS'
|
|
2758
|
+
- period_start, period_end (str): For cumulative: 'MM-DD HH:MM:SS'
|
|
2759
|
+
- rectangle_vertices: Location vertices
|
|
2760
|
+
- computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
|
|
2761
|
+
Faces whose XY centroid falls outside the masked region are set to NaN.
|
|
2762
|
+
Useful for analyzing specific buildings or sub-regions.
|
|
2763
|
+
- progress_report (bool): Print progress
|
|
2764
|
+
- with_reflections (bool): Enable multi-bounce surface reflections (default: False).
|
|
2765
|
+
Set to True for more accurate results but slower computation.
|
|
2766
|
+
|
|
2767
|
+
Returns:
|
|
2768
|
+
Trimesh object with irradiance values (W/m² or Wh/m²) in metadata
|
|
2769
|
+
"""
|
|
2770
|
+
from datetime import datetime
|
|
2771
|
+
import pytz
|
|
2772
|
+
|
|
2773
|
+
# NOTE: We frequently forward **kwargs to lower-level functions; ensure
|
|
2774
|
+
# we don't pass duplicate keyword args (e.g., progress_report).
|
|
2775
|
+
progress_report = kwargs.get('progress_report', False)
|
|
2776
|
+
kwargs = dict(kwargs)
|
|
2777
|
+
kwargs.pop('progress_report', None)
|
|
2778
|
+
|
|
2779
|
+
# Load EPW data using helper function
|
|
2780
|
+
df, lon, lat, tz = _load_epw_data(
|
|
2781
|
+
epw_file_path=kwargs.pop('epw_file_path', None),
|
|
2782
|
+
download_nearest_epw=kwargs.pop('download_nearest_epw', False),
|
|
2783
|
+
voxcity=voxcity,
|
|
2784
|
+
**kwargs
|
|
2785
|
+
)
|
|
2786
|
+
|
|
2787
|
+
# Create building mesh for output (just geometry, no SVF computation)
|
|
2788
|
+
# The RadiationModel computes SVF internally for voxel surfaces, so we don't need
|
|
2789
|
+
# the expensive get_surface_view_factor() call. We just need the mesh geometry.
|
|
2790
|
+
if building_svf_mesh is None:
|
|
2791
|
+
try:
|
|
2792
|
+
from voxcity.geoprocessor.mesh import create_voxel_mesh
|
|
2793
|
+
building_class_id = kwargs.get('building_class_id', -3)
|
|
2794
|
+
voxel_data = voxcity.voxels.classes
|
|
2795
|
+
meshsize = voxcity.voxels.meta.meshsize
|
|
2796
|
+
building_id_grid = voxcity.buildings.ids
|
|
2797
|
+
|
|
2798
|
+
building_svf_mesh = create_voxel_mesh(
|
|
2799
|
+
voxel_data,
|
|
2800
|
+
building_class_id,
|
|
2801
|
+
meshsize,
|
|
2802
|
+
building_id_grid=building_id_grid,
|
|
2803
|
+
mesh_type='open_air'
|
|
2804
|
+
)
|
|
2805
|
+
if progress_report:
|
|
2806
|
+
n_faces = len(building_svf_mesh.faces) if building_svf_mesh is not None else 0
|
|
2807
|
+
print(f"Created building mesh with {n_faces} faces")
|
|
2808
|
+
except ImportError:
|
|
2809
|
+
pass # Will fail later with "Building mesh has no faces" error
|
|
2810
|
+
|
|
2811
|
+
if calc_type == 'instantaneous':
|
|
2812
|
+
calc_time = kwargs.get('calc_time', '01-01 12:00:00')
|
|
2813
|
+
try:
|
|
2814
|
+
calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%S')
|
|
2815
|
+
except ValueError:
|
|
2816
|
+
raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'")
|
|
2817
|
+
|
|
2818
|
+
df_period = df[
|
|
2819
|
+
(df.index.month == calc_dt.month) &
|
|
2820
|
+
(df.index.day == calc_dt.day) &
|
|
2821
|
+
(df.index.hour == calc_dt.hour)
|
|
2822
|
+
]
|
|
2823
|
+
if df_period.empty:
|
|
2824
|
+
raise ValueError("No EPW data at the specified time.")
|
|
2825
|
+
|
|
2826
|
+
# Get solar position
|
|
2827
|
+
offset_minutes = int(tz * 60)
|
|
2828
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
2829
|
+
df_local = df_period.copy()
|
|
2830
|
+
df_local.index = df_local.index.tz_localize(local_tz)
|
|
2831
|
+
df_utc = df_local.tz_convert(pytz.UTC)
|
|
2832
|
+
|
|
2833
|
+
solar_positions = _get_solar_positions_astral(df_utc.index, lon, lat)
|
|
2834
|
+
DNI = float(df_utc.iloc[0]['DNI']) * direct_normal_irradiance_scaling
|
|
2835
|
+
DHI = float(df_utc.iloc[0]['DHI']) * diffuse_irradiance_scaling
|
|
2836
|
+
azimuth_degrees = float(solar_positions.iloc[0]['azimuth'])
|
|
2837
|
+
elevation_degrees = float(solar_positions.iloc[0]['elevation'])
|
|
2838
|
+
|
|
2839
|
+
return get_building_solar_irradiance(
|
|
2840
|
+
voxcity,
|
|
2841
|
+
building_svf_mesh=building_svf_mesh,
|
|
2842
|
+
azimuth_degrees_ori=azimuth_degrees,
|
|
2843
|
+
elevation_degrees=elevation_degrees,
|
|
2844
|
+
direct_normal_irradiance=DNI,
|
|
2845
|
+
diffuse_irradiance=DHI,
|
|
2846
|
+
**kwargs
|
|
2847
|
+
)
|
|
2848
|
+
|
|
2849
|
+
elif calc_type == 'cumulative':
|
|
2850
|
+
period_start = kwargs.get('period_start', '01-01 00:00:00')
|
|
2851
|
+
period_end = kwargs.get('period_end', '12-31 23:59:59')
|
|
2852
|
+
time_step_hours = float(kwargs.get('time_step_hours', 1.0))
|
|
2853
|
+
|
|
2854
|
+
# Avoid passing duplicates: we pass these explicitly below.
|
|
2855
|
+
kwargs.pop('period_start', None)
|
|
2856
|
+
kwargs.pop('period_end', None)
|
|
2857
|
+
kwargs.pop('time_step_hours', None)
|
|
2858
|
+
|
|
2859
|
+
return get_cumulative_building_solar_irradiance(
|
|
2860
|
+
voxcity,
|
|
2861
|
+
building_svf_mesh=building_svf_mesh,
|
|
2862
|
+
weather_df=df,
|
|
2863
|
+
lon=lon,
|
|
2864
|
+
lat=lat,
|
|
2865
|
+
tz=tz,
|
|
2866
|
+
direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
|
|
2867
|
+
diffuse_irradiance_scaling=diffuse_irradiance_scaling,
|
|
2868
|
+
period_start=period_start,
|
|
2869
|
+
period_end=period_end,
|
|
2870
|
+
time_step_hours=time_step_hours,
|
|
2871
|
+
**kwargs
|
|
2872
|
+
)
|
|
2873
|
+
|
|
2874
|
+
else:
|
|
2875
|
+
raise ValueError(f"Unknown calc_type: {calc_type}. Use 'instantaneous' or 'cumulative'.")
|
|
2876
|
+
|
|
2877
|
+
|
|
2878
|
+
# =============================================================================
|
|
2879
|
+
# Volumetric Solar Irradiance Functions
|
|
2880
|
+
# =============================================================================
|
|
2881
|
+
# Module-level cache for VolumetricFluxCalculator
|
|
2882
|
+
_volumetric_flux_cache: Optional[Dict] = None
|
|
2883
|
+
|
|
2884
|
+
|
|
2885
|
+
def clear_volumetric_flux_cache():
|
|
2886
|
+
"""Clear the cached VolumetricFluxCalculator to free memory or force recomputation."""
|
|
2887
|
+
global _volumetric_flux_cache
|
|
2888
|
+
_volumetric_flux_cache = None
|
|
2889
|
+
|
|
2890
|
+
|
|
2891
|
+
def _get_or_create_volumetric_calculator(
|
|
2892
|
+
voxcity,
|
|
2893
|
+
n_azimuth: int = 36,
|
|
2894
|
+
n_zenith: int = 9,
|
|
2895
|
+
progress_report: bool = False,
|
|
2896
|
+
**kwargs
|
|
2897
|
+
):
|
|
2898
|
+
"""
|
|
2899
|
+
Get cached VolumetricFluxCalculator or create a new one if cache is invalid.
|
|
2900
|
+
|
|
2901
|
+
Args:
|
|
2902
|
+
voxcity: VoxCity object
|
|
2903
|
+
n_azimuth: Number of azimuthal directions
|
|
2904
|
+
n_zenith: Number of zenith angle divisions
|
|
2905
|
+
progress_report: Print progress messages
|
|
2906
|
+
**kwargs: Additional parameters
|
|
2907
|
+
|
|
2908
|
+
Returns:
|
|
2909
|
+
Tuple of (VolumetricFluxCalculator, Domain)
|
|
2910
|
+
"""
|
|
2911
|
+
global _volumetric_flux_cache
|
|
2912
|
+
|
|
2913
|
+
from .volumetric import VolumetricFluxCalculator
|
|
2914
|
+
from .domain import Domain
|
|
2915
|
+
|
|
2916
|
+
voxel_data = voxcity.voxels.classes
|
|
2917
|
+
meshsize = voxcity.voxels.meta.meshsize
|
|
2918
|
+
ni, nj, nk = voxel_data.shape
|
|
2919
|
+
|
|
2920
|
+
# Check if cache is valid
|
|
2921
|
+
cache_valid = False
|
|
2922
|
+
if _volumetric_flux_cache is not None:
|
|
2923
|
+
cache = _volumetric_flux_cache
|
|
2924
|
+
if (cache.get('voxcity_shape') == voxel_data.shape and
|
|
2925
|
+
cache.get('meshsize') == meshsize and
|
|
2926
|
+
cache.get('n_azimuth') == n_azimuth):
|
|
2927
|
+
cache_valid = True
|
|
2928
|
+
if progress_report:
|
|
2929
|
+
print("Using cached VolumetricFluxCalculator (SVF already computed)")
|
|
2930
|
+
|
|
2931
|
+
if cache_valid:
|
|
2932
|
+
return _volumetric_flux_cache['calculator'], _volumetric_flux_cache['domain']
|
|
2933
|
+
|
|
2934
|
+
# Need to create new calculator
|
|
2935
|
+
if progress_report:
|
|
2936
|
+
print("Creating new VolumetricFluxCalculator...")
|
|
2937
|
+
|
|
2938
|
+
# Get location using helper function
|
|
2939
|
+
origin_lat, origin_lon = _get_location_from_voxcity(voxcity)
|
|
2940
|
+
|
|
2941
|
+
# Create domain
|
|
2942
|
+
domain = Domain(
|
|
2943
|
+
nx=ni, ny=nj, nz=nk,
|
|
2944
|
+
dx=meshsize, dy=meshsize, dz=meshsize,
|
|
2945
|
+
origin_lat=origin_lat,
|
|
2946
|
+
origin_lon=origin_lon
|
|
2947
|
+
)
|
|
2948
|
+
|
|
2949
|
+
# Convert VoxCity voxel data to domain arrays using vectorized helper
|
|
2950
|
+
default_lad = kwargs.get('default_lad', 1.0)
|
|
2951
|
+
is_solid_np, lad_np = _convert_voxel_data_to_arrays(voxel_data, default_lad)
|
|
2952
|
+
|
|
2953
|
+
# Set domain arrays
|
|
2954
|
+
_set_solid_array(domain, is_solid_np)
|
|
2955
|
+
domain.set_lad_from_array(lad_np)
|
|
2956
|
+
_update_topo_from_solid(domain)
|
|
2957
|
+
|
|
2958
|
+
# Create VolumetricFluxCalculator
|
|
2959
|
+
calculator = VolumetricFluxCalculator(
|
|
2960
|
+
domain,
|
|
2961
|
+
n_azimuth=n_azimuth,
|
|
2962
|
+
min_opaque_lad=kwargs.get('min_opaque_lad', 0.5)
|
|
2963
|
+
)
|
|
2964
|
+
|
|
2965
|
+
# Compute volumetric sky view factors (expensive, do once)
|
|
2966
|
+
if progress_report:
|
|
2967
|
+
print("Computing volumetric sky view factors...")
|
|
2968
|
+
calculator.compute_skyvf_vol(n_zenith=n_zenith)
|
|
2969
|
+
|
|
2970
|
+
# Cache the calculator
|
|
2971
|
+
_volumetric_flux_cache = {
|
|
2972
|
+
'calculator': calculator,
|
|
2973
|
+
'domain': domain,
|
|
2974
|
+
'voxcity_shape': voxel_data.shape,
|
|
2975
|
+
'meshsize': meshsize,
|
|
2976
|
+
'n_azimuth': n_azimuth
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
if progress_report:
|
|
2980
|
+
print(f"VolumetricFluxCalculator cached.")
|
|
2981
|
+
|
|
2982
|
+
return calculator, domain
|
|
2983
|
+
|
|
2984
|
+
|
|
2985
|
+
def get_volumetric_solar_irradiance_map(
|
|
2986
|
+
voxcity,
|
|
2987
|
+
azimuth_degrees_ori: float,
|
|
2988
|
+
elevation_degrees: float,
|
|
2989
|
+
direct_normal_irradiance: float,
|
|
2990
|
+
diffuse_irradiance: float,
|
|
2991
|
+
volumetric_height: float = 1.5,
|
|
2992
|
+
with_reflections: bool = False,
|
|
2993
|
+
show_plot: bool = False,
|
|
2994
|
+
**kwargs
|
|
2995
|
+
) -> np.ndarray:
|
|
2996
|
+
"""
|
|
2997
|
+
GPU-accelerated volumetric solar irradiance map at a specified height.
|
|
2998
|
+
|
|
2999
|
+
Computes the 3D radiation field at each grid cell and extracts a 2D horizontal
|
|
3000
|
+
slice at the specified height. This is useful for:
|
|
3001
|
+
- Mean Radiant Temperature (MRT) calculations
|
|
3002
|
+
- Pedestrian thermal comfort analysis
|
|
3003
|
+
- Light availability assessment
|
|
3004
|
+
|
|
3005
|
+
Args:
|
|
3006
|
+
voxcity: VoxCity object
|
|
3007
|
+
azimuth_degrees_ori: Solar azimuth in degrees (0=North, clockwise)
|
|
3008
|
+
elevation_degrees: Solar elevation in degrees above horizon
|
|
3009
|
+
direct_normal_irradiance: DNI in W/m²
|
|
3010
|
+
diffuse_irradiance: DHI in W/m²
|
|
3011
|
+
volumetric_height: Height above ground for irradiance extraction (meters)
|
|
3012
|
+
with_reflections: If True, include reflected radiation from surfaces.
|
|
3013
|
+
If False (default), only direct + diffuse sky radiation.
|
|
3014
|
+
show_plot: Whether to display a matplotlib plot
|
|
3015
|
+
**kwargs: Additional parameters:
|
|
3016
|
+
- n_azimuth (int): Number of azimuthal directions for SVF (default: 36)
|
|
3017
|
+
- n_zenith (int): Number of zenith angles for SVF (default: 9)
|
|
3018
|
+
- computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
|
|
3019
|
+
Grid cells outside the masked region are set to NaN.
|
|
3020
|
+
- progress_report (bool): Print progress (default: False)
|
|
3021
|
+
- colormap (str): Colormap for plot (default: 'magma')
|
|
3022
|
+
- vmin, vmax (float): Colormap bounds
|
|
3023
|
+
- n_reflection_steps (int): Reflection bounces when with_reflections=True (default: 2)
|
|
3024
|
+
|
|
3025
|
+
Returns:
|
|
3026
|
+
2D numpy array of volumetric irradiance at the specified height (W/m²)
|
|
3027
|
+
"""
|
|
3028
|
+
import math
|
|
3029
|
+
|
|
3030
|
+
kwargs = kwargs.copy() # Don't modify caller's kwargs
|
|
3031
|
+
progress_report = kwargs.pop('progress_report', False)
|
|
3032
|
+
n_azimuth = kwargs.pop('n_azimuth', 36)
|
|
3033
|
+
n_zenith = kwargs.pop('n_zenith', 9)
|
|
3034
|
+
computation_mask = kwargs.pop('computation_mask', None)
|
|
3035
|
+
|
|
3036
|
+
# Get or create cached calculator
|
|
3037
|
+
calculator, domain = _get_or_create_volumetric_calculator(
|
|
3038
|
+
voxcity,
|
|
3039
|
+
n_azimuth=n_azimuth,
|
|
3040
|
+
n_zenith=n_zenith,
|
|
3041
|
+
progress_report=progress_report,
|
|
3042
|
+
**kwargs
|
|
3043
|
+
)
|
|
3044
|
+
|
|
3045
|
+
voxel_data = voxcity.voxels.classes
|
|
3046
|
+
meshsize = voxcity.voxels.meta.meshsize
|
|
3047
|
+
ni, nj, nk = voxel_data.shape
|
|
3048
|
+
|
|
3049
|
+
# Convert solar angles to direction vector
|
|
3050
|
+
# Match the coordinate system used in ground-level functions:
|
|
3051
|
+
# azimuth_degrees_ori: 0=North, 90=East (clockwise from North)
|
|
3052
|
+
# Transform to model coordinates: 180 - azimuth_degrees_ori
|
|
3053
|
+
azimuth_degrees = 180 - azimuth_degrees_ori
|
|
3054
|
+
azimuth_rad = math.radians(azimuth_degrees)
|
|
3055
|
+
elevation_rad = math.radians(elevation_degrees)
|
|
3056
|
+
|
|
3057
|
+
cos_elev = math.cos(elevation_rad)
|
|
3058
|
+
sin_elev = math.sin(elevation_rad)
|
|
3059
|
+
|
|
3060
|
+
# Direction toward sun (matching ground-level function convention)
|
|
3061
|
+
sun_dir_x = cos_elev * math.cos(azimuth_rad)
|
|
3062
|
+
sun_dir_y = cos_elev * math.sin(azimuth_rad)
|
|
3063
|
+
sun_dir_z = sin_elev
|
|
3064
|
+
sun_direction = (sun_dir_x, sun_dir_y, sun_dir_z)
|
|
3065
|
+
|
|
3066
|
+
cos_zenith = sin_elev # cos(zenith) = sin(elevation)
|
|
3067
|
+
|
|
3068
|
+
# Compute volumetric flux
|
|
3069
|
+
if with_reflections:
|
|
3070
|
+
# Use full reflection model
|
|
3071
|
+
if progress_report:
|
|
3072
|
+
print("Computing volumetric flux with reflections...")
|
|
3073
|
+
|
|
3074
|
+
# Get radiation model for surface reflections
|
|
3075
|
+
n_reflection_steps = kwargs.pop('n_reflection_steps', 2)
|
|
3076
|
+
model, valid_ground, ground_k = _get_or_create_radiation_model(
|
|
3077
|
+
voxcity,
|
|
3078
|
+
n_reflection_steps=n_reflection_steps,
|
|
3079
|
+
progress_report=progress_report,
|
|
3080
|
+
**kwargs
|
|
3081
|
+
)
|
|
3082
|
+
|
|
3083
|
+
# Manually set solar position on the model's solar_calc
|
|
3084
|
+
model.solar_calc.cos_zenith[None] = cos_zenith
|
|
3085
|
+
model.solar_calc.sun_direction[None] = [sun_direction[0], sun_direction[1], sun_direction[2]]
|
|
3086
|
+
model.solar_calc.sun_up[None] = 1 if cos_zenith > 0 else 0
|
|
3087
|
+
|
|
3088
|
+
# Compute shortwave radiation with the set solar position
|
|
3089
|
+
model.compute_shortwave_radiation(
|
|
3090
|
+
sw_direct=direct_normal_irradiance,
|
|
3091
|
+
sw_diffuse=diffuse_irradiance
|
|
3092
|
+
)
|
|
3093
|
+
|
|
3094
|
+
# Get surface outgoing radiation
|
|
3095
|
+
n_surfaces = model.surfaces.count
|
|
3096
|
+
surf_outgoing = model.surfaces.sw_out.to_numpy()[:n_surfaces]
|
|
3097
|
+
|
|
3098
|
+
# Compute volumetric flux including reflections
|
|
3099
|
+
# Use cached C2S-VF matrix if available, otherwise compute dynamically
|
|
3100
|
+
if calculator.c2s_matrix_cached:
|
|
3101
|
+
calculator.compute_swflux_vol_with_reflections_cached(
|
|
3102
|
+
sw_direct=direct_normal_irradiance,
|
|
3103
|
+
sw_diffuse=diffuse_irradiance,
|
|
3104
|
+
cos_zenith=cos_zenith,
|
|
3105
|
+
sun_direction=sun_direction,
|
|
3106
|
+
surf_outgoing=surf_outgoing,
|
|
3107
|
+
lad=domain.lad
|
|
3108
|
+
)
|
|
3109
|
+
else:
|
|
3110
|
+
calculator.compute_swflux_vol_with_reflections(
|
|
3111
|
+
sw_direct=direct_normal_irradiance,
|
|
3112
|
+
sw_diffuse=diffuse_irradiance,
|
|
3113
|
+
cos_zenith=cos_zenith,
|
|
3114
|
+
sun_direction=sun_direction,
|
|
3115
|
+
surfaces=model.surfaces,
|
|
3116
|
+
surf_outgoing=surf_outgoing,
|
|
3117
|
+
lad=domain.lad
|
|
3118
|
+
)
|
|
3119
|
+
else:
|
|
3120
|
+
# Simple direct + diffuse only
|
|
3121
|
+
if progress_report:
|
|
3122
|
+
print("Computing volumetric flux (direct + diffuse)...")
|
|
3123
|
+
|
|
3124
|
+
calculator.compute_swflux_vol(
|
|
3125
|
+
sw_direct=direct_normal_irradiance,
|
|
3126
|
+
sw_diffuse=diffuse_irradiance,
|
|
3127
|
+
cos_zenith=cos_zenith,
|
|
3128
|
+
sun_direction=sun_direction,
|
|
3129
|
+
lad=domain.lad
|
|
3130
|
+
)
|
|
3131
|
+
|
|
3132
|
+
# Compute ground_k for terrain-following extraction
|
|
3133
|
+
ground_k = _compute_ground_k_from_voxels(voxel_data)
|
|
3134
|
+
|
|
3135
|
+
# Extract terrain-following horizontal slice at specified height above ground
|
|
3136
|
+
# For each (i,j), extract at ground_k[i,j] + height_offset_k
|
|
3137
|
+
height_offset_k = max(1, int(round(volumetric_height / meshsize)))
|
|
3138
|
+
if progress_report:
|
|
3139
|
+
print(f"Extracting volumetric irradiance at {volumetric_height}m above terrain (offset={height_offset_k} cells)")
|
|
3140
|
+
|
|
3141
|
+
# Get full 3D volumetric flux and is_solid arrays
|
|
3142
|
+
swflux_3d = calculator.get_swflux_vol()
|
|
3143
|
+
is_solid = domain.is_solid.to_numpy()
|
|
3144
|
+
|
|
3145
|
+
# Create output array
|
|
3146
|
+
volumetric_map = np.full((ni, nj), np.nan, dtype=np.float64)
|
|
3147
|
+
|
|
3148
|
+
# Extract terrain-following values
|
|
3149
|
+
for i in range(ni):
|
|
3150
|
+
for j in range(nj):
|
|
3151
|
+
gk = ground_k[i, j]
|
|
3152
|
+
if gk < 0:
|
|
3153
|
+
# No valid ground - keep NaN
|
|
3154
|
+
continue
|
|
3155
|
+
k_extract = gk + height_offset_k
|
|
3156
|
+
if k_extract >= nk:
|
|
3157
|
+
# Above domain - keep NaN
|
|
3158
|
+
continue
|
|
3159
|
+
# Check if extraction point is in a solid cell
|
|
3160
|
+
if is_solid[i, j, k_extract] == 1:
|
|
3161
|
+
# Inside solid (building) - keep NaN
|
|
3162
|
+
continue
|
|
3163
|
+
volumetric_map[i, j] = swflux_3d[i, j, k_extract]
|
|
3164
|
+
|
|
3165
|
+
# Flip to match VoxCity coordinate system
|
|
3166
|
+
volumetric_map = np.flipud(volumetric_map)
|
|
3167
|
+
|
|
3168
|
+
# Apply computation_mask if provided
|
|
3169
|
+
if computation_mask is not None:
|
|
3170
|
+
# Handle mask shape orientation
|
|
3171
|
+
if computation_mask.shape == volumetric_map.shape:
|
|
3172
|
+
flipped_mask = np.flipud(computation_mask)
|
|
3173
|
+
volumetric_map = np.where(flipped_mask, volumetric_map, np.nan)
|
|
3174
|
+
elif computation_mask.T.shape == volumetric_map.shape:
|
|
3175
|
+
flipped_mask = np.flipud(computation_mask.T)
|
|
3176
|
+
volumetric_map = np.where(flipped_mask, volumetric_map, np.nan)
|
|
3177
|
+
else:
|
|
3178
|
+
# Best effort - try direct application
|
|
3179
|
+
if computation_mask.shape == volumetric_map.shape:
|
|
3180
|
+
volumetric_map = np.where(computation_mask, volumetric_map, np.nan)
|
|
3181
|
+
|
|
3182
|
+
if progress_report:
|
|
3183
|
+
n_masked = np.sum(np.isnan(volumetric_map))
|
|
3184
|
+
total = volumetric_map.size
|
|
3185
|
+
print(f" Cells outside computation_mask set to NaN: {n_masked}/{total} ({100*n_masked/total:.1f}%)")
|
|
3186
|
+
|
|
3187
|
+
if show_plot:
|
|
3188
|
+
colormap = kwargs.get('colormap', 'magma')
|
|
3189
|
+
vmin = kwargs.get('vmin', 0.0)
|
|
3190
|
+
vmax = kwargs.get('vmax', max(float(np.nanmax(volumetric_map)), 1.0))
|
|
3191
|
+
try:
|
|
3192
|
+
import matplotlib.pyplot as plt
|
|
3193
|
+
cmap = plt.cm.get_cmap(colormap).copy()
|
|
3194
|
+
cmap.set_bad(color='lightgray')
|
|
3195
|
+
plt.figure(figsize=(10, 8))
|
|
3196
|
+
plt.imshow(volumetric_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
3197
|
+
plt.colorbar(label=f'Volumetric Solar Irradiance at {volumetric_height}m (W/m²)')
|
|
3198
|
+
plt.title(f'Volumetric Irradiance (reflections={"on" if with_reflections else "off"})')
|
|
3199
|
+
plt.axis('off')
|
|
3200
|
+
plt.show()
|
|
3201
|
+
except ImportError:
|
|
3202
|
+
pass
|
|
3203
|
+
|
|
3204
|
+
return volumetric_map
|
|
3205
|
+
|
|
3206
|
+
|
|
3207
|
+
def get_cumulative_volumetric_solar_irradiance(
|
|
3208
|
+
voxcity,
|
|
3209
|
+
df,
|
|
3210
|
+
lon: float,
|
|
3211
|
+
lat: float,
|
|
3212
|
+
tz: float,
|
|
3213
|
+
direct_normal_irradiance_scaling: float = 1.0,
|
|
3214
|
+
diffuse_irradiance_scaling: float = 1.0,
|
|
3215
|
+
volumetric_height: float = 1.5,
|
|
3216
|
+
with_reflections: bool = False,
|
|
3217
|
+
show_plot: bool = False,
|
|
3218
|
+
**kwargs
|
|
3219
|
+
) -> np.ndarray:
|
|
3220
|
+
"""
|
|
3221
|
+
GPU-accelerated cumulative volumetric solar irradiance over a period.
|
|
3222
|
+
|
|
3223
|
+
Integrates the 3D radiation field over time and extracts a 2D horizontal
|
|
3224
|
+
slice at the specified height.
|
|
3225
|
+
|
|
3226
|
+
Args:
|
|
3227
|
+
voxcity: VoxCity object
|
|
3228
|
+
df: pandas DataFrame with 'DNI' and 'DHI' columns, datetime-indexed
|
|
3229
|
+
lon: Longitude in degrees
|
|
3230
|
+
lat: Latitude in degrees
|
|
3231
|
+
tz: Timezone offset in hours
|
|
3232
|
+
direct_normal_irradiance_scaling: Scaling factor for DNI
|
|
3233
|
+
diffuse_irradiance_scaling: Scaling factor for DHI
|
|
3234
|
+
volumetric_height: Height above ground for irradiance extraction (meters)
|
|
3235
|
+
with_reflections: If True, include reflected radiation from buildings,
|
|
3236
|
+
ground, and tree canopy surfaces. If False (default), only direct
|
|
3237
|
+
+ diffuse sky radiation.
|
|
3238
|
+
show_plot: Whether to display a matplotlib plot
|
|
3239
|
+
**kwargs: Additional parameters:
|
|
3240
|
+
- start_time (str): Start time 'MM-DD HH:MM:SS' (default: '01-01 05:00:00')
|
|
3241
|
+
- end_time (str): End time 'MM-DD HH:MM:SS' (default: '01-01 20:00:00')
|
|
3242
|
+
- use_sky_patches (bool): Use sky patch optimization (default: True)
|
|
3243
|
+
- sky_discretization (str): 'tregenza', 'reinhart', 'uniform', 'fibonacci'
|
|
3244
|
+
- computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
|
|
3245
|
+
Grid cells outside the masked region are set to NaN.
|
|
3246
|
+
- progress_report (bool): Print progress (default: False)
|
|
3247
|
+
- n_reflection_steps (int): Reflection bounces when with_reflections=True (default: 2)
|
|
3248
|
+
|
|
3249
|
+
Returns:
|
|
3250
|
+
2D numpy array of cumulative volumetric irradiance at the specified height (Wh/m²)
|
|
3251
|
+
"""
|
|
3252
|
+
import time
|
|
3253
|
+
from datetime import datetime
|
|
3254
|
+
import pytz
|
|
3255
|
+
import math
|
|
3256
|
+
|
|
3257
|
+
# Extract parameters
|
|
3258
|
+
kwargs = kwargs.copy()
|
|
3259
|
+
progress_report = kwargs.pop('progress_report', False)
|
|
3260
|
+
start_time = kwargs.pop('start_time', '01-01 05:00:00')
|
|
3261
|
+
end_time = kwargs.pop('end_time', '01-01 20:00:00')
|
|
3262
|
+
use_sky_patches = kwargs.pop('use_sky_patches', True)
|
|
3263
|
+
sky_discretization = kwargs.pop('sky_discretization', 'tregenza')
|
|
3264
|
+
n_azimuth = kwargs.pop('n_azimuth', 36)
|
|
3265
|
+
n_zenith = kwargs.pop('n_zenith', 9)
|
|
3266
|
+
computation_mask = kwargs.pop('computation_mask', None)
|
|
3267
|
+
|
|
3268
|
+
if df.empty:
|
|
3269
|
+
raise ValueError("No data in EPW dataframe.")
|
|
3270
|
+
|
|
3271
|
+
# Parse time range
|
|
3272
|
+
try:
|
|
3273
|
+
start_dt = datetime.strptime(start_time, '%m-%d %H:%M:%S')
|
|
3274
|
+
end_dt = datetime.strptime(end_time, '%m-%d %H:%M:%S')
|
|
3275
|
+
except ValueError as ve:
|
|
3276
|
+
raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
|
|
3277
|
+
|
|
3278
|
+
# Filter dataframe to period
|
|
3279
|
+
df = df.copy()
|
|
3280
|
+
df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
|
|
3281
|
+
start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
|
|
3282
|
+
end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
|
|
3283
|
+
start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
|
|
3284
|
+
end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
|
|
3285
|
+
|
|
3286
|
+
if start_hour <= end_hour:
|
|
3287
|
+
df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
|
|
3288
|
+
else:
|
|
3289
|
+
df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
|
|
3290
|
+
|
|
3291
|
+
if df_period.empty:
|
|
3292
|
+
raise ValueError("No EPW data in the specified period.")
|
|
3293
|
+
|
|
3294
|
+
# Localize and convert to UTC
|
|
3295
|
+
offset_minutes = int(tz * 60)
|
|
3296
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
3297
|
+
df_period_local = df_period.copy()
|
|
3298
|
+
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
3299
|
+
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
3300
|
+
|
|
3301
|
+
# Get solar positions
|
|
3302
|
+
solar_positions = _get_solar_positions_astral(df_period_utc.index, lon, lat)
|
|
3303
|
+
|
|
3304
|
+
# Get or create cached calculator
|
|
3305
|
+
calculator, domain = _get_or_create_volumetric_calculator(
|
|
3306
|
+
voxcity,
|
|
3307
|
+
n_azimuth=n_azimuth,
|
|
3308
|
+
n_zenith=n_zenith,
|
|
3309
|
+
progress_report=progress_report,
|
|
3310
|
+
**kwargs
|
|
3311
|
+
)
|
|
3312
|
+
|
|
3313
|
+
voxel_data = voxcity.voxels.classes
|
|
3314
|
+
meshsize = voxcity.voxels.meta.meshsize
|
|
3315
|
+
ni, nj, nk = voxel_data.shape
|
|
3316
|
+
|
|
3317
|
+
# Compute terrain-following extraction parameters
|
|
3318
|
+
height_offset_k = max(1, int(round(volumetric_height / meshsize)))
|
|
3319
|
+
if progress_report:
|
|
3320
|
+
print(f"Extracting volumetric irradiance at {volumetric_height}m above terrain (offset={height_offset_k} cells)")
|
|
3321
|
+
|
|
3322
|
+
# Get is_solid array for masking
|
|
3323
|
+
is_solid = domain.is_solid.to_numpy()
|
|
3324
|
+
|
|
3325
|
+
# Initialize cumulative map (will be NaN-masked at the end)
|
|
3326
|
+
cumulative_map = np.zeros((ni, nj), dtype=np.float64)
|
|
3327
|
+
time_step_hours = kwargs.get('time_step_hours', 1.0)
|
|
3328
|
+
|
|
3329
|
+
# Get radiation model for reflections if needed
|
|
3330
|
+
model = None
|
|
3331
|
+
ground_k = None
|
|
3332
|
+
if with_reflections:
|
|
3333
|
+
n_reflection_steps = kwargs.pop('n_reflection_steps', 2)
|
|
3334
|
+
model, valid_ground, ground_k = _get_or_create_radiation_model(
|
|
3335
|
+
voxcity,
|
|
3336
|
+
n_reflection_steps=n_reflection_steps,
|
|
3337
|
+
progress_report=progress_report,
|
|
3338
|
+
**kwargs
|
|
3339
|
+
)
|
|
3340
|
+
else:
|
|
3341
|
+
# Compute ground_k for terrain-following extraction
|
|
3342
|
+
ground_k = _compute_ground_k_from_voxels(voxel_data)
|
|
3343
|
+
|
|
3344
|
+
# OPTIMIZATION: Initialize GPU-side cumulative accumulation
|
|
3345
|
+
# This avoids transferring full 3D arrays for each patch/timestep
|
|
3346
|
+
calculator.init_cumulative_accumulation(
|
|
3347
|
+
ground_k=ground_k,
|
|
3348
|
+
height_offset_k=height_offset_k,
|
|
3349
|
+
is_solid=is_solid
|
|
3350
|
+
)
|
|
3351
|
+
|
|
3352
|
+
# OPTIMIZATION: Pre-compute Terrain-to-Surface VF matrix for cached reflections
|
|
3353
|
+
# This makes reflection computation O(nnz) instead of O(N_cells * N_surfaces)
|
|
3354
|
+
# for each sky patch, providing massive speedup for cumulative calculations.
|
|
3355
|
+
if with_reflections and model is not None:
|
|
3356
|
+
t2s_start = time.perf_counter() if progress_report else 0
|
|
3357
|
+
calculator.compute_t2s_matrix(
|
|
3358
|
+
surfaces=model.surfaces,
|
|
3359
|
+
min_vf_threshold=1e-6,
|
|
3360
|
+
progress_report=progress_report
|
|
3361
|
+
)
|
|
3362
|
+
if progress_report:
|
|
3363
|
+
t2s_elapsed = time.perf_counter() - t2s_start
|
|
3364
|
+
print(f" T2S matrix pre-computation: {t2s_elapsed:.2f}s")
|
|
3365
|
+
|
|
3366
|
+
# Extract arrays
|
|
3367
|
+
azimuth_arr = solar_positions['azimuth'].to_numpy()
|
|
3368
|
+
elevation_arr = solar_positions['elevation'].to_numpy()
|
|
3369
|
+
dni_arr = df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling
|
|
3370
|
+
dhi_arr = df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling
|
|
3371
|
+
|
|
3372
|
+
n_timesteps = len(azimuth_arr)
|
|
3373
|
+
|
|
3374
|
+
if progress_report:
|
|
3375
|
+
print(f"Computing cumulative volumetric irradiance for {n_timesteps} timesteps...")
|
|
3376
|
+
print(f" Height: {volumetric_height}m, Reflections: {'on' if with_reflections else 'off'}")
|
|
3377
|
+
print(f" Using GPU-optimized terrain-following accumulation")
|
|
3378
|
+
|
|
3379
|
+
t0 = time.perf_counter() if progress_report else 0
|
|
3380
|
+
|
|
3381
|
+
if use_sky_patches:
|
|
3382
|
+
# Use sky patch aggregation for efficiency
|
|
3383
|
+
from .sky import (
|
|
3384
|
+
generate_tregenza_patches,
|
|
3385
|
+
generate_reinhart_patches,
|
|
3386
|
+
generate_uniform_grid_patches,
|
|
3387
|
+
generate_fibonacci_patches,
|
|
3388
|
+
get_tregenza_patch_index
|
|
3389
|
+
)
|
|
3390
|
+
|
|
3391
|
+
# Generate sky patches
|
|
3392
|
+
if sky_discretization.lower() == 'tregenza':
|
|
3393
|
+
patches, directions, solid_angles = generate_tregenza_patches()
|
|
3394
|
+
elif sky_discretization.lower() == 'reinhart':
|
|
3395
|
+
mf = kwargs.get('reinhart_mf', kwargs.get('mf', 4))
|
|
3396
|
+
patches, directions, solid_angles = generate_reinhart_patches(mf=mf)
|
|
3397
|
+
elif sky_discretization.lower() == 'uniform':
|
|
3398
|
+
n_az = kwargs.get('sky_n_azimuth', 36)
|
|
3399
|
+
n_el = kwargs.get('sky_n_elevation', 9)
|
|
3400
|
+
patches, directions, solid_angles = generate_uniform_grid_patches(n_az, n_el)
|
|
3401
|
+
elif sky_discretization.lower() == 'fibonacci':
|
|
3402
|
+
n_patches = kwargs.get('sky_n_patches', 145)
|
|
3403
|
+
patches, directions, solid_angles = generate_fibonacci_patches(n_patches=n_patches)
|
|
3404
|
+
else:
|
|
3405
|
+
raise ValueError(f"Unknown sky discretization method: {sky_discretization}")
|
|
3406
|
+
|
|
3407
|
+
n_patches = len(patches)
|
|
3408
|
+
cumulative_dni = np.zeros(n_patches, dtype=np.float64)
|
|
3409
|
+
total_dhi = 0.0
|
|
3410
|
+
|
|
3411
|
+
# Bin sun positions to patches
|
|
3412
|
+
for i in range(n_timesteps):
|
|
3413
|
+
elev = elevation_arr[i]
|
|
3414
|
+
if elev <= 0:
|
|
3415
|
+
continue
|
|
3416
|
+
|
|
3417
|
+
az = azimuth_arr[i]
|
|
3418
|
+
dni = dni_arr[i]
|
|
3419
|
+
dhi = dhi_arr[i]
|
|
3420
|
+
|
|
3421
|
+
if dni > 0:
|
|
3422
|
+
patch_idx = get_tregenza_patch_index(az, elev)
|
|
3423
|
+
if 0 <= patch_idx < n_patches:
|
|
3424
|
+
cumulative_dni[patch_idx] += dni * time_step_hours
|
|
3425
|
+
|
|
3426
|
+
if dhi > 0:
|
|
3427
|
+
total_dhi += dhi * time_step_hours
|
|
3428
|
+
|
|
3429
|
+
# Process each patch with accumulated DNI
|
|
3430
|
+
patches_with_dni = np.where(cumulative_dni > 0)[0]
|
|
3431
|
+
|
|
3432
|
+
if progress_report:
|
|
3433
|
+
print(f" Processing {len(patches_with_dni)} sky patches with accumulated DNI...")
|
|
3434
|
+
|
|
3435
|
+
for idx, patch_idx in enumerate(patches_with_dni):
|
|
3436
|
+
patch_dni = cumulative_dni[patch_idx]
|
|
3437
|
+
patch_dir = directions[patch_idx]
|
|
3438
|
+
|
|
3439
|
+
# Convert patch direction to azimuth/elevation
|
|
3440
|
+
patch_azimuth_ori = math.degrees(math.atan2(patch_dir[0], patch_dir[1]))
|
|
3441
|
+
if patch_azimuth_ori < 0:
|
|
3442
|
+
patch_azimuth_ori += 360
|
|
3443
|
+
patch_elevation = math.degrees(math.asin(patch_dir[2]))
|
|
3444
|
+
|
|
3445
|
+
# Apply same coordinate transform as ground-level functions
|
|
3446
|
+
patch_azimuth = 180 - patch_azimuth_ori
|
|
3447
|
+
azimuth_rad = math.radians(patch_azimuth)
|
|
3448
|
+
elevation_rad = math.radians(patch_elevation)
|
|
3449
|
+
cos_elev = math.cos(elevation_rad)
|
|
3450
|
+
sin_elev = math.sin(elevation_rad)
|
|
3451
|
+
|
|
3452
|
+
cos_zenith = sin_elev
|
|
3453
|
+
sun_dir_x = cos_elev * math.cos(azimuth_rad)
|
|
3454
|
+
sun_dir_y = cos_elev * math.sin(azimuth_rad)
|
|
3455
|
+
sun_dir_z = sin_elev
|
|
3456
|
+
sun_direction = (sun_dir_x, sun_dir_y, sun_dir_z)
|
|
3457
|
+
|
|
3458
|
+
if with_reflections and model is not None:
|
|
3459
|
+
# Set solar position on the model
|
|
3460
|
+
model.solar_calc.cos_zenith[None] = cos_zenith
|
|
3461
|
+
model.solar_calc.sun_direction[None] = [sun_direction[0], sun_direction[1], sun_direction[2]]
|
|
3462
|
+
model.solar_calc.sun_up[None] = 1 if cos_zenith > 0 else 0
|
|
3463
|
+
|
|
3464
|
+
# Compute surface irradiance with reflections (uses cached SVF matrix)
|
|
3465
|
+
model.compute_shortwave_radiation(
|
|
3466
|
+
sw_direct=patch_dni / time_step_hours, # Instantaneous for reflection calc
|
|
3467
|
+
sw_diffuse=0.0 # DHI handled separately
|
|
3468
|
+
)
|
|
3469
|
+
|
|
3470
|
+
n_surfaces = model.surfaces.count
|
|
3471
|
+
surf_outgoing = model.surfaces.sw_out.to_numpy()[:n_surfaces]
|
|
3472
|
+
|
|
3473
|
+
# OPTIMIZED: Compute direct+diffuse for full volume first
|
|
3474
|
+
calculator.compute_swflux_vol(
|
|
3475
|
+
sw_direct=patch_dni / time_step_hours,
|
|
3476
|
+
sw_diffuse=0.0,
|
|
3477
|
+
cos_zenith=cos_zenith,
|
|
3478
|
+
sun_direction=sun_direction,
|
|
3479
|
+
lad=domain.lad
|
|
3480
|
+
)
|
|
3481
|
+
|
|
3482
|
+
# OPTIMIZED: Compute reflections using pre-computed T2S-VF matrix
|
|
3483
|
+
# This is O(nnz) instead of O(N_terrain_cells * N_surfaces) per patch
|
|
3484
|
+
calculator.compute_reflected_flux_terrain_cached(surf_outgoing=surf_outgoing)
|
|
3485
|
+
|
|
3486
|
+
# Add reflections to swflux_vol at extraction level
|
|
3487
|
+
calculator._add_reflected_to_total()
|
|
3488
|
+
else:
|
|
3489
|
+
calculator.compute_swflux_vol(
|
|
3490
|
+
sw_direct=patch_dni / time_step_hours,
|
|
3491
|
+
sw_diffuse=0.0,
|
|
3492
|
+
cos_zenith=cos_zenith,
|
|
3493
|
+
sun_direction=sun_direction,
|
|
3494
|
+
lad=domain.lad
|
|
3495
|
+
)
|
|
3496
|
+
|
|
3497
|
+
# OPTIMIZATION: Accumulate terrain-following slice directly on GPU
|
|
3498
|
+
# This avoids transferring the full 3D array for each patch
|
|
3499
|
+
calculator.accumulate_terrain_following_slice_gpu(weight=time_step_hours)
|
|
3500
|
+
|
|
3501
|
+
if progress_report and (idx + 1) % 10 == 0:
|
|
3502
|
+
elapsed = time.perf_counter() - t0
|
|
3503
|
+
print(f" Processed {idx + 1}/{len(patches_with_dni)} patches ({elapsed:.1f}s)")
|
|
3504
|
+
|
|
3505
|
+
# Add diffuse contribution using GPU-optimized SVF accumulation
|
|
3506
|
+
if total_dhi > 0:
|
|
3507
|
+
calculator.accumulate_svf_diffuse_gpu(total_dhi=total_dhi)
|
|
3508
|
+
|
|
3509
|
+
else:
|
|
3510
|
+
# Process each timestep individually
|
|
3511
|
+
for i in range(n_timesteps):
|
|
3512
|
+
elev = elevation_arr[i]
|
|
3513
|
+
if elev <= 0:
|
|
3514
|
+
continue
|
|
3515
|
+
|
|
3516
|
+
az = azimuth_arr[i]
|
|
3517
|
+
dni = dni_arr[i]
|
|
3518
|
+
dhi = dhi_arr[i]
|
|
3519
|
+
|
|
3520
|
+
if dni <= 0 and dhi <= 0:
|
|
3521
|
+
continue
|
|
3522
|
+
|
|
3523
|
+
# Convert to direction vector
|
|
3524
|
+
# Match the coordinate system used in ground-level functions
|
|
3525
|
+
azimuth_degrees = 180 - az
|
|
3526
|
+
azimuth_rad = math.radians(azimuth_degrees)
|
|
3527
|
+
elevation_rad = math.radians(elev)
|
|
3528
|
+
cos_elev = math.cos(elevation_rad)
|
|
3529
|
+
sin_elev = math.sin(elevation_rad)
|
|
3530
|
+
|
|
3531
|
+
sun_dir_x = cos_elev * math.cos(azimuth_rad)
|
|
3532
|
+
sun_dir_y = cos_elev * math.sin(azimuth_rad)
|
|
3533
|
+
sun_dir_z = sin_elev
|
|
3534
|
+
sun_direction = (sun_dir_x, sun_dir_y, sun_dir_z)
|
|
3535
|
+
cos_zenith = sin_elev
|
|
3536
|
+
|
|
3537
|
+
if with_reflections and model is not None:
|
|
3538
|
+
# Set solar position on the model
|
|
3539
|
+
model.solar_calc.cos_zenith[None] = cos_zenith
|
|
3540
|
+
model.solar_calc.sun_direction[None] = [sun_direction[0], sun_direction[1], sun_direction[2]]
|
|
3541
|
+
model.solar_calc.sun_up[None] = 1 if cos_zenith > 0 else 0
|
|
3542
|
+
|
|
3543
|
+
model.compute_shortwave_radiation(
|
|
3544
|
+
sw_direct=dni,
|
|
3545
|
+
sw_diffuse=dhi
|
|
3546
|
+
)
|
|
3547
|
+
|
|
3548
|
+
n_surfaces = model.surfaces.count
|
|
3549
|
+
surf_outgoing = model.surfaces.sw_out.to_numpy()[:n_surfaces]
|
|
3550
|
+
|
|
3551
|
+
# OPTIMIZED: Compute direct+diffuse for full volume first
|
|
3552
|
+
calculator.compute_swflux_vol(
|
|
3553
|
+
sw_direct=dni,
|
|
3554
|
+
sw_diffuse=dhi,
|
|
3555
|
+
cos_zenith=cos_zenith,
|
|
3556
|
+
sun_direction=sun_direction,
|
|
3557
|
+
lad=domain.lad
|
|
3558
|
+
)
|
|
3559
|
+
|
|
3560
|
+
# OPTIMIZED: Compute reflections using pre-computed T2S-VF matrix
|
|
3561
|
+
# This is O(nnz) instead of O(N_terrain_cells * N_surfaces) per timestep
|
|
3562
|
+
calculator.compute_reflected_flux_terrain_cached(surf_outgoing=surf_outgoing)
|
|
3563
|
+
|
|
3564
|
+
# Add reflections to swflux_vol at extraction level
|
|
3565
|
+
calculator._add_reflected_to_total()
|
|
3566
|
+
else:
|
|
3567
|
+
calculator.compute_swflux_vol(
|
|
3568
|
+
sw_direct=dni,
|
|
3569
|
+
sw_diffuse=dhi,
|
|
3570
|
+
cos_zenith=cos_zenith,
|
|
3571
|
+
sun_direction=sun_direction,
|
|
3572
|
+
lad=domain.lad
|
|
3573
|
+
)
|
|
3574
|
+
|
|
3575
|
+
# OPTIMIZATION: Accumulate terrain-following slice directly on GPU
|
|
3576
|
+
calculator.accumulate_terrain_following_slice_gpu(weight=time_step_hours)
|
|
3577
|
+
|
|
3578
|
+
if progress_report and (i + 1) % 100 == 0:
|
|
3579
|
+
elapsed = time.perf_counter() - t0
|
|
3580
|
+
print(f" Processed {i + 1}/{n_timesteps} timesteps ({elapsed:.1f}s)")
|
|
3581
|
+
|
|
3582
|
+
if progress_report:
|
|
3583
|
+
elapsed = time.perf_counter() - t0
|
|
3584
|
+
print(f"Cumulative volumetric irradiance complete in {elapsed:.2f}s")
|
|
3585
|
+
|
|
3586
|
+
# Get final cumulative map from GPU with NaN masking
|
|
3587
|
+
cumulative_map = calculator.finalize_cumulative_map(apply_nan_mask=True)
|
|
3588
|
+
|
|
3589
|
+
# Flip to match VoxCity coordinate system
|
|
3590
|
+
cumulative_map = np.flipud(cumulative_map)
|
|
3591
|
+
|
|
3592
|
+
# Apply computation_mask if provided
|
|
3593
|
+
if computation_mask is not None:
|
|
3594
|
+
# Handle mask shape orientation
|
|
3595
|
+
if computation_mask.shape == cumulative_map.shape:
|
|
3596
|
+
flipped_mask = np.flipud(computation_mask)
|
|
3597
|
+
cumulative_map = np.where(flipped_mask, cumulative_map, np.nan)
|
|
3598
|
+
elif computation_mask.T.shape == cumulative_map.shape:
|
|
3599
|
+
flipped_mask = np.flipud(computation_mask.T)
|
|
3600
|
+
cumulative_map = np.where(flipped_mask, cumulative_map, np.nan)
|
|
3601
|
+
else:
|
|
3602
|
+
# Best effort - try direct application
|
|
3603
|
+
if computation_mask.shape == cumulative_map.shape:
|
|
3604
|
+
cumulative_map = np.where(computation_mask, cumulative_map, np.nan)
|
|
3605
|
+
|
|
3606
|
+
if progress_report:
|
|
3607
|
+
n_masked = np.sum(np.isnan(cumulative_map))
|
|
3608
|
+
total = cumulative_map.size
|
|
3609
|
+
print(f" Cells outside computation_mask set to NaN: {n_masked}/{total} ({100*n_masked/total:.1f}%)")
|
|
3610
|
+
|
|
3611
|
+
if show_plot:
|
|
3612
|
+
colormap = kwargs.get('colormap', 'magma')
|
|
3613
|
+
vmin = kwargs.get('vmin', 0.0)
|
|
3614
|
+
vmax = kwargs.get('vmax', max(float(np.nanmax(cumulative_map)), 1.0))
|
|
3615
|
+
try:
|
|
3616
|
+
import matplotlib.pyplot as plt
|
|
3617
|
+
cmap = plt.cm.get_cmap(colormap).copy()
|
|
3618
|
+
cmap.set_bad(color='lightgray')
|
|
3619
|
+
plt.figure(figsize=(10, 8))
|
|
3620
|
+
plt.imshow(cumulative_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
3621
|
+
plt.colorbar(label=f'Cumulative Volumetric Irradiance at {volumetric_height}m (Wh/m²)')
|
|
3622
|
+
plt.title(f'Cumulative Volumetric Irradiance (reflections={"on" if with_reflections else "off"})')
|
|
3623
|
+
plt.axis('off')
|
|
3624
|
+
plt.show()
|
|
3625
|
+
except ImportError:
|
|
3626
|
+
pass
|
|
3627
|
+
|
|
3628
|
+
return cumulative_map
|
|
3629
|
+
|
|
3630
|
+
|
|
3631
|
+
def get_volumetric_solar_irradiance_using_epw(
|
|
3632
|
+
voxcity,
|
|
3633
|
+
calc_type: str = 'instantaneous',
|
|
3634
|
+
direct_normal_irradiance_scaling: float = 1.0,
|
|
3635
|
+
diffuse_irradiance_scaling: float = 1.0,
|
|
3636
|
+
volumetric_height: float = 1.5,
|
|
3637
|
+
with_reflections: bool = False,
|
|
3638
|
+
show_plot: bool = False,
|
|
3639
|
+
**kwargs
|
|
3640
|
+
) -> np.ndarray:
|
|
3641
|
+
"""
|
|
3642
|
+
GPU-accelerated volumetric solar irradiance from EPW file.
|
|
3643
|
+
|
|
3644
|
+
Computes 3D radiation fields and extracts a 2D horizontal slice at the
|
|
3645
|
+
specified height above ground. This is useful for:
|
|
3646
|
+
- Mean Radiant Temperature (MRT) calculations
|
|
3647
|
+
- Pedestrian thermal comfort analysis
|
|
3648
|
+
- Light availability assessment
|
|
3649
|
+
|
|
3650
|
+
Args:
|
|
3651
|
+
voxcity: VoxCity object
|
|
3652
|
+
calc_type: 'instantaneous' or 'cumulative'
|
|
3653
|
+
direct_normal_irradiance_scaling: Scaling factor for DNI
|
|
3654
|
+
diffuse_irradiance_scaling: Scaling factor for DHI
|
|
3655
|
+
volumetric_height: Height above ground for irradiance extraction (meters)
|
|
3656
|
+
with_reflections: If True, include reflected radiation from buildings,
|
|
3657
|
+
ground, and tree canopy surfaces. If False (default), only direct
|
|
3658
|
+
+ diffuse sky radiation.
|
|
3659
|
+
show_plot: Whether to display a matplotlib plot
|
|
3660
|
+
**kwargs: Additional parameters including:
|
|
3661
|
+
- epw_file_path (str): Path to EPW file
|
|
3662
|
+
- download_nearest_epw (bool): Download nearest EPW (default: False)
|
|
3663
|
+
- calc_time (str): For instantaneous: 'MM-DD HH:MM:SS'
|
|
3664
|
+
- start_time, end_time (str): For cumulative: 'MM-DD HH:MM:SS'
|
|
3665
|
+
- rectangle_vertices: Location vertices (for EPW download)
|
|
3666
|
+
- computation_mask (np.ndarray): Optional 2D boolean mask of shape (nx, ny).
|
|
3667
|
+
Grid cells outside the masked region are set to NaN.
|
|
3668
|
+
- n_reflection_steps (int): Reflection bounces when with_reflections=True (default: 2)
|
|
3669
|
+
|
|
3670
|
+
Returns:
|
|
3671
|
+
2D numpy array of volumetric irradiance at the specified height (W/m² or Wh/m²)
|
|
3672
|
+
"""
|
|
3673
|
+
from datetime import datetime
|
|
3674
|
+
import pytz
|
|
3675
|
+
|
|
3676
|
+
# Load EPW data using helper function
|
|
3677
|
+
kwargs_copy = dict(kwargs)
|
|
3678
|
+
df, lon, lat, tz = _load_epw_data(
|
|
3679
|
+
epw_file_path=kwargs_copy.pop('epw_file_path', None),
|
|
3680
|
+
download_nearest_epw=kwargs_copy.pop('download_nearest_epw', False),
|
|
3681
|
+
voxcity=voxcity,
|
|
3682
|
+
**kwargs_copy
|
|
3683
|
+
)
|
|
3684
|
+
|
|
3685
|
+
if calc_type == 'instantaneous':
|
|
3686
|
+
calc_time = kwargs.get('calc_time', '01-01 12:00:00')
|
|
3687
|
+
try:
|
|
3688
|
+
calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%SS')
|
|
3689
|
+
except ValueError:
|
|
3690
|
+
try:
|
|
3691
|
+
calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%S')
|
|
3692
|
+
except ValueError:
|
|
3693
|
+
raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'")
|
|
3694
|
+
|
|
3695
|
+
df_period = df[
|
|
3696
|
+
(df.index.month == calc_dt.month) &
|
|
3697
|
+
(df.index.day == calc_dt.day) &
|
|
3698
|
+
(df.index.hour == calc_dt.hour)
|
|
3699
|
+
]
|
|
3700
|
+
if df_period.empty:
|
|
3701
|
+
raise ValueError("No EPW data at the specified time.")
|
|
3702
|
+
|
|
3703
|
+
# Get solar position
|
|
3704
|
+
offset_minutes = int(tz * 60)
|
|
3705
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
3706
|
+
df_local = df_period.copy()
|
|
3707
|
+
df_local.index = df_local.index.tz_localize(local_tz)
|
|
3708
|
+
df_utc = df_local.tz_convert(pytz.UTC)
|
|
3709
|
+
|
|
3710
|
+
solar_positions = _get_solar_positions_astral(df_utc.index, lon, lat)
|
|
3711
|
+
DNI = float(df_utc.iloc[0]['DNI']) * direct_normal_irradiance_scaling
|
|
3712
|
+
DHI = float(df_utc.iloc[0]['DHI']) * diffuse_irradiance_scaling
|
|
3713
|
+
azimuth_degrees = float(solar_positions.iloc[0]['azimuth'])
|
|
3714
|
+
elevation_degrees = float(solar_positions.iloc[0]['elevation'])
|
|
3715
|
+
|
|
3716
|
+
return get_volumetric_solar_irradiance_map(
|
|
3717
|
+
voxcity,
|
|
3718
|
+
azimuth_degrees,
|
|
3719
|
+
elevation_degrees,
|
|
3720
|
+
DNI,
|
|
3721
|
+
DHI,
|
|
3722
|
+
volumetric_height=volumetric_height,
|
|
3723
|
+
with_reflections=with_reflections,
|
|
3724
|
+
show_plot=show_plot,
|
|
3725
|
+
**kwargs
|
|
3726
|
+
)
|
|
3727
|
+
|
|
3728
|
+
elif calc_type == 'cumulative':
|
|
3729
|
+
return get_cumulative_volumetric_solar_irradiance(
|
|
3730
|
+
voxcity,
|
|
3731
|
+
df,
|
|
3732
|
+
lon,
|
|
3733
|
+
lat,
|
|
3734
|
+
tz,
|
|
3735
|
+
direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
|
|
3736
|
+
diffuse_irradiance_scaling=diffuse_irradiance_scaling,
|
|
3737
|
+
volumetric_height=volumetric_height,
|
|
3738
|
+
with_reflections=with_reflections,
|
|
3739
|
+
show_plot=show_plot,
|
|
3740
|
+
**kwargs
|
|
3741
|
+
)
|
|
3742
|
+
|
|
3743
|
+
else:
|
|
3744
|
+
raise ValueError(f"Unknown calc_type: {calc_type}. Use 'instantaneous' or 'cumulative'.")
|
|
3745
|
+
|
|
3746
|
+
|
|
3747
|
+
def get_global_solar_irradiance_using_epw(
|
|
3748
|
+
voxcity,
|
|
3749
|
+
temporal_mode: str = 'instantaneous',
|
|
3750
|
+
spatial_mode: str = 'horizontal',
|
|
3751
|
+
direct_normal_irradiance_scaling: float = 1.0,
|
|
3752
|
+
diffuse_irradiance_scaling: float = 1.0,
|
|
3753
|
+
show_plot: bool = False,
|
|
3754
|
+
calc_type: str = None, # Deprecated, for backward compatibility
|
|
3755
|
+
computation_mask: np.ndarray = None,
|
|
3756
|
+
**kwargs
|
|
3757
|
+
) -> np.ndarray:
|
|
3758
|
+
"""
|
|
3759
|
+
GPU-accelerated global irradiance from EPW file.
|
|
3760
|
+
|
|
3761
|
+
This function matches the signature of voxcity.simulator.solar.get_global_solar_irradiance_using_epw
|
|
3762
|
+
using Taichi GPU acceleration.
|
|
3763
|
+
|
|
3764
|
+
Args:
|
|
3765
|
+
voxcity: VoxCity object
|
|
3766
|
+
temporal_mode: Time integration mode:
|
|
3767
|
+
- 'instantaneous': Single time point (requires calc_time)
|
|
3768
|
+
- 'cumulative': Integrate over time range (requires start_time, end_time)
|
|
3769
|
+
spatial_mode: Spatial computation mode:
|
|
3770
|
+
- 'horizontal': 2D ground-level irradiance at view_point_height
|
|
3771
|
+
- 'volumetric': 3D radiation field extracted at volumetric_height above terrain
|
|
3772
|
+
direct_normal_irradiance_scaling: Scaling factor for DNI
|
|
3773
|
+
diffuse_irradiance_scaling: Scaling factor for DHI
|
|
3774
|
+
show_plot: Whether to display a matplotlib plot
|
|
3775
|
+
calc_type: DEPRECATED. Use temporal_mode and spatial_mode instead.
|
|
3776
|
+
Legacy values 'instantaneous', 'cumulative', 'volumetric' are still supported.
|
|
3777
|
+
computation_mask: Optional 2D boolean numpy array of shape (nx, ny).
|
|
3778
|
+
If provided, only cells where mask is True will be computed.
|
|
3779
|
+
Cells where mask is False will be set to NaN in the output.
|
|
3780
|
+
Use create_computation_mask() to create masks easily.
|
|
3781
|
+
**kwargs: Additional parameters including:
|
|
3782
|
+
- epw_file_path (str): Path to EPW file
|
|
3783
|
+
- download_nearest_epw (bool): Download nearest EPW (default: False)
|
|
3784
|
+
- calc_time (str): For instantaneous: 'MM-DD HH:MM:SS'
|
|
3785
|
+
- start_time, end_time (str): For cumulative: 'MM-DD HH:MM:SS'
|
|
3786
|
+
- rectangle_vertices: Location vertices (for EPW download)
|
|
3787
|
+
- view_point_height (float): Height for horizontal mode (default: 1.5)
|
|
3788
|
+
- volumetric_height (float): Height for volumetric mode (default: 1.5)
|
|
3789
|
+
- with_reflections (bool): Include reflections (default: False)
|
|
3790
|
+
- n_reflection_steps (int): Reflection bounces (default: 2)
|
|
3791
|
+
|
|
3792
|
+
Returns:
|
|
3793
|
+
2D numpy array of irradiance (W/m² for instantaneous, Wh/m² for cumulative)
|
|
3794
|
+
|
|
3795
|
+
Examples:
|
|
3796
|
+
# Instantaneous ground-level irradiance
|
|
3797
|
+
grid = get_global_solar_irradiance_using_epw(
|
|
3798
|
+
voxcity,
|
|
3799
|
+
temporal_mode='instantaneous',
|
|
3800
|
+
spatial_mode='horizontal',
|
|
3801
|
+
calc_time='08-03 10:00:00',
|
|
3802
|
+
epw_file_path='weather.epw'
|
|
3803
|
+
)
|
|
3804
|
+
|
|
3805
|
+
# Cumulative volumetric irradiance with reflections
|
|
3806
|
+
grid = get_global_solar_irradiance_using_epw(
|
|
3807
|
+
voxcity,
|
|
3808
|
+
temporal_mode='cumulative',
|
|
3809
|
+
spatial_mode='volumetric',
|
|
3810
|
+
start_time='01-01 09:00:00',
|
|
3811
|
+
end_time='01-31 19:00:00',
|
|
3812
|
+
volumetric_height=1.5,
|
|
3813
|
+
with_reflections=True,
|
|
3814
|
+
epw_file_path='weather.epw'
|
|
3815
|
+
)
|
|
3816
|
+
"""
|
|
3817
|
+
from datetime import datetime
|
|
3818
|
+
import pytz
|
|
3819
|
+
import warnings
|
|
3820
|
+
|
|
3821
|
+
# Handle backward compatibility with calc_type parameter
|
|
3822
|
+
if calc_type is not None:
|
|
3823
|
+
warnings.warn(
|
|
3824
|
+
"calc_type parameter is deprecated. Use temporal_mode and spatial_mode instead. "
|
|
3825
|
+
"Example: temporal_mode='cumulative', spatial_mode='volumetric'",
|
|
3826
|
+
DeprecationWarning,
|
|
3827
|
+
stacklevel=2
|
|
3828
|
+
)
|
|
3829
|
+
if calc_type == 'instantaneous':
|
|
3830
|
+
temporal_mode = 'instantaneous'
|
|
3831
|
+
spatial_mode = 'horizontal'
|
|
3832
|
+
elif calc_type == 'cumulative':
|
|
3833
|
+
temporal_mode = 'cumulative'
|
|
3834
|
+
spatial_mode = 'horizontal'
|
|
3835
|
+
elif calc_type == 'volumetric':
|
|
3836
|
+
# Legacy volumetric: determine temporal mode from time parameters
|
|
3837
|
+
spatial_mode = 'volumetric'
|
|
3838
|
+
calc_time = kwargs.get('calc_time', None)
|
|
3839
|
+
start_time = kwargs.get('start_time', None)
|
|
3840
|
+
if calc_time is not None and start_time is None:
|
|
3841
|
+
temporal_mode = 'instantaneous'
|
|
3842
|
+
else:
|
|
3843
|
+
temporal_mode = 'cumulative'
|
|
3844
|
+
else:
|
|
3845
|
+
raise ValueError(f"Unknown calc_type: {calc_type}. Use temporal_mode/spatial_mode instead.")
|
|
3846
|
+
|
|
3847
|
+
# Validate parameters
|
|
3848
|
+
if temporal_mode not in ('instantaneous', 'cumulative'):
|
|
3849
|
+
raise ValueError(f"temporal_mode must be 'instantaneous' or 'cumulative', got '{temporal_mode}'")
|
|
3850
|
+
if spatial_mode not in ('horizontal', 'volumetric'):
|
|
3851
|
+
raise ValueError(f"spatial_mode must be 'horizontal' or 'volumetric', got '{spatial_mode}'")
|
|
3852
|
+
|
|
3853
|
+
# Load EPW data using helper function
|
|
3854
|
+
kwargs_copy = dict(kwargs)
|
|
3855
|
+
df, lon, lat, tz = _load_epw_data(
|
|
3856
|
+
epw_file_path=kwargs_copy.pop('epw_file_path', None),
|
|
3857
|
+
download_nearest_epw=kwargs_copy.pop('download_nearest_epw', False),
|
|
3858
|
+
voxcity=voxcity,
|
|
3859
|
+
**kwargs_copy
|
|
3860
|
+
)
|
|
3861
|
+
|
|
3862
|
+
# Add computation_mask to kwargs for passing to underlying functions
|
|
3863
|
+
if computation_mask is not None:
|
|
3864
|
+
kwargs['computation_mask'] = computation_mask
|
|
3865
|
+
|
|
3866
|
+
# Route to appropriate function based on temporal_mode × spatial_mode
|
|
3867
|
+
if spatial_mode == 'horizontal':
|
|
3868
|
+
# Ground-level horizontal irradiance
|
|
3869
|
+
if temporal_mode == 'instantaneous':
|
|
3870
|
+
calc_time = kwargs.get('calc_time', '01-01 12:00:00')
|
|
3871
|
+
try:
|
|
3872
|
+
calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%S')
|
|
3873
|
+
except ValueError:
|
|
3874
|
+
raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'")
|
|
3875
|
+
|
|
3876
|
+
df_period = df[
|
|
3877
|
+
(df.index.month == calc_dt.month) &
|
|
3878
|
+
(df.index.day == calc_dt.day) &
|
|
3879
|
+
(df.index.hour == calc_dt.hour)
|
|
3880
|
+
]
|
|
3881
|
+
if df_period.empty:
|
|
3882
|
+
raise ValueError("No EPW data at the specified time.")
|
|
3883
|
+
|
|
3884
|
+
offset_minutes = int(tz * 60)
|
|
3885
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
3886
|
+
df_local = df_period.copy()
|
|
3887
|
+
df_local.index = df_local.index.tz_localize(local_tz)
|
|
3888
|
+
df_utc = df_local.tz_convert(pytz.UTC)
|
|
3889
|
+
|
|
3890
|
+
solar_positions = _get_solar_positions_astral(df_utc.index, lon, lat)
|
|
3891
|
+
DNI = float(df_utc.iloc[0]['DNI']) * direct_normal_irradiance_scaling
|
|
3892
|
+
DHI = float(df_utc.iloc[0]['DHI']) * diffuse_irradiance_scaling
|
|
3893
|
+
azimuth_degrees = float(solar_positions.iloc[0]['azimuth'])
|
|
3894
|
+
elevation_degrees = float(solar_positions.iloc[0]['elevation'])
|
|
3895
|
+
|
|
3896
|
+
return get_global_solar_irradiance_map(
|
|
3897
|
+
voxcity,
|
|
3898
|
+
azimuth_degrees,
|
|
3899
|
+
elevation_degrees,
|
|
3900
|
+
DNI,
|
|
3901
|
+
DHI,
|
|
3902
|
+
show_plot=show_plot,
|
|
3903
|
+
**kwargs
|
|
3904
|
+
)
|
|
3905
|
+
|
|
3906
|
+
else: # cumulative
|
|
3907
|
+
return get_cumulative_global_solar_irradiance(
|
|
3908
|
+
voxcity,
|
|
3909
|
+
df,
|
|
3910
|
+
lon,
|
|
3911
|
+
lat,
|
|
3912
|
+
tz,
|
|
3913
|
+
direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
|
|
3914
|
+
diffuse_irradiance_scaling=diffuse_irradiance_scaling,
|
|
3915
|
+
show_plot=show_plot,
|
|
3916
|
+
**kwargs
|
|
3917
|
+
)
|
|
3918
|
+
|
|
3919
|
+
else: # volumetric
|
|
3920
|
+
# 3D volumetric radiation field
|
|
3921
|
+
volumetric_height = kwargs.pop('volumetric_height', kwargs.pop('view_point_height', 1.5))
|
|
3922
|
+
with_reflections = kwargs.pop('with_reflections', False)
|
|
3923
|
+
|
|
3924
|
+
if temporal_mode == 'instantaneous':
|
|
3925
|
+
calc_time = kwargs.get('calc_time', '01-01 12:00:00')
|
|
3926
|
+
try:
|
|
3927
|
+
calc_dt = datetime.strptime(calc_time, '%m-%d %H:%M:%S')
|
|
3928
|
+
except ValueError:
|
|
3929
|
+
raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'")
|
|
3930
|
+
|
|
3931
|
+
df_period = df[
|
|
3932
|
+
(df.index.month == calc_dt.month) &
|
|
3933
|
+
(df.index.day == calc_dt.day) &
|
|
3934
|
+
(df.index.hour == calc_dt.hour)
|
|
3935
|
+
]
|
|
3936
|
+
if df_period.empty:
|
|
3937
|
+
raise ValueError("No EPW data at the specified time.")
|
|
3938
|
+
|
|
3939
|
+
offset_minutes = int(tz * 60)
|
|
3940
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
3941
|
+
df_local = df_period.copy()
|
|
3942
|
+
df_local.index = df_local.index.tz_localize(local_tz)
|
|
3943
|
+
df_utc = df_local.tz_convert(pytz.UTC)
|
|
3944
|
+
|
|
3945
|
+
solar_positions = _get_solar_positions_astral(df_utc.index, lon, lat)
|
|
3946
|
+
DNI = float(df_utc.iloc[0]['DNI']) * direct_normal_irradiance_scaling
|
|
3947
|
+
DHI = float(df_utc.iloc[0]['DHI']) * diffuse_irradiance_scaling
|
|
3948
|
+
azimuth_degrees = float(solar_positions.iloc[0]['azimuth'])
|
|
3949
|
+
elevation_degrees = float(solar_positions.iloc[0]['elevation'])
|
|
3950
|
+
|
|
3951
|
+
return get_volumetric_solar_irradiance_map(
|
|
3952
|
+
voxcity,
|
|
3953
|
+
azimuth_degrees,
|
|
3954
|
+
elevation_degrees,
|
|
3955
|
+
DNI,
|
|
3956
|
+
DHI,
|
|
3957
|
+
volumetric_height=volumetric_height,
|
|
3958
|
+
with_reflections=with_reflections,
|
|
3959
|
+
show_plot=show_plot,
|
|
3960
|
+
**kwargs
|
|
3961
|
+
)
|
|
3962
|
+
|
|
3963
|
+
else: # cumulative
|
|
3964
|
+
return get_cumulative_volumetric_solar_irradiance(
|
|
3965
|
+
voxcity,
|
|
3966
|
+
df,
|
|
3967
|
+
lon,
|
|
3968
|
+
lat,
|
|
3969
|
+
tz,
|
|
3970
|
+
direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
|
|
3971
|
+
diffuse_irradiance_scaling=diffuse_irradiance_scaling,
|
|
3972
|
+
volumetric_height=volumetric_height,
|
|
3973
|
+
with_reflections=with_reflections,
|
|
3974
|
+
show_plot=show_plot,
|
|
3975
|
+
**kwargs
|
|
3976
|
+
)
|
|
3977
|
+
|
|
3978
|
+
|
|
3979
|
+
def save_irradiance_mesh(mesh, filepath: str) -> None:
|
|
3980
|
+
"""
|
|
3981
|
+
Save irradiance mesh to pickle file.
|
|
3982
|
+
|
|
3983
|
+
Args:
|
|
3984
|
+
mesh: Trimesh object with irradiance metadata
|
|
3985
|
+
filepath: Output file path
|
|
3986
|
+
"""
|
|
3987
|
+
import pickle
|
|
3988
|
+
with open(filepath, 'wb') as f:
|
|
3989
|
+
pickle.dump(mesh, f)
|
|
3990
|
+
|
|
3991
|
+
|
|
3992
|
+
def load_irradiance_mesh(filepath: str):
|
|
3993
|
+
"""
|
|
3994
|
+
Load irradiance mesh from pickle file.
|
|
3995
|
+
|
|
3996
|
+
Args:
|
|
3997
|
+
filepath: Input file path
|
|
3998
|
+
|
|
3999
|
+
Returns:
|
|
4000
|
+
Trimesh object with irradiance metadata
|
|
4001
|
+
"""
|
|
4002
|
+
import pickle
|
|
4003
|
+
with open(filepath, 'rb') as f:
|
|
4004
|
+
return pickle.load(f)
|
|
4005
|
+
|
|
4006
|
+
|
|
4007
|
+
# =============================================================================
|
|
4008
|
+
# Internal Helper Functions
|
|
4009
|
+
# =============================================================================
|
|
4010
|
+
|
|
4011
|
+
# Module-level cache for GPU ray tracer fields
|
|
4012
|
+
@dataclass
|
|
4013
|
+
class _CachedGPURayTracer:
|
|
4014
|
+
"""Cached Taichi fields for GPU ray tracing."""
|
|
4015
|
+
is_solid_field: object # ti.field
|
|
4016
|
+
lad_field: object # ti.field
|
|
4017
|
+
transmittance_field: object # ti.field
|
|
4018
|
+
topo_top_field: object # ti.field
|
|
4019
|
+
trace_rays_kernel: object # compiled kernel
|
|
4020
|
+
voxel_shape: Tuple[int, int, int]
|
|
4021
|
+
meshsize: float
|
|
4022
|
+
voxel_data_id: int = 0 # id() of last voxel_data array to detect changes
|
|
4023
|
+
|
|
4024
|
+
|
|
4025
|
+
_gpu_ray_tracer_cache: Optional[_CachedGPURayTracer] = None
|
|
4026
|
+
|
|
4027
|
+
# Module-level cached kernel for topo computation
|
|
4028
|
+
_cached_topo_kernel = None
|
|
4029
|
+
|
|
4030
|
+
|
|
4031
|
+
def _get_cached_topo_kernel():
|
|
4032
|
+
"""Get or create cached topography kernel."""
|
|
4033
|
+
global _cached_topo_kernel
|
|
4034
|
+
if _cached_topo_kernel is not None:
|
|
4035
|
+
return _cached_topo_kernel
|
|
4036
|
+
|
|
4037
|
+
import taichi as ti
|
|
4038
|
+
from ..init_taichi import ensure_initialized
|
|
4039
|
+
ensure_initialized()
|
|
4040
|
+
|
|
4041
|
+
@ti.kernel
|
|
4042
|
+
def _topo_kernel(
|
|
4043
|
+
is_solid_f: ti.template(),
|
|
4044
|
+
topo_f: ti.template(),
|
|
4045
|
+
grid_nz: ti.i32
|
|
4046
|
+
):
|
|
4047
|
+
for i, j in topo_f:
|
|
4048
|
+
max_k = -1
|
|
4049
|
+
for k in range(grid_nz):
|
|
4050
|
+
if is_solid_f[i, j, k] == 1:
|
|
4051
|
+
max_k = k
|
|
4052
|
+
topo_f[i, j] = max_k
|
|
4053
|
+
|
|
4054
|
+
_cached_topo_kernel = _topo_kernel
|
|
4055
|
+
return _cached_topo_kernel
|
|
4056
|
+
|
|
4057
|
+
|
|
4058
|
+
def _compute_topo_gpu(is_solid_field, topo_top_field, nz: int):
|
|
4059
|
+
"""Compute topography (highest solid voxel) using GPU."""
|
|
4060
|
+
kernel = _get_cached_topo_kernel()
|
|
4061
|
+
kernel(is_solid_field, topo_top_field, nz)
|
|
4062
|
+
|
|
4063
|
+
|
|
4064
|
+
# Module-level cached kernel for ray tracing
|
|
4065
|
+
_cached_trace_rays_kernel = None
|
|
4066
|
+
|
|
4067
|
+
|
|
4068
|
+
def _get_cached_trace_rays_kernel():
|
|
4069
|
+
"""Get or create cached ray tracing kernel."""
|
|
4070
|
+
global _cached_trace_rays_kernel
|
|
4071
|
+
if _cached_trace_rays_kernel is not None:
|
|
4072
|
+
return _cached_trace_rays_kernel
|
|
4073
|
+
|
|
4074
|
+
import taichi as ti
|
|
4075
|
+
from ..init_taichi import ensure_initialized
|
|
4076
|
+
ensure_initialized()
|
|
4077
|
+
|
|
4078
|
+
@ti.kernel
|
|
4079
|
+
def trace_rays_kernel(
|
|
4080
|
+
is_solid_f: ti.template(),
|
|
4081
|
+
lad_f: ti.template(),
|
|
4082
|
+
topo_f: ti.template(),
|
|
4083
|
+
trans_f: ti.template(),
|
|
4084
|
+
sun_x: ti.f32, sun_y: ti.f32, sun_z: ti.f32,
|
|
4085
|
+
vhk: ti.i32, ext: ti.f32,
|
|
4086
|
+
dx: ti.f32, step: ti.f32, max_dist: ti.f32,
|
|
4087
|
+
grid_nx: ti.i32, grid_ny: ti.i32, grid_nz: ti.i32
|
|
4088
|
+
):
|
|
4089
|
+
for i, j in trans_f:
|
|
4090
|
+
ground_k = topo_f[i, j]
|
|
4091
|
+
start_k = ground_k + vhk
|
|
4092
|
+
if start_k < 0:
|
|
4093
|
+
start_k = 0
|
|
4094
|
+
if start_k >= grid_nz:
|
|
4095
|
+
start_k = grid_nz - 1
|
|
4096
|
+
|
|
4097
|
+
while start_k < grid_nz - 1 and is_solid_f[i, j, start_k] == 1:
|
|
4098
|
+
start_k += 1
|
|
4099
|
+
|
|
4100
|
+
if is_solid_f[i, j, start_k] == 1:
|
|
4101
|
+
trans_f[i, j] = 0.0
|
|
4102
|
+
else:
|
|
4103
|
+
ox = (float(i) + 0.5) * dx
|
|
4104
|
+
oy = (float(j) + 0.5) * dx
|
|
4105
|
+
oz = (float(start_k) + 0.5) * dx
|
|
4106
|
+
|
|
4107
|
+
trans = 1.0
|
|
4108
|
+
t = step
|
|
4109
|
+
|
|
4110
|
+
while t < max_dist and trans > 0.001:
|
|
4111
|
+
px = ox + sun_x * t
|
|
4112
|
+
py = oy + sun_y * t
|
|
4113
|
+
pz = oz + sun_z * t
|
|
4114
|
+
|
|
4115
|
+
gi = int(px / dx)
|
|
4116
|
+
gj = int(py / dx)
|
|
4117
|
+
gk = int(pz / dx)
|
|
4118
|
+
|
|
4119
|
+
if gi < 0 or gi >= grid_nx or gj < 0 or gj >= grid_ny:
|
|
4120
|
+
break
|
|
4121
|
+
if gk < 0 or gk >= grid_nz:
|
|
4122
|
+
break
|
|
4123
|
+
|
|
4124
|
+
if is_solid_f[gi, gj, gk] == 1:
|
|
4125
|
+
trans = 0.0
|
|
4126
|
+
break
|
|
4127
|
+
|
|
4128
|
+
lad_val = lad_f[gi, gj, gk]
|
|
4129
|
+
if lad_val > 0.0:
|
|
4130
|
+
trans *= ti.exp(-ext * lad_val * step)
|
|
4131
|
+
|
|
4132
|
+
t += step
|
|
4133
|
+
|
|
4134
|
+
trans_f[i, j] = trans
|
|
4135
|
+
|
|
4136
|
+
_cached_trace_rays_kernel = trace_rays_kernel
|
|
4137
|
+
return _cached_trace_rays_kernel
|
|
4138
|
+
|
|
4139
|
+
|
|
4140
|
+
def _get_or_create_gpu_ray_tracer(
|
|
4141
|
+
voxel_data: np.ndarray,
|
|
4142
|
+
meshsize: float,
|
|
4143
|
+
tree_lad: float = 1.0
|
|
4144
|
+
) -> _CachedGPURayTracer:
|
|
4145
|
+
"""
|
|
4146
|
+
Get cached GPU ray tracer or create new one if cache is invalid.
|
|
4147
|
+
|
|
4148
|
+
The Taichi fields and kernels are expensive to create, so we cache them.
|
|
4149
|
+
"""
|
|
4150
|
+
global _gpu_ray_tracer_cache
|
|
4151
|
+
|
|
4152
|
+
import taichi as ti
|
|
4153
|
+
from ..init_taichi import ensure_initialized
|
|
4154
|
+
ensure_initialized()
|
|
4155
|
+
|
|
4156
|
+
nx, ny, nz = voxel_data.shape
|
|
4157
|
+
|
|
4158
|
+
# Check if cache is valid
|
|
4159
|
+
if _gpu_ray_tracer_cache is not None:
|
|
4160
|
+
cache = _gpu_ray_tracer_cache
|
|
4161
|
+
if cache.voxel_shape == (nx, ny, nz) and cache.meshsize == meshsize:
|
|
4162
|
+
# Check if voxel data has changed (same array object = same data)
|
|
4163
|
+
if cache.voxel_data_id == id(voxel_data):
|
|
4164
|
+
# Data hasn't changed, reuse cached fields directly
|
|
4165
|
+
return cache
|
|
4166
|
+
|
|
4167
|
+
# Data changed, need to re-upload (but keep fields)
|
|
4168
|
+
# Use vectorized helper
|
|
4169
|
+
is_solid, lad_array = _convert_voxel_data_to_arrays(voxel_data, tree_lad)
|
|
4170
|
+
|
|
4171
|
+
cache.is_solid_field.from_numpy(is_solid)
|
|
4172
|
+
cache.lad_field.from_numpy(lad_array)
|
|
4173
|
+
cache.voxel_data_id = id(voxel_data)
|
|
4174
|
+
|
|
4175
|
+
# Recompute topo
|
|
4176
|
+
_compute_topo_gpu(cache.is_solid_field, cache.topo_top_field, nz)
|
|
4177
|
+
return cache
|
|
4178
|
+
|
|
4179
|
+
# Need to create new cache - use vectorized helper
|
|
4180
|
+
is_solid, lad_array = _convert_voxel_data_to_arrays(voxel_data, tree_lad)
|
|
4181
|
+
|
|
4182
|
+
# Create Taichi fields
|
|
4183
|
+
is_solid_field = ti.field(dtype=ti.i32, shape=(nx, ny, nz))
|
|
4184
|
+
lad_field = ti.field(dtype=ti.f32, shape=(nx, ny, nz))
|
|
4185
|
+
transmittance_field = ti.field(dtype=ti.f32, shape=(nx, ny))
|
|
4186
|
+
topo_top_field = ti.field(dtype=ti.i32, shape=(nx, ny))
|
|
4187
|
+
|
|
4188
|
+
is_solid_field.from_numpy(is_solid)
|
|
4189
|
+
lad_field.from_numpy(lad_array)
|
|
4190
|
+
|
|
4191
|
+
# Compute topography using cached kernel
|
|
4192
|
+
_compute_topo_gpu(is_solid_field, topo_top_field, nz)
|
|
4193
|
+
|
|
4194
|
+
# Get cached ray tracing kernel
|
|
4195
|
+
trace_rays_kernel = _get_cached_trace_rays_kernel()
|
|
4196
|
+
|
|
4197
|
+
# Cache it
|
|
4198
|
+
_gpu_ray_tracer_cache = _CachedGPURayTracer(
|
|
4199
|
+
is_solid_field=is_solid_field,
|
|
4200
|
+
lad_field=lad_field,
|
|
4201
|
+
transmittance_field=transmittance_field,
|
|
4202
|
+
topo_top_field=topo_top_field,
|
|
4203
|
+
trace_rays_kernel=trace_rays_kernel,
|
|
4204
|
+
voxel_shape=(nx, ny, nz),
|
|
4205
|
+
meshsize=meshsize,
|
|
4206
|
+
voxel_data_id=id(voxel_data)
|
|
4207
|
+
)
|
|
4208
|
+
|
|
4209
|
+
return _gpu_ray_tracer_cache
|
|
4210
|
+
|
|
4211
|
+
|
|
4212
|
+
def _compute_direct_transmittance_map_gpu(
|
|
4213
|
+
voxel_data: np.ndarray,
|
|
4214
|
+
sun_direction: Tuple[float, float, float],
|
|
4215
|
+
view_point_height: float,
|
|
4216
|
+
meshsize: float,
|
|
4217
|
+
tree_k: float = 0.6,
|
|
4218
|
+
tree_lad: float = 1.0
|
|
4219
|
+
) -> np.ndarray:
|
|
4220
|
+
"""
|
|
4221
|
+
Compute direct solar transmittance map using GPU ray tracing.
|
|
4222
|
+
|
|
4223
|
+
Returns a 2D array where each cell contains the transmittance (0-1)
|
|
4224
|
+
for direct sunlight from the given direction.
|
|
4225
|
+
|
|
4226
|
+
Uses cached Taichi fields to avoid expensive re-creation.
|
|
4227
|
+
"""
|
|
4228
|
+
nx, ny, nz = voxel_data.shape
|
|
4229
|
+
|
|
4230
|
+
# Get or create cached ray tracer
|
|
4231
|
+
cache = _get_or_create_gpu_ray_tracer(voxel_data, meshsize, tree_lad)
|
|
4232
|
+
|
|
4233
|
+
# Run ray tracing with current sun direction
|
|
4234
|
+
sun_dir_x = float(sun_direction[0])
|
|
4235
|
+
sun_dir_y = float(sun_direction[1])
|
|
4236
|
+
sun_dir_z = float(sun_direction[2])
|
|
4237
|
+
view_height_k = max(1, int(view_point_height / meshsize))
|
|
4238
|
+
step_size = meshsize * 0.5
|
|
4239
|
+
max_trace_dist = float(max(nx, ny, nz) * meshsize * 2)
|
|
4240
|
+
|
|
4241
|
+
cache.trace_rays_kernel(
|
|
4242
|
+
cache.is_solid_field,
|
|
4243
|
+
cache.lad_field,
|
|
4244
|
+
cache.topo_top_field,
|
|
4245
|
+
cache.transmittance_field,
|
|
4246
|
+
sun_dir_x, sun_dir_y, sun_dir_z,
|
|
4247
|
+
view_height_k, tree_k,
|
|
4248
|
+
meshsize, step_size, max_trace_dist,
|
|
4249
|
+
nx, ny, nz # Grid dimensions as parameters
|
|
4250
|
+
)
|
|
4251
|
+
|
|
4252
|
+
return cache.transmittance_field.to_numpy()
|
|
4253
|
+
|
|
4254
|
+
|
|
4255
|
+
def _get_solar_positions_astral(times, lon: float, lat: float):
|
|
4256
|
+
"""
|
|
4257
|
+
Compute solar azimuth and elevation using Astral library.
|
|
4258
|
+
"""
|
|
4259
|
+
import pandas as pd
|
|
4260
|
+
try:
|
|
4261
|
+
from astral import Observer
|
|
4262
|
+
from astral.sun import elevation, azimuth
|
|
4263
|
+
|
|
4264
|
+
observer = Observer(latitude=lat, longitude=lon)
|
|
4265
|
+
df_pos = pd.DataFrame(index=times, columns=['azimuth', 'elevation'], dtype=float)
|
|
4266
|
+
for t in times:
|
|
4267
|
+
el = elevation(observer=observer, dateandtime=t)
|
|
4268
|
+
az = azimuth(observer=observer, dateandtime=t)
|
|
4269
|
+
df_pos.at[t, 'elevation'] = el
|
|
4270
|
+
df_pos.at[t, 'azimuth'] = az
|
|
4271
|
+
return df_pos
|
|
4272
|
+
except ImportError:
|
|
4273
|
+
raise ImportError("Astral library required for solar position calculation. Install with: pip install astral")
|
|
4274
|
+
|
|
4275
|
+
|
|
4276
|
+
# Public alias for VoxCity API compatibility
|
|
4277
|
+
def get_solar_positions_astral(times, lon: float, lat: float):
|
|
4278
|
+
"""
|
|
4279
|
+
Compute solar azimuth and elevation for given times and location using Astral.
|
|
4280
|
+
|
|
4281
|
+
This function matches the signature of voxcity.simulator.solar.get_solar_positions_astral.
|
|
4282
|
+
|
|
4283
|
+
Args:
|
|
4284
|
+
times: Pandas DatetimeIndex of times (should be timezone-aware, preferably UTC)
|
|
4285
|
+
lon: Longitude in degrees
|
|
4286
|
+
lat: Latitude in degrees
|
|
4287
|
+
|
|
4288
|
+
Returns:
|
|
4289
|
+
DataFrame indexed by times with columns ['azimuth', 'elevation'] in degrees
|
|
4290
|
+
"""
|
|
4291
|
+
return _get_solar_positions_astral(times, lon, lat)
|
|
4292
|
+
|
|
4293
|
+
|
|
4294
|
+
def _export_irradiance_to_obj(voxcity, irradiance_map: np.ndarray, output_name: str = 'irradiance', **kwargs):
|
|
4295
|
+
"""Export irradiance map to OBJ file using VoxCity utilities."""
|
|
4296
|
+
try:
|
|
4297
|
+
from voxcity.exporter.obj import grid_to_obj
|
|
4298
|
+
meshsize = voxcity.voxels.meta.meshsize
|
|
4299
|
+
dem_grid = voxcity.dem.elevation if hasattr(voxcity, 'dem') and voxcity.dem else np.zeros_like(irradiance_map)
|
|
4300
|
+
output_dir = kwargs.get('output_directory', 'output')
|
|
4301
|
+
view_point_height = kwargs.get('view_point_height', 1.5)
|
|
4302
|
+
colormap = kwargs.get('colormap', 'magma')
|
|
4303
|
+
vmin = kwargs.get('vmin', 0.0)
|
|
4304
|
+
vmax = kwargs.get('vmax', float(np.nanmax(irradiance_map)) if not np.all(np.isnan(irradiance_map)) else 1.0)
|
|
4305
|
+
num_colors = kwargs.get('num_colors', 10)
|
|
4306
|
+
alpha = kwargs.get('alpha', 1.0)
|
|
4307
|
+
|
|
4308
|
+
grid_to_obj(
|
|
4309
|
+
irradiance_map,
|
|
4310
|
+
dem_grid,
|
|
4311
|
+
output_dir,
|
|
4312
|
+
output_name,
|
|
4313
|
+
meshsize,
|
|
4314
|
+
view_point_height,
|
|
4315
|
+
colormap_name=colormap,
|
|
4316
|
+
num_colors=num_colors,
|
|
4317
|
+
alpha=alpha,
|
|
4318
|
+
vmin=vmin,
|
|
4319
|
+
vmax=vmax
|
|
4320
|
+
)
|
|
4321
|
+
except ImportError:
|
|
4322
|
+
print("VoxCity exporter.obj required for OBJ export")
|