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