voxcity 0.5.11__py3-none-any.whl → 0.5.13__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.
Potentially problematic release.
This version of voxcity might be problematic. Click here for more details.
- voxcity/downloader/osm.py +954 -646
- voxcity/simulator/solar.py +1422 -1371
- voxcity/simulator/view.py +16 -15
- voxcity/utils/visualization.py +2 -2
- {voxcity-0.5.11.dist-info → voxcity-0.5.13.dist-info}/METADATA +2 -3
- {voxcity-0.5.11.dist-info → voxcity-0.5.13.dist-info}/RECORD +10 -10
- {voxcity-0.5.11.dist-info → voxcity-0.5.13.dist-info}/WHEEL +1 -1
- {voxcity-0.5.11.dist-info → voxcity-0.5.13.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-0.5.11.dist-info → voxcity-0.5.13.dist-info}/licenses/LICENSE +0 -0
- {voxcity-0.5.11.dist-info → voxcity-0.5.13.dist-info}/top_level.txt +0 -0
voxcity/simulator/solar.py
CHANGED
|
@@ -1,1371 +1,1422 @@
|
|
|
1
|
-
import numpy as np
|
|
2
|
-
import pandas as pd
|
|
3
|
-
import matplotlib.pyplot as plt
|
|
4
|
-
from numba import njit, prange
|
|
5
|
-
from datetime import datetime, timezone
|
|
6
|
-
import pytz
|
|
7
|
-
from astral import Observer
|
|
8
|
-
from astral.sun import elevation, azimuth
|
|
9
|
-
|
|
10
|
-
from .view import trace_ray_generic, compute_vi_map_generic, get_sky_view_factor_map, get_surface_view_factor
|
|
11
|
-
from ..utils.weather import get_nearest_epw_from_climate_onebuilding, read_epw_for_solar_simulation
|
|
12
|
-
from ..exporter.obj import grid_to_obj, export_obj
|
|
13
|
-
|
|
14
|
-
@njit(parallel=True)
|
|
15
|
-
def compute_direct_solar_irradiance_map_binary(voxel_data, sun_direction, view_point_height, hit_values, meshsize, tree_k, tree_lad, inclusion_mode):
|
|
16
|
-
"""
|
|
17
|
-
Compute a map of direct solar irradiation accounting for tree transmittance.
|
|
18
|
-
|
|
19
|
-
The function:
|
|
20
|
-
1. Places observers at valid locations (empty voxels above ground)
|
|
21
|
-
2. Casts rays from each observer in the sun direction
|
|
22
|
-
3. Computes transmittance through trees using Beer-Lambert law
|
|
23
|
-
4. Returns a 2D map of transmittance values
|
|
24
|
-
|
|
25
|
-
Args:
|
|
26
|
-
voxel_data (ndarray): 3D array of voxel values.
|
|
27
|
-
sun_direction (tuple): Direction vector of the sun.
|
|
28
|
-
view_point_height (float): Observer height in meters.
|
|
29
|
-
hit_values (tuple): Values considered non-obstacles if inclusion_mode=False.
|
|
30
|
-
meshsize (float): Size of each voxel in meters.
|
|
31
|
-
tree_k (float): Tree extinction coefficient.
|
|
32
|
-
tree_lad (float): Leaf area density in m^-1.
|
|
33
|
-
inclusion_mode (bool): False here, meaning any voxel not in hit_values is an obstacle.
|
|
34
|
-
|
|
35
|
-
Returns:
|
|
36
|
-
ndarray: 2D array of transmittance values (0.0-1.0), NaN = invalid observer position.
|
|
37
|
-
"""
|
|
38
|
-
|
|
39
|
-
view_height_voxel = int(view_point_height / meshsize)
|
|
40
|
-
|
|
41
|
-
nx, ny, nz = voxel_data.shape
|
|
42
|
-
irradiance_map = np.full((nx, ny), np.nan, dtype=np.float64)
|
|
43
|
-
|
|
44
|
-
# Normalize sun direction vector for ray tracing
|
|
45
|
-
sd = np.array(sun_direction, dtype=np.float64)
|
|
46
|
-
sd_len = np.sqrt(sd[0]**2 + sd[1]**2 + sd[2]**2)
|
|
47
|
-
if sd_len == 0.0:
|
|
48
|
-
return np.flipud(irradiance_map)
|
|
49
|
-
sd /= sd_len
|
|
50
|
-
|
|
51
|
-
# Process each x,y position in parallel
|
|
52
|
-
for x in prange(nx):
|
|
53
|
-
for y in range(ny):
|
|
54
|
-
found_observer = False
|
|
55
|
-
# Search upward for valid observer position
|
|
56
|
-
for z in range(1, nz):
|
|
57
|
-
# Check if current voxel is empty/tree and voxel below is solid
|
|
58
|
-
if voxel_data[x, y, z] in (0, -2) and voxel_data[x, y, z - 1] not in (0, -2):
|
|
59
|
-
# Skip if standing on building/vegetation/water
|
|
60
|
-
if (voxel_data[x, y, z - 1] in (7, 8, 9)) or (voxel_data[x, y, z - 1] < 0):
|
|
61
|
-
irradiance_map[x, y] = np.nan
|
|
62
|
-
found_observer = True
|
|
63
|
-
break
|
|
64
|
-
else:
|
|
65
|
-
# Place observer and cast a ray in sun direction
|
|
66
|
-
observer_location = np.array([x, y, z + view_height_voxel], dtype=np.float64)
|
|
67
|
-
hit, transmittance = trace_ray_generic(voxel_data, observer_location, sd,
|
|
68
|
-
hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
|
|
69
|
-
irradiance_map[x, y] = transmittance if not hit else 0.0
|
|
70
|
-
found_observer = True
|
|
71
|
-
break
|
|
72
|
-
if not found_observer:
|
|
73
|
-
irradiance_map[x, y] = np.nan
|
|
74
|
-
|
|
75
|
-
# Flip map vertically to match visualization conventions
|
|
76
|
-
return np.flipud(irradiance_map)
|
|
77
|
-
|
|
78
|
-
def get_direct_solar_irradiance_map(voxel_data, meshsize, azimuth_degrees_ori, elevation_degrees,
|
|
79
|
-
direct_normal_irradiance, show_plot=False, **kwargs):
|
|
80
|
-
"""
|
|
81
|
-
Compute direct solar irradiance map with tree transmittance.
|
|
82
|
-
|
|
83
|
-
The function:
|
|
84
|
-
1. Converts sun angles to direction vector
|
|
85
|
-
2. Computes binary transmittance map
|
|
86
|
-
3. Scales by direct normal irradiance and sun elevation
|
|
87
|
-
4. Optionally visualizes and exports results
|
|
88
|
-
|
|
89
|
-
Args:
|
|
90
|
-
voxel_data (ndarray): 3D array of voxel values.
|
|
91
|
-
meshsize (float): Size of each voxel in meters.
|
|
92
|
-
azimuth_degrees_ori (float): Sun azimuth angle in degrees (0° = North, 90° = East).
|
|
93
|
-
elevation_degrees (float): Sun elevation angle in degrees above horizon.
|
|
94
|
-
direct_normal_irradiance (float): Direct normal irradiance in W/m².
|
|
95
|
-
show_plot (bool): Whether to display visualization.
|
|
96
|
-
**kwargs: Additional arguments including:
|
|
97
|
-
- view_point_height (float): Observer height in meters (default: 1.5)
|
|
98
|
-
- colormap (str): Matplotlib colormap name (default: 'magma')
|
|
99
|
-
- vmin (float): Minimum value for colormap
|
|
100
|
-
- vmax (float): Maximum value for colormap
|
|
101
|
-
- tree_k (float): Tree extinction coefficient (default: 0.6)
|
|
102
|
-
- tree_lad (float): Leaf area density in m^-1 (default: 1.0)
|
|
103
|
-
- obj_export (bool): Whether to export as OBJ file
|
|
104
|
-
- output_directory (str): Directory for OBJ export
|
|
105
|
-
- output_file_name (str): Filename for OBJ export
|
|
106
|
-
- dem_grid (ndarray): DEM grid for OBJ export
|
|
107
|
-
- num_colors (int): Number of colors for OBJ export
|
|
108
|
-
- alpha (float): Alpha value for OBJ export
|
|
109
|
-
|
|
110
|
-
Returns:
|
|
111
|
-
ndarray: 2D array of direct solar irradiance values (W/m²).
|
|
112
|
-
"""
|
|
113
|
-
view_point_height = kwargs.get("view_point_height", 1.5)
|
|
114
|
-
colormap = kwargs.get("colormap", 'magma')
|
|
115
|
-
vmin = kwargs.get("vmin", 0.0)
|
|
116
|
-
vmax = kwargs.get("vmax", direct_normal_irradiance)
|
|
117
|
-
|
|
118
|
-
# Get tree transmittance parameters
|
|
119
|
-
tree_k = kwargs.get("tree_k", 0.6)
|
|
120
|
-
tree_lad = kwargs.get("tree_lad", 1.0)
|
|
121
|
-
|
|
122
|
-
# Convert sun angles to direction vector
|
|
123
|
-
# Note: azimuth is adjusted by 180° to match coordinate system
|
|
124
|
-
azimuth_degrees = 180 - azimuth_degrees_ori
|
|
125
|
-
azimuth_radians = np.deg2rad(azimuth_degrees)
|
|
126
|
-
elevation_radians = np.deg2rad(elevation_degrees)
|
|
127
|
-
dx = np.cos(elevation_radians) * np.cos(azimuth_radians)
|
|
128
|
-
dy = np.cos(elevation_radians) * np.sin(azimuth_radians)
|
|
129
|
-
dz = np.sin(elevation_radians)
|
|
130
|
-
sun_direction = (dx, dy, dz)
|
|
131
|
-
|
|
132
|
-
# All non-zero voxels are obstacles except for trees which have transmittance
|
|
133
|
-
hit_values = (0,)
|
|
134
|
-
inclusion_mode = False
|
|
135
|
-
|
|
136
|
-
# Compute transmittance map
|
|
137
|
-
transmittance_map = compute_direct_solar_irradiance_map_binary(
|
|
138
|
-
voxel_data, sun_direction, view_point_height, hit_values,
|
|
139
|
-
meshsize, tree_k, tree_lad, inclusion_mode
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
# Scale by direct normal irradiance and sun elevation
|
|
143
|
-
sin_elev = dz
|
|
144
|
-
direct_map = transmittance_map * direct_normal_irradiance * sin_elev
|
|
145
|
-
|
|
146
|
-
# Optional visualization
|
|
147
|
-
if show_plot:
|
|
148
|
-
cmap = plt.cm.get_cmap(colormap).copy()
|
|
149
|
-
cmap.set_bad(color='lightgray')
|
|
150
|
-
plt.figure(figsize=(10, 8))
|
|
151
|
-
# plt.title("Horizontal Direct Solar Irradiance Map (0° = North)")
|
|
152
|
-
plt.imshow(direct_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
153
|
-
plt.colorbar(label='Direct Solar Irradiance (W/m²)')
|
|
154
|
-
plt.axis('off')
|
|
155
|
-
plt.show()
|
|
156
|
-
|
|
157
|
-
# Optional OBJ export
|
|
158
|
-
obj_export = kwargs.get("obj_export", False)
|
|
159
|
-
if obj_export:
|
|
160
|
-
dem_grid = kwargs.get("dem_grid", np.zeros_like(direct_map))
|
|
161
|
-
output_dir = kwargs.get("output_directory", "output")
|
|
162
|
-
output_file_name = kwargs.get("output_file_name", "direct_solar_irradiance")
|
|
163
|
-
num_colors = kwargs.get("num_colors", 10)
|
|
164
|
-
alpha = kwargs.get("alpha", 1.0)
|
|
165
|
-
grid_to_obj(
|
|
166
|
-
direct_map,
|
|
167
|
-
dem_grid,
|
|
168
|
-
output_dir,
|
|
169
|
-
output_file_name,
|
|
170
|
-
meshsize,
|
|
171
|
-
view_point_height,
|
|
172
|
-
colormap_name=colormap,
|
|
173
|
-
num_colors=num_colors,
|
|
174
|
-
alpha=alpha,
|
|
175
|
-
vmin=vmin,
|
|
176
|
-
vmax=vmax
|
|
177
|
-
)
|
|
178
|
-
|
|
179
|
-
return direct_map
|
|
180
|
-
|
|
181
|
-
def get_diffuse_solar_irradiance_map(voxel_data, meshsize, diffuse_irradiance=1.0, show_plot=False, **kwargs):
|
|
182
|
-
"""
|
|
183
|
-
Compute diffuse solar irradiance map using the Sky View Factor (SVF) with tree transmittance.
|
|
184
|
-
|
|
185
|
-
The function:
|
|
186
|
-
1. Computes SVF map accounting for tree transmittance
|
|
187
|
-
2. Scales SVF by diffuse horizontal irradiance
|
|
188
|
-
3. Optionally visualizes and exports results
|
|
189
|
-
|
|
190
|
-
Args:
|
|
191
|
-
voxel_data (ndarray): 3D array of voxel values.
|
|
192
|
-
meshsize (float): Size of each voxel in meters.
|
|
193
|
-
diffuse_irradiance (float): Diffuse horizontal irradiance in W/m².
|
|
194
|
-
show_plot (bool): Whether to display visualization.
|
|
195
|
-
**kwargs: Additional arguments including:
|
|
196
|
-
- view_point_height (float): Observer height in meters (default: 1.5)
|
|
197
|
-
- colormap (str): Matplotlib colormap name (default: 'magma')
|
|
198
|
-
- vmin (float): Minimum value for colormap
|
|
199
|
-
- vmax (float): Maximum value for colormap
|
|
200
|
-
- tree_k (float): Tree extinction coefficient
|
|
201
|
-
- tree_lad (float): Leaf area density in m^-1
|
|
202
|
-
- obj_export (bool): Whether to export as OBJ file
|
|
203
|
-
- output_directory (str): Directory for OBJ export
|
|
204
|
-
- output_file_name (str): Filename for OBJ export
|
|
205
|
-
- dem_grid (ndarray): DEM grid for OBJ export
|
|
206
|
-
- num_colors (int): Number of colors for OBJ export
|
|
207
|
-
- alpha (float): Alpha value for OBJ export
|
|
208
|
-
|
|
209
|
-
Returns:
|
|
210
|
-
ndarray: 2D array of diffuse solar irradiance values (W/m²).
|
|
211
|
-
"""
|
|
212
|
-
|
|
213
|
-
view_point_height = kwargs.get("view_point_height", 1.5)
|
|
214
|
-
colormap = kwargs.get("colormap", 'magma')
|
|
215
|
-
vmin = kwargs.get("vmin", 0.0)
|
|
216
|
-
vmax = kwargs.get("vmax", diffuse_irradiance)
|
|
217
|
-
|
|
218
|
-
# Pass tree transmittance parameters to SVF calculation
|
|
219
|
-
svf_kwargs = kwargs.copy()
|
|
220
|
-
svf_kwargs["colormap"] = "BuPu_r"
|
|
221
|
-
svf_kwargs["vmin"] = 0
|
|
222
|
-
svf_kwargs["vmax"] = 1
|
|
223
|
-
|
|
224
|
-
# SVF calculation now handles tree transmittance internally
|
|
225
|
-
SVF_map = get_sky_view_factor_map(voxel_data, meshsize, **svf_kwargs)
|
|
226
|
-
diffuse_map = SVF_map * diffuse_irradiance
|
|
227
|
-
|
|
228
|
-
# Optional visualization
|
|
229
|
-
if show_plot:
|
|
230
|
-
vmin = kwargs.get("vmin", 0.0)
|
|
231
|
-
vmax = kwargs.get("vmax", diffuse_irradiance)
|
|
232
|
-
cmap = plt.cm.get_cmap(colormap).copy()
|
|
233
|
-
cmap.set_bad(color='lightgray')
|
|
234
|
-
plt.figure(figsize=(10, 8))
|
|
235
|
-
# plt.title("Diffuse Solar Irradiance Map")
|
|
236
|
-
plt.imshow(diffuse_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
237
|
-
plt.colorbar(label='Diffuse Solar Irradiance (W/m²)')
|
|
238
|
-
plt.axis('off')
|
|
239
|
-
plt.show()
|
|
240
|
-
|
|
241
|
-
# Optional OBJ export
|
|
242
|
-
obj_export = kwargs.get("obj_export", False)
|
|
243
|
-
if obj_export:
|
|
244
|
-
dem_grid = kwargs.get("dem_grid", np.zeros_like(diffuse_map))
|
|
245
|
-
output_dir = kwargs.get("output_directory", "output")
|
|
246
|
-
output_file_name = kwargs.get("output_file_name", "diffuse_solar_irradiance")
|
|
247
|
-
num_colors = kwargs.get("num_colors", 10)
|
|
248
|
-
alpha = kwargs.get("alpha", 1.0)
|
|
249
|
-
grid_to_obj(
|
|
250
|
-
diffuse_map,
|
|
251
|
-
dem_grid,
|
|
252
|
-
output_dir,
|
|
253
|
-
output_file_name,
|
|
254
|
-
meshsize,
|
|
255
|
-
view_point_height,
|
|
256
|
-
colormap_name=colormap,
|
|
257
|
-
num_colors=num_colors,
|
|
258
|
-
alpha=alpha,
|
|
259
|
-
vmin=vmin,
|
|
260
|
-
vmax=vmax
|
|
261
|
-
)
|
|
262
|
-
|
|
263
|
-
return diffuse_map
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
def get_global_solar_irradiance_map(
|
|
267
|
-
voxel_data,
|
|
268
|
-
meshsize,
|
|
269
|
-
azimuth_degrees,
|
|
270
|
-
elevation_degrees,
|
|
271
|
-
direct_normal_irradiance,
|
|
272
|
-
diffuse_irradiance,
|
|
273
|
-
show_plot=False,
|
|
274
|
-
**kwargs
|
|
275
|
-
):
|
|
276
|
-
"""
|
|
277
|
-
Compute global solar irradiance (direct + diffuse) on a horizontal plane at each valid observer location.
|
|
278
|
-
|
|
279
|
-
The function:
|
|
280
|
-
1. Computes direct solar irradiance map
|
|
281
|
-
2. Computes diffuse solar irradiance map
|
|
282
|
-
3. Combines maps and optionally visualizes/exports results
|
|
283
|
-
|
|
284
|
-
Args:
|
|
285
|
-
voxel_data (ndarray): 3D voxel array.
|
|
286
|
-
meshsize (float): Voxel size in meters.
|
|
287
|
-
azimuth_degrees (float): Sun azimuth angle in degrees (0° = North, 90° = East).
|
|
288
|
-
elevation_degrees (float): Sun elevation angle in degrees above horizon.
|
|
289
|
-
direct_normal_irradiance (float): Direct normal irradiance in W/m².
|
|
290
|
-
diffuse_irradiance (float): Diffuse horizontal irradiance in W/m².
|
|
291
|
-
show_plot (bool): Whether to display visualization.
|
|
292
|
-
**kwargs: Additional arguments including:
|
|
293
|
-
- view_point_height (float): Observer height in meters (default: 1.5)
|
|
294
|
-
- colormap (str): Matplotlib colormap name (default: 'magma')
|
|
295
|
-
- vmin (float): Minimum value for colormap
|
|
296
|
-
- vmax (float): Maximum value for colormap
|
|
297
|
-
- tree_k (float): Tree extinction coefficient
|
|
298
|
-
- tree_lad (float): Leaf area density in m^-1
|
|
299
|
-
- obj_export (bool): Whether to export as OBJ file
|
|
300
|
-
- output_directory (str): Directory for OBJ export
|
|
301
|
-
- output_file_name (str): Filename for OBJ export
|
|
302
|
-
- dem_grid (ndarray): DEM grid for OBJ export
|
|
303
|
-
- num_colors (int): Number of colors for OBJ export
|
|
304
|
-
- alpha (float): Alpha value for OBJ export
|
|
305
|
-
|
|
306
|
-
Returns:
|
|
307
|
-
ndarray: 2D array of global solar irradiance values (W/m²).
|
|
308
|
-
"""
|
|
309
|
-
|
|
310
|
-
colormap = kwargs.get("colormap", 'magma')
|
|
311
|
-
|
|
312
|
-
# Create kwargs for diffuse calculation
|
|
313
|
-
direct_diffuse_kwargs = kwargs.copy()
|
|
314
|
-
direct_diffuse_kwargs.update({
|
|
315
|
-
'show_plot': True,
|
|
316
|
-
'obj_export': False
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
# Compute direct irradiance map (no mode/hit_values/inclusion_mode needed)
|
|
320
|
-
direct_map = get_direct_solar_irradiance_map(
|
|
321
|
-
voxel_data,
|
|
322
|
-
meshsize,
|
|
323
|
-
azimuth_degrees,
|
|
324
|
-
elevation_degrees,
|
|
325
|
-
direct_normal_irradiance,
|
|
326
|
-
**direct_diffuse_kwargs
|
|
327
|
-
)
|
|
328
|
-
|
|
329
|
-
# Compute diffuse irradiance map
|
|
330
|
-
diffuse_map = get_diffuse_solar_irradiance_map(
|
|
331
|
-
voxel_data,
|
|
332
|
-
meshsize,
|
|
333
|
-
diffuse_irradiance=diffuse_irradiance,
|
|
334
|
-
**direct_diffuse_kwargs
|
|
335
|
-
)
|
|
336
|
-
|
|
337
|
-
# Sum the two components
|
|
338
|
-
global_map = direct_map + diffuse_map
|
|
339
|
-
|
|
340
|
-
vmin = kwargs.get("vmin", np.nanmin(global_map))
|
|
341
|
-
vmax = kwargs.get("vmax", np.nanmax(global_map))
|
|
342
|
-
|
|
343
|
-
# Optional visualization
|
|
344
|
-
if show_plot:
|
|
345
|
-
cmap = plt.cm.get_cmap(colormap).copy()
|
|
346
|
-
cmap.set_bad(color='lightgray')
|
|
347
|
-
plt.figure(figsize=(10, 8))
|
|
348
|
-
# plt.title("Global Solar Irradiance Map")
|
|
349
|
-
plt.imshow(global_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
350
|
-
plt.colorbar(label='Global Solar Irradiance (W/m²)')
|
|
351
|
-
plt.axis('off')
|
|
352
|
-
plt.show()
|
|
353
|
-
|
|
354
|
-
# Optional OBJ export
|
|
355
|
-
obj_export = kwargs.get("obj_export", False)
|
|
356
|
-
if obj_export:
|
|
357
|
-
dem_grid = kwargs.get("dem_grid", np.zeros_like(global_map))
|
|
358
|
-
output_dir = kwargs.get("output_directory", "output")
|
|
359
|
-
output_file_name = kwargs.get("output_file_name", "global_solar_irradiance")
|
|
360
|
-
num_colors = kwargs.get("num_colors", 10)
|
|
361
|
-
alpha = kwargs.get("alpha", 1.0)
|
|
362
|
-
meshsize_param = kwargs.get("meshsize", meshsize)
|
|
363
|
-
view_point_height = kwargs.get("view_point_height", 1.5)
|
|
364
|
-
grid_to_obj(
|
|
365
|
-
global_map,
|
|
366
|
-
dem_grid,
|
|
367
|
-
output_dir,
|
|
368
|
-
output_file_name,
|
|
369
|
-
meshsize_param,
|
|
370
|
-
view_point_height,
|
|
371
|
-
colormap_name=colormap,
|
|
372
|
-
num_colors=num_colors,
|
|
373
|
-
alpha=alpha,
|
|
374
|
-
vmin=vmin,
|
|
375
|
-
vmax=vmax
|
|
376
|
-
)
|
|
377
|
-
|
|
378
|
-
return global_map
|
|
379
|
-
|
|
380
|
-
def get_solar_positions_astral(times, lon, lat):
|
|
381
|
-
"""
|
|
382
|
-
Compute solar azimuth and elevation using Astral for given times and location.
|
|
383
|
-
|
|
384
|
-
The function:
|
|
385
|
-
1. Creates an Astral observer at the specified location
|
|
386
|
-
2. Computes sun position for each timestamp
|
|
387
|
-
3. Returns DataFrame with azimuth and elevation angles
|
|
388
|
-
|
|
389
|
-
Args:
|
|
390
|
-
times (DatetimeIndex): Array of timezone-aware datetime objects.
|
|
391
|
-
lon (float): Longitude in degrees.
|
|
392
|
-
lat (float): Latitude in degrees.
|
|
393
|
-
|
|
394
|
-
Returns:
|
|
395
|
-
DataFrame: DataFrame with columns 'azimuth' and 'elevation' containing solar positions.
|
|
396
|
-
"""
|
|
397
|
-
observer = Observer(latitude=lat, longitude=lon)
|
|
398
|
-
df_pos = pd.DataFrame(index=times, columns=['azimuth', 'elevation'], dtype=float)
|
|
399
|
-
|
|
400
|
-
for t in times:
|
|
401
|
-
# t is already timezone-aware; no need to replace tzinfo
|
|
402
|
-
el = elevation(observer=observer, dateandtime=t)
|
|
403
|
-
az = azimuth(observer=observer, dateandtime=t)
|
|
404
|
-
df_pos.at[t, 'elevation'] = el
|
|
405
|
-
df_pos.at[t, 'azimuth'] = az
|
|
406
|
-
|
|
407
|
-
return df_pos
|
|
408
|
-
|
|
409
|
-
def get_cumulative_global_solar_irradiance(
|
|
410
|
-
voxel_data,
|
|
411
|
-
meshsize,
|
|
412
|
-
df, lon, lat, tz,
|
|
413
|
-
direct_normal_irradiance_scaling=1.0,
|
|
414
|
-
diffuse_irradiance_scaling=1.0,
|
|
415
|
-
**kwargs
|
|
416
|
-
):
|
|
417
|
-
"""
|
|
418
|
-
Compute cumulative global solar irradiance over a specified period using data from an EPW file.
|
|
419
|
-
|
|
420
|
-
The function:
|
|
421
|
-
1. Filters EPW data for specified time period
|
|
422
|
-
2. Computes sun positions for each timestep
|
|
423
|
-
3. Calculates and accumulates global irradiance maps
|
|
424
|
-
4. Handles tree transmittance and visualization
|
|
425
|
-
|
|
426
|
-
Args:
|
|
427
|
-
voxel_data (ndarray): 3D array of voxel values.
|
|
428
|
-
meshsize (float): Size of each voxel in meters.
|
|
429
|
-
df (DataFrame): EPW weather data.
|
|
430
|
-
lon (float): Longitude in degrees.
|
|
431
|
-
lat (float): Latitude in degrees.
|
|
432
|
-
tz (float): Timezone offset in hours.
|
|
433
|
-
direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
|
|
434
|
-
diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
|
|
435
|
-
**kwargs: Additional arguments including:
|
|
436
|
-
- view_point_height (float): Observer height in meters (default: 1.5)
|
|
437
|
-
- start_time (str): Start time in format 'MM-DD HH:MM:SS'
|
|
438
|
-
- end_time (str): End time in format 'MM-DD HH:MM:SS'
|
|
439
|
-
- tree_k (float): Tree extinction coefficient
|
|
440
|
-
- tree_lad (float): Leaf area density in m^-1
|
|
441
|
-
- show_plot (bool): Whether to show final plot
|
|
442
|
-
- show_each_timestep (bool): Whether to show plots for each timestep
|
|
443
|
-
- colormap (str): Matplotlib colormap name
|
|
444
|
-
- vmin (float): Minimum value for colormap
|
|
445
|
-
- vmax (float): Maximum value for colormap
|
|
446
|
-
- obj_export (bool): Whether to export as OBJ file
|
|
447
|
-
- output_directory (str): Directory for OBJ export
|
|
448
|
-
- output_file_name (str): Filename for OBJ export
|
|
449
|
-
- dem_grid (ndarray): DEM grid for OBJ export
|
|
450
|
-
- num_colors (int): Number of colors for OBJ export
|
|
451
|
-
- alpha (float): Alpha value for OBJ export
|
|
452
|
-
|
|
453
|
-
Returns:
|
|
454
|
-
ndarray: 2D array of cumulative global solar irradiance values (W/m²·hour).
|
|
455
|
-
"""
|
|
456
|
-
view_point_height = kwargs.get("view_point_height", 1.5)
|
|
457
|
-
colormap = kwargs.get("colormap", 'magma')
|
|
458
|
-
start_time = kwargs.get("start_time", "01-01 05:00:00")
|
|
459
|
-
end_time = kwargs.get("end_time", "01-01 20:00:00")
|
|
460
|
-
|
|
461
|
-
if df.empty:
|
|
462
|
-
raise ValueError("No data in EPW file.")
|
|
463
|
-
|
|
464
|
-
# Parse start and end times without year
|
|
465
|
-
try:
|
|
466
|
-
start_dt = datetime.strptime(start_time, "%m-%d %H:%M:%S")
|
|
467
|
-
end_dt = datetime.strptime(end_time, "%m-%d %H:%M:%S")
|
|
468
|
-
except ValueError as ve:
|
|
469
|
-
raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
|
|
470
|
-
|
|
471
|
-
# Add hour of year column and filter data
|
|
472
|
-
df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
|
|
473
|
-
|
|
474
|
-
# Convert dates to day of year and hour
|
|
475
|
-
start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
|
|
476
|
-
end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
|
|
477
|
-
|
|
478
|
-
start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
|
|
479
|
-
end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
|
|
480
|
-
|
|
481
|
-
# Handle period crossing year boundary
|
|
482
|
-
if start_hour <= end_hour:
|
|
483
|
-
df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
|
|
484
|
-
else:
|
|
485
|
-
df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
|
|
486
|
-
|
|
487
|
-
# Filter by minutes within start/end hours
|
|
488
|
-
df_period = df_period[
|
|
489
|
-
((df_period.index.hour != start_dt.hour) | (df_period.index.minute >= start_dt.minute)) &
|
|
490
|
-
((df_period.index.hour != end_dt.hour) | (df_period.index.minute <= end_dt.minute))
|
|
491
|
-
]
|
|
492
|
-
|
|
493
|
-
if df_period.empty:
|
|
494
|
-
raise ValueError("No EPW data in the specified period.")
|
|
495
|
-
|
|
496
|
-
# Handle timezone conversion
|
|
497
|
-
offset_minutes = int(tz * 60)
|
|
498
|
-
local_tz = pytz.FixedOffset(offset_minutes)
|
|
499
|
-
df_period_local = df_period.copy()
|
|
500
|
-
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
501
|
-
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
502
|
-
|
|
503
|
-
# Compute solar positions for period
|
|
504
|
-
solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
|
|
505
|
-
|
|
506
|
-
# Create kwargs for diffuse calculation
|
|
507
|
-
diffuse_kwargs = kwargs.copy()
|
|
508
|
-
diffuse_kwargs.update({
|
|
509
|
-
'show_plot': False,
|
|
510
|
-
'obj_export': False
|
|
511
|
-
})
|
|
512
|
-
|
|
513
|
-
# Compute base diffuse map once with diffuse_irradiance=1.0
|
|
514
|
-
base_diffuse_map = get_diffuse_solar_irradiance_map(
|
|
515
|
-
voxel_data,
|
|
516
|
-
meshsize,
|
|
517
|
-
diffuse_irradiance=1.0,
|
|
518
|
-
**diffuse_kwargs
|
|
519
|
-
)
|
|
520
|
-
|
|
521
|
-
# Initialize accumulation maps
|
|
522
|
-
cumulative_map = np.zeros((voxel_data.shape[0], voxel_data.shape[1]))
|
|
523
|
-
mask_map = np.ones((voxel_data.shape[0], voxel_data.shape[1]), dtype=bool)
|
|
524
|
-
|
|
525
|
-
# Create kwargs for direct calculation
|
|
526
|
-
direct_kwargs = kwargs.copy()
|
|
527
|
-
direct_kwargs.update({
|
|
528
|
-
'show_plot': False,
|
|
529
|
-
'view_point_height': view_point_height,
|
|
530
|
-
'obj_export': False
|
|
531
|
-
})
|
|
532
|
-
|
|
533
|
-
# Process each timestep
|
|
534
|
-
for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
|
|
535
|
-
# Get scaled irradiance values
|
|
536
|
-
DNI = row['DNI'] * direct_normal_irradiance_scaling
|
|
537
|
-
DHI = row['DHI'] * diffuse_irradiance_scaling
|
|
538
|
-
time_local = df_period_local.index[idx]
|
|
539
|
-
|
|
540
|
-
# Get solar position for timestep
|
|
541
|
-
solpos = solar_positions.loc[time_utc]
|
|
542
|
-
azimuth_degrees = solpos['azimuth']
|
|
543
|
-
elevation_degrees = solpos['elevation']
|
|
544
|
-
|
|
545
|
-
# Compute direct irradiance map with transmittance
|
|
546
|
-
direct_map = get_direct_solar_irradiance_map(
|
|
547
|
-
voxel_data,
|
|
548
|
-
meshsize,
|
|
549
|
-
azimuth_degrees,
|
|
550
|
-
elevation_degrees,
|
|
551
|
-
direct_normal_irradiance=DNI,
|
|
552
|
-
**direct_kwargs
|
|
553
|
-
)
|
|
554
|
-
|
|
555
|
-
# Scale base diffuse map by actual DHI
|
|
556
|
-
diffuse_map = base_diffuse_map * DHI
|
|
557
|
-
|
|
558
|
-
# Combine direct and diffuse components
|
|
559
|
-
global_map = direct_map + diffuse_map
|
|
560
|
-
|
|
561
|
-
# Update valid pixel mask
|
|
562
|
-
mask_map &= ~np.isnan(global_map)
|
|
563
|
-
|
|
564
|
-
# Replace NaN with 0 for accumulation
|
|
565
|
-
global_map_filled = np.nan_to_num(global_map, nan=0.0)
|
|
566
|
-
cumulative_map += global_map_filled
|
|
567
|
-
|
|
568
|
-
# Optional timestep visualization
|
|
569
|
-
show_each_timestep = kwargs.get("show_each_timestep", False)
|
|
570
|
-
if show_each_timestep:
|
|
571
|
-
colormap = kwargs.get("colormap", 'viridis')
|
|
572
|
-
vmin = kwargs.get("vmin", 0.0)
|
|
573
|
-
vmax = kwargs.get("vmax", max(direct_normal_irradiance_scaling, diffuse_irradiance_scaling) * 1000)
|
|
574
|
-
cmap = plt.cm.get_cmap(colormap).copy()
|
|
575
|
-
cmap.set_bad(color='lightgray')
|
|
576
|
-
plt.figure(figsize=(10, 8))
|
|
577
|
-
# plt.title(f"Global Solar Irradiance at {time_local.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
578
|
-
plt.imshow(global_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
579
|
-
plt.axis('off')
|
|
580
|
-
plt.colorbar(label='Global Solar Irradiance (W/m²)')
|
|
581
|
-
plt.show()
|
|
582
|
-
|
|
583
|
-
# Apply mask to final result
|
|
584
|
-
cumulative_map[~mask_map] = np.nan
|
|
585
|
-
|
|
586
|
-
# Final visualization
|
|
587
|
-
show_plot = kwargs.get("show_plot", True)
|
|
588
|
-
if show_plot:
|
|
589
|
-
colormap = kwargs.get("colormap", 'magma')
|
|
590
|
-
vmin = kwargs.get("vmin", np.nanmin(cumulative_map))
|
|
591
|
-
vmax = kwargs.get("vmax", np.nanmax(cumulative_map))
|
|
592
|
-
cmap = plt.cm.get_cmap(colormap).copy()
|
|
593
|
-
cmap.set_bad(color='lightgray')
|
|
594
|
-
plt.figure(figsize=(10, 8))
|
|
595
|
-
# plt.title("Cumulative Global Solar Irradiance Map")
|
|
596
|
-
plt.imshow(cumulative_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
597
|
-
plt.colorbar(label='Cumulative Global Solar Irradiance (W/m²·hour)')
|
|
598
|
-
plt.axis('off')
|
|
599
|
-
plt.show()
|
|
600
|
-
|
|
601
|
-
# Optional OBJ export
|
|
602
|
-
obj_export = kwargs.get("obj_export", False)
|
|
603
|
-
if obj_export:
|
|
604
|
-
colormap = kwargs.get("colormap", "magma")
|
|
605
|
-
vmin = kwargs.get("vmin", np.nanmin(cumulative_map))
|
|
606
|
-
vmax = kwargs.get("vmax", np.nanmax(cumulative_map))
|
|
607
|
-
dem_grid = kwargs.get("dem_grid", np.zeros_like(cumulative_map))
|
|
608
|
-
output_dir = kwargs.get("output_directory", "output")
|
|
609
|
-
output_file_name = kwargs.get("output_file_name", "cummurative_global_solar_irradiance")
|
|
610
|
-
num_colors = kwargs.get("num_colors", 10)
|
|
611
|
-
alpha = kwargs.get("alpha", 1.0)
|
|
612
|
-
grid_to_obj(
|
|
613
|
-
cumulative_map,
|
|
614
|
-
dem_grid,
|
|
615
|
-
output_dir,
|
|
616
|
-
output_file_name,
|
|
617
|
-
meshsize,
|
|
618
|
-
view_point_height,
|
|
619
|
-
colormap_name=colormap,
|
|
620
|
-
num_colors=num_colors,
|
|
621
|
-
alpha=alpha,
|
|
622
|
-
vmin=vmin,
|
|
623
|
-
vmax=vmax
|
|
624
|
-
)
|
|
625
|
-
|
|
626
|
-
return cumulative_map
|
|
627
|
-
|
|
628
|
-
def get_global_solar_irradiance_using_epw(
|
|
629
|
-
voxel_data,
|
|
630
|
-
meshsize,
|
|
631
|
-
calc_type='instantaneous',
|
|
632
|
-
direct_normal_irradiance_scaling=1.0,
|
|
633
|
-
diffuse_irradiance_scaling=1.0,
|
|
634
|
-
**kwargs
|
|
635
|
-
):
|
|
636
|
-
"""
|
|
637
|
-
Compute global solar irradiance using EPW weather data, either for a single time or cumulatively over a period.
|
|
638
|
-
|
|
639
|
-
The function:
|
|
640
|
-
1. Optionally downloads and reads EPW weather data
|
|
641
|
-
2. Handles timezone conversions and solar position calculations
|
|
642
|
-
3. Computes either instantaneous or cumulative irradiance maps
|
|
643
|
-
4. Supports visualization and export options
|
|
644
|
-
|
|
645
|
-
Args:
|
|
646
|
-
voxel_data (ndarray): 3D array of voxel values.
|
|
647
|
-
meshsize (float): Size of each voxel in meters.
|
|
648
|
-
calc_type (str): 'instantaneous' or 'cumulative'.
|
|
649
|
-
direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
|
|
650
|
-
diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
|
|
651
|
-
**kwargs: Additional arguments including:
|
|
652
|
-
- download_nearest_epw (bool): Whether to download nearest EPW file
|
|
653
|
-
- epw_file_path (str): Path to EPW file
|
|
654
|
-
- rectangle_vertices (list): List of (lat,lon) coordinates for EPW download
|
|
655
|
-
- output_dir (str): Directory for EPW download
|
|
656
|
-
- calc_time (str): Time for instantaneous calculation ('MM-DD HH:MM:SS')
|
|
657
|
-
- start_time (str): Start time for cumulative calculation
|
|
658
|
-
- end_time (str): End time for cumulative calculation
|
|
659
|
-
- start_hour (int): Starting hour for daily time window (0-23)
|
|
660
|
-
- end_hour (int): Ending hour for daily time window (0-23)
|
|
661
|
-
- view_point_height (float): Observer height in meters
|
|
662
|
-
- tree_k (float): Tree extinction coefficient
|
|
663
|
-
- tree_lad (float): Leaf area density in m^-1
|
|
664
|
-
- show_plot (bool): Whether to show visualization
|
|
665
|
-
- show_each_timestep (bool): Whether to show timestep plots
|
|
666
|
-
- colormap (str): Matplotlib colormap name
|
|
667
|
-
- obj_export (bool): Whether to export as OBJ file
|
|
668
|
-
|
|
669
|
-
Returns:
|
|
670
|
-
ndarray: 2D array of solar irradiance values (W/m²).
|
|
671
|
-
"""
|
|
672
|
-
view_point_height = kwargs.get("view_point_height", 1.5)
|
|
673
|
-
colormap = kwargs.get("colormap", 'magma')
|
|
674
|
-
|
|
675
|
-
# Get EPW file
|
|
676
|
-
download_nearest_epw = kwargs.get("download_nearest_epw", False)
|
|
677
|
-
rectangle_vertices = kwargs.get("rectangle_vertices", None)
|
|
678
|
-
epw_file_path = kwargs.get("epw_file_path", None)
|
|
679
|
-
if download_nearest_epw:
|
|
680
|
-
if rectangle_vertices is None:
|
|
681
|
-
print("rectangle_vertices is required to download nearest EPW file")
|
|
682
|
-
return None
|
|
683
|
-
else:
|
|
684
|
-
# Calculate center point of rectangle
|
|
685
|
-
lons = [coord[0] for coord in rectangle_vertices]
|
|
686
|
-
lats = [coord[1] for coord in rectangle_vertices]
|
|
687
|
-
center_lon = (min(lons) + max(lons)) / 2
|
|
688
|
-
center_lat = (min(lats) + max(lats)) / 2
|
|
689
|
-
target_point = (center_lon, center_lat)
|
|
690
|
-
|
|
691
|
-
# Optional: specify maximum distance in kilometers
|
|
692
|
-
max_distance = 100 # None for no limit
|
|
693
|
-
|
|
694
|
-
output_dir = kwargs.get("output_dir", "output")
|
|
695
|
-
|
|
696
|
-
epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
|
|
697
|
-
longitude=center_lon,
|
|
698
|
-
latitude=center_lat,
|
|
699
|
-
output_dir=output_dir,
|
|
700
|
-
max_distance=max_distance,
|
|
701
|
-
extract_zip=True,
|
|
702
|
-
load_data=True
|
|
703
|
-
)
|
|
704
|
-
|
|
705
|
-
# Read EPW data
|
|
706
|
-
df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
|
|
707
|
-
if df.empty:
|
|
708
|
-
raise ValueError("No data in EPW file.")
|
|
709
|
-
|
|
710
|
-
if calc_type == 'instantaneous':
|
|
711
|
-
|
|
712
|
-
calc_time = kwargs.get("calc_time", "01-01 12:00:00")
|
|
713
|
-
|
|
714
|
-
# Parse start and end times without year
|
|
715
|
-
try:
|
|
716
|
-
calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
|
|
717
|
-
except ValueError as ve:
|
|
718
|
-
raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
|
|
719
|
-
|
|
720
|
-
df_period = df[
|
|
721
|
-
(df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
|
|
722
|
-
]
|
|
723
|
-
|
|
724
|
-
if df_period.empty:
|
|
725
|
-
raise ValueError("No EPW data at the specified time.")
|
|
726
|
-
|
|
727
|
-
# Prepare timezone conversion
|
|
728
|
-
offset_minutes = int(tz * 60)
|
|
729
|
-
local_tz = pytz.FixedOffset(offset_minutes)
|
|
730
|
-
df_period_local = df_period.copy()
|
|
731
|
-
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
732
|
-
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
733
|
-
|
|
734
|
-
# Compute solar positions
|
|
735
|
-
solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
|
|
736
|
-
direct_normal_irradiance = df_period_utc.iloc[0]['DNI']
|
|
737
|
-
diffuse_irradiance = df_period_utc.iloc[0]['DHI']
|
|
738
|
-
azimuth_degrees = solar_positions.iloc[0]['azimuth']
|
|
739
|
-
elevation_degrees = solar_positions.iloc[0]['elevation']
|
|
740
|
-
solar_map = get_global_solar_irradiance_map(
|
|
741
|
-
voxel_data, # 3D voxel grid representing the urban environment
|
|
742
|
-
meshsize, # Size of each grid cell in meters
|
|
743
|
-
azimuth_degrees, # Sun's azimuth angle
|
|
744
|
-
elevation_degrees, # Sun's elevation angle
|
|
745
|
-
direct_normal_irradiance, # Direct Normal Irradiance value
|
|
746
|
-
diffuse_irradiance, # Diffuse irradiance value
|
|
747
|
-
show_plot=True, # Display visualization of results
|
|
748
|
-
**kwargs
|
|
749
|
-
)
|
|
750
|
-
if calc_type == 'cumulative':
|
|
751
|
-
# Get time window parameters
|
|
752
|
-
start_hour = kwargs.get("start_hour", 0) # Default to midnight
|
|
753
|
-
end_hour = kwargs.get("end_hour", 23) # Default to 11 PM
|
|
754
|
-
|
|
755
|
-
# Filter dataframe for specified hours
|
|
756
|
-
df_filtered = df[(df.index.hour >= start_hour) & (df.index.hour <= end_hour)]
|
|
757
|
-
|
|
758
|
-
solar_map = get_cumulative_global_solar_irradiance(
|
|
759
|
-
voxel_data,
|
|
760
|
-
meshsize,
|
|
761
|
-
df_filtered, lon, lat, tz,
|
|
762
|
-
**kwargs
|
|
763
|
-
)
|
|
764
|
-
|
|
765
|
-
return solar_map
|
|
766
|
-
|
|
767
|
-
import numpy as np
|
|
768
|
-
import trimesh
|
|
769
|
-
import time
|
|
770
|
-
from numba import njit
|
|
771
|
-
|
|
772
|
-
##############################################################################
|
|
773
|
-
# 1) New Numba helper: per-face solar irradiance computation
|
|
774
|
-
##############################################################################
|
|
775
|
-
@njit
|
|
776
|
-
def compute_solar_irradiance_for_all_faces(
|
|
777
|
-
face_centers,
|
|
778
|
-
face_normals,
|
|
779
|
-
face_svf,
|
|
780
|
-
sun_direction,
|
|
781
|
-
direct_normal_irradiance,
|
|
782
|
-
diffuse_irradiance,
|
|
783
|
-
voxel_data,
|
|
784
|
-
meshsize,
|
|
785
|
-
tree_k,
|
|
786
|
-
tree_lad,
|
|
787
|
-
hit_values,
|
|
788
|
-
inclusion_mode,
|
|
789
|
-
grid_bounds_real,
|
|
790
|
-
boundary_epsilon
|
|
791
|
-
):
|
|
792
|
-
"""
|
|
793
|
-
Numba-compiled function to compute direct, diffuse, and global solar irradiance
|
|
794
|
-
for each face in the mesh.
|
|
795
|
-
|
|
796
|
-
Args:
|
|
797
|
-
face_centers (float64[:, :]): (N x 3) array of face center points
|
|
798
|
-
face_normals (float64[:, :]): (N x 3) array of face normals
|
|
799
|
-
face_svf (float64[:]): (N) array of SVF values for each face
|
|
800
|
-
sun_direction (float64[:]): (3) array for sun direction (dx, dy, dz)
|
|
801
|
-
direct_normal_irradiance (float): Direct normal irradiance (DNI) in W/m²
|
|
802
|
-
diffuse_irradiance (float): Diffuse horizontal irradiance (DHI) in W/m²
|
|
803
|
-
voxel_data (ndarray): 3D array of voxel values
|
|
804
|
-
meshsize (float): Size of each voxel in meters
|
|
805
|
-
tree_k (float): Tree extinction coefficient
|
|
806
|
-
tree_lad (float): Leaf area density
|
|
807
|
-
hit_values (tuple): Values considered 'sky' (e.g. (0,))
|
|
808
|
-
inclusion_mode (bool): Whether we want to "include" or "exclude" these hit_values
|
|
809
|
-
grid_bounds_real (float64[2,3]): [[x_min, y_min, z_min],[x_max, y_max, z_max]]
|
|
810
|
-
boundary_epsilon (float): Distance threshold for bounding-box check
|
|
811
|
-
|
|
812
|
-
Returns:
|
|
813
|
-
(direct_irr, diffuse_irr, global_irr) as three float64[N] arrays
|
|
814
|
-
"""
|
|
815
|
-
n_faces = face_centers.shape[0]
|
|
816
|
-
|
|
817
|
-
face_direct = np.zeros(n_faces, dtype=np.float64)
|
|
818
|
-
face_diffuse = np.zeros(n_faces, dtype=np.float64)
|
|
819
|
-
face_global = np.zeros(n_faces, dtype=np.float64)
|
|
820
|
-
|
|
821
|
-
x_min, y_min, z_min = grid_bounds_real[0, 0], grid_bounds_real[0, 1], grid_bounds_real[0, 2]
|
|
822
|
-
x_max, y_max, z_max = grid_bounds_real[1, 0], grid_bounds_real[1, 1], grid_bounds_real[1, 2]
|
|
823
|
-
|
|
824
|
-
for fidx in range(n_faces):
|
|
825
|
-
center = face_centers[fidx]
|
|
826
|
-
normal = face_normals[fidx]
|
|
827
|
-
svf = face_svf[fidx]
|
|
828
|
-
|
|
829
|
-
# -- 1) Check for vertical boundary face
|
|
830
|
-
is_vertical = (abs(normal[2]) < 0.01)
|
|
831
|
-
|
|
832
|
-
on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
|
|
833
|
-
on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
|
|
834
|
-
on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
|
|
835
|
-
on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
|
|
836
|
-
|
|
837
|
-
is_boundary_vertical = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
|
|
838
|
-
|
|
839
|
-
if is_boundary_vertical:
|
|
840
|
-
face_direct[fidx] = np.nan
|
|
841
|
-
face_diffuse[fidx] = np.nan
|
|
842
|
-
face_global[fidx] = np.nan
|
|
843
|
-
continue
|
|
844
|
-
|
|
845
|
-
# If SVF is NaN, skip (means it was set to boundary or invalid earlier)
|
|
846
|
-
if svf != svf: # NaN check in Numba
|
|
847
|
-
face_direct[fidx] = np.nan
|
|
848
|
-
face_diffuse[fidx] = np.nan
|
|
849
|
-
face_global[fidx] = np.nan
|
|
850
|
-
continue
|
|
851
|
-
|
|
852
|
-
# -- 2) Direct irradiance (if face is oriented towards sun)
|
|
853
|
-
cos_incidence = normal[0]*sun_direction[0] + \
|
|
854
|
-
normal[1]*sun_direction[1] + \
|
|
855
|
-
normal[2]*sun_direction[2]
|
|
856
|
-
|
|
857
|
-
direct_val = 0.0
|
|
858
|
-
if cos_incidence > 0.0:
|
|
859
|
-
# Offset ray origin slightly to avoid self-intersection
|
|
860
|
-
offset_vox = 0.1
|
|
861
|
-
ray_origin_x = center[0]/meshsize + normal[0]*offset_vox
|
|
862
|
-
ray_origin_y = center[1]/meshsize + normal[1]*offset_vox
|
|
863
|
-
ray_origin_z = center[2]/meshsize + normal[2]*offset_vox
|
|
864
|
-
|
|
865
|
-
# Single ray toward the sun
|
|
866
|
-
hit_detected, transmittance = trace_ray_generic(
|
|
867
|
-
voxel_data,
|
|
868
|
-
np.array([ray_origin_x, ray_origin_y, ray_origin_z], dtype=np.float64),
|
|
869
|
-
sun_direction,
|
|
870
|
-
hit_values,
|
|
871
|
-
meshsize,
|
|
872
|
-
tree_k,
|
|
873
|
-
tree_lad,
|
|
874
|
-
inclusion_mode
|
|
875
|
-
)
|
|
876
|
-
if not hit_detected:
|
|
877
|
-
direct_val = direct_normal_irradiance * cos_incidence * transmittance
|
|
878
|
-
|
|
879
|
-
# -- 3) Diffuse irradiance from sky: use SVF * DHI
|
|
880
|
-
diffuse_val = svf * diffuse_irradiance
|
|
881
|
-
if diffuse_val > diffuse_irradiance:
|
|
882
|
-
diffuse_val = diffuse_irradiance
|
|
883
|
-
|
|
884
|
-
# -- 4) Sum up
|
|
885
|
-
face_direct[fidx] = direct_val
|
|
886
|
-
face_diffuse[fidx] = diffuse_val
|
|
887
|
-
face_global[fidx] = direct_val + diffuse_val
|
|
888
|
-
|
|
889
|
-
return face_direct, face_diffuse, face_global
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
##############################################################################
|
|
893
|
-
# 2) Modified get_building_solar_irradiance: main Python wrapper
|
|
894
|
-
##############################################################################
|
|
895
|
-
def get_building_solar_irradiance(
|
|
896
|
-
voxel_data,
|
|
897
|
-
meshsize,
|
|
898
|
-
building_svf_mesh,
|
|
899
|
-
azimuth_degrees,
|
|
900
|
-
elevation_degrees,
|
|
901
|
-
direct_normal_irradiance,
|
|
902
|
-
diffuse_irradiance,
|
|
903
|
-
**kwargs
|
|
904
|
-
):
|
|
905
|
-
"""
|
|
906
|
-
Calculate solar irradiance on building surfaces using SVF,
|
|
907
|
-
with the numeric per-face loop accelerated by Numba.
|
|
908
|
-
|
|
909
|
-
Args:
|
|
910
|
-
voxel_data (ndarray): 3D array of voxel values.
|
|
911
|
-
meshsize (float): Size of each voxel in meters.
|
|
912
|
-
building_svf_mesh (trimesh.Trimesh): Building mesh with SVF values in metadata.
|
|
913
|
-
azimuth_degrees (float): Sun azimuth angle in degrees (0=North, 90=East).
|
|
914
|
-
elevation_degrees (float): Sun elevation angle in degrees above horizon.
|
|
915
|
-
direct_normal_irradiance (float): DNI in W/m².
|
|
916
|
-
diffuse_irradiance (float): DHI in W/m².
|
|
917
|
-
**kwargs: Additional parameters, e.g. tree_k, tree_lad, progress_report, obj_export, etc.
|
|
918
|
-
|
|
919
|
-
Returns:
|
|
920
|
-
trimesh.Trimesh: A copy of the input mesh with direct/diffuse/global irradiance stored in metadata.
|
|
921
|
-
"""
|
|
922
|
-
import time
|
|
923
|
-
|
|
924
|
-
tree_k = kwargs.get("tree_k", 0.6)
|
|
925
|
-
tree_lad = kwargs.get("tree_lad", 1.0)
|
|
926
|
-
progress_report = kwargs.get("progress_report", False)
|
|
927
|
-
|
|
928
|
-
# Sky detection
|
|
929
|
-
hit_values = (0,) # '0' = sky
|
|
930
|
-
inclusion_mode = False
|
|
931
|
-
|
|
932
|
-
# Convert angles -> direction
|
|
933
|
-
az_rad = np.deg2rad(180 - azimuth_degrees)
|
|
934
|
-
el_rad = np.deg2rad(elevation_degrees)
|
|
935
|
-
sun_dx = np.cos(el_rad) * np.cos(az_rad)
|
|
936
|
-
sun_dy = np.cos(el_rad) * np.sin(az_rad)
|
|
937
|
-
sun_dz = np.sin(el_rad)
|
|
938
|
-
sun_direction = np.array([sun_dx, sun_dy, sun_dz], dtype=np.float64)
|
|
939
|
-
|
|
940
|
-
# Extract mesh data
|
|
941
|
-
face_centers = building_svf_mesh.triangles_center
|
|
942
|
-
face_normals = building_svf_mesh.face_normals
|
|
943
|
-
|
|
944
|
-
# Get SVF from metadata
|
|
945
|
-
if hasattr(building_svf_mesh, 'metadata') and ('svf' in building_svf_mesh.metadata):
|
|
946
|
-
face_svf = building_svf_mesh.metadata['svf']
|
|
947
|
-
else:
|
|
948
|
-
face_svf = np.zeros(len(building_svf_mesh.faces), dtype=np.float64)
|
|
949
|
-
|
|
950
|
-
# Prepare boundary checks
|
|
951
|
-
grid_shape = voxel_data.shape
|
|
952
|
-
grid_bounds_voxel = np.array([[0,0,0],[grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=np.float64)
|
|
953
|
-
grid_bounds_real = grid_bounds_voxel * meshsize
|
|
954
|
-
boundary_epsilon = meshsize * 0.05
|
|
955
|
-
|
|
956
|
-
# Call Numba-compiled function
|
|
957
|
-
t0 = time.time()
|
|
958
|
-
face_direct, face_diffuse, face_global = compute_solar_irradiance_for_all_faces(
|
|
959
|
-
face_centers,
|
|
960
|
-
face_normals,
|
|
961
|
-
face_svf,
|
|
962
|
-
sun_direction,
|
|
963
|
-
direct_normal_irradiance,
|
|
964
|
-
diffuse_irradiance,
|
|
965
|
-
voxel_data,
|
|
966
|
-
meshsize,
|
|
967
|
-
tree_k,
|
|
968
|
-
tree_lad,
|
|
969
|
-
hit_values,
|
|
970
|
-
inclusion_mode,
|
|
971
|
-
grid_bounds_real,
|
|
972
|
-
boundary_epsilon
|
|
973
|
-
)
|
|
974
|
-
if progress_report:
|
|
975
|
-
elapsed = time.time() - t0
|
|
976
|
-
print(f"Numba-based solar irradiance calculation took {elapsed:.2f} seconds")
|
|
977
|
-
|
|
978
|
-
# Create a copy of the mesh
|
|
979
|
-
irradiance_mesh = building_svf_mesh.copy()
|
|
980
|
-
if not hasattr(irradiance_mesh, 'metadata'):
|
|
981
|
-
irradiance_mesh.metadata = {}
|
|
982
|
-
|
|
983
|
-
# Store results
|
|
984
|
-
irradiance_mesh.metadata['svf'] = face_svf
|
|
985
|
-
irradiance_mesh.metadata['direct'] = face_direct
|
|
986
|
-
irradiance_mesh.metadata['diffuse'] = face_diffuse
|
|
987
|
-
irradiance_mesh.metadata['global'] = face_global
|
|
988
|
-
|
|
989
|
-
irradiance_mesh.name = "Solar Irradiance (W/m²)"
|
|
990
|
-
|
|
991
|
-
# # Optional OBJ export
|
|
992
|
-
# obj_export = kwargs.get("obj_export", False)
|
|
993
|
-
# if obj_export:
|
|
994
|
-
# _export_solar_irradiance_mesh(
|
|
995
|
-
# irradiance_mesh,
|
|
996
|
-
# face_global,
|
|
997
|
-
# **kwargs
|
|
998
|
-
# )
|
|
999
|
-
|
|
1000
|
-
return irradiance_mesh
|
|
1001
|
-
|
|
1002
|
-
##############################################################################
|
|
1003
|
-
# 4) Modified get_cumulative_building_solar_irradiance
|
|
1004
|
-
##############################################################################
|
|
1005
|
-
def get_cumulative_building_solar_irradiance(
|
|
1006
|
-
voxel_data,
|
|
1007
|
-
meshsize,
|
|
1008
|
-
building_svf_mesh,
|
|
1009
|
-
weather_df,
|
|
1010
|
-
lon, lat, tz,
|
|
1011
|
-
**kwargs
|
|
1012
|
-
):
|
|
1013
|
-
"""
|
|
1014
|
-
Calculate cumulative solar irradiance on building surfaces over a time period.
|
|
1015
|
-
Uses the Numba-accelerated get_building_solar_irradiance for each time step.
|
|
1016
|
-
|
|
1017
|
-
Args:
|
|
1018
|
-
voxel_data (ndarray): 3D array of voxel values.
|
|
1019
|
-
meshsize (float): Size of each voxel in meters.
|
|
1020
|
-
building_svf_mesh (trimesh.Trimesh): Mesh with pre-calculated SVF in metadata.
|
|
1021
|
-
weather_df (DataFrame): Weather data with DNI (W/m²) and DHI (W/m²).
|
|
1022
|
-
lon (float): Longitude in degrees.
|
|
1023
|
-
lat (float): Latitude in degrees.
|
|
1024
|
-
tz (float): Timezone offset in hours.
|
|
1025
|
-
**kwargs: Additional parameters for time range, scaling, OBJ export, etc.
|
|
1026
|
-
|
|
1027
|
-
Returns:
|
|
1028
|
-
trimesh.Trimesh: A mesh with cumulative (Wh/m²) irradiance in metadata.
|
|
1029
|
-
"""
|
|
1030
|
-
import pytz
|
|
1031
|
-
from datetime import datetime
|
|
1032
|
-
|
|
1033
|
-
period_start = kwargs.get("period_start", "01-01 00:00:00")
|
|
1034
|
-
period_end = kwargs.get("period_end", "12-31 23:59:59")
|
|
1035
|
-
time_step_hours = kwargs.get("time_step_hours", 1.0)
|
|
1036
|
-
direct_normal_irradiance_scaling = kwargs.get("direct_normal_irradiance_scaling", 1.0)
|
|
1037
|
-
diffuse_irradiance_scaling = kwargs.get("diffuse_irradiance_scaling", 1.0)
|
|
1038
|
-
|
|
1039
|
-
# Parse times, create local tz
|
|
1040
|
-
try:
|
|
1041
|
-
start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
|
|
1042
|
-
end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
|
|
1043
|
-
except ValueError as ve:
|
|
1044
|
-
raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
|
|
1045
|
-
|
|
1046
|
-
offset_minutes = int(tz * 60)
|
|
1047
|
-
local_tz = pytz.FixedOffset(offset_minutes)
|
|
1048
|
-
|
|
1049
|
-
# Filter weather_df
|
|
1050
|
-
df_period = weather_df[
|
|
1051
|
-
((weather_df.index.month > start_dt.month) |
|
|
1052
|
-
((weather_df.index.month == start_dt.month) &
|
|
1053
|
-
(weather_df.index.day >= start_dt.day) &
|
|
1054
|
-
(weather_df.index.hour >= start_dt.hour))) &
|
|
1055
|
-
((weather_df.index.month < end_dt.month) |
|
|
1056
|
-
((weather_df.index.month == end_dt.month) &
|
|
1057
|
-
(weather_df.index.day <= end_dt.day) &
|
|
1058
|
-
(weather_df.index.hour <= end_dt.hour)))
|
|
1059
|
-
]
|
|
1060
|
-
if df_period.empty:
|
|
1061
|
-
raise ValueError("No weather data in specified period.")
|
|
1062
|
-
|
|
1063
|
-
# Convert to local time, then to UTC
|
|
1064
|
-
df_period_local = df_period.copy()
|
|
1065
|
-
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
1066
|
-
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
1067
|
-
|
|
1068
|
-
# Get solar positions
|
|
1069
|
-
# You presumably have a get_solar_positions_astral(...) that returns az/elev
|
|
1070
|
-
solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
|
|
1071
|
-
|
|
1072
|
-
# Prepare arrays for accumulation
|
|
1073
|
-
n_faces = len(building_svf_mesh.faces)
|
|
1074
|
-
face_cum_direct = np.zeros(n_faces, dtype=np.float64)
|
|
1075
|
-
face_cum_diffuse = np.zeros(n_faces, dtype=np.float64)
|
|
1076
|
-
face_cum_global = np.zeros(n_faces, dtype=np.float64)
|
|
1077
|
-
|
|
1078
|
-
boundary_mask = None
|
|
1079
|
-
|
|
1080
|
-
# Iterate over each timestep
|
|
1081
|
-
for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
|
|
1082
|
-
DNI = row['DNI'] * direct_normal_irradiance_scaling
|
|
1083
|
-
DHI = row['DHI'] * diffuse_irradiance_scaling
|
|
1084
|
-
|
|
1085
|
-
# Sun angles
|
|
1086
|
-
az_deg = solar_positions.loc[time_utc, 'azimuth']
|
|
1087
|
-
el_deg = solar_positions.loc[time_utc, 'elevation']
|
|
1088
|
-
|
|
1089
|
-
# Skip if sun below horizon
|
|
1090
|
-
if el_deg <= 0:
|
|
1091
|
-
continue
|
|
1092
|
-
|
|
1093
|
-
# Call instantaneous function (Numba-accelerated inside)
|
|
1094
|
-
irr_mesh = get_building_solar_irradiance(
|
|
1095
|
-
voxel_data,
|
|
1096
|
-
meshsize,
|
|
1097
|
-
building_svf_mesh,
|
|
1098
|
-
az_deg,
|
|
1099
|
-
el_deg,
|
|
1100
|
-
DNI,
|
|
1101
|
-
DHI,
|
|
1102
|
-
show_plot=False, # or any other flags
|
|
1103
|
-
**kwargs
|
|
1104
|
-
)
|
|
1105
|
-
|
|
1106
|
-
# Extract arrays
|
|
1107
|
-
face_dir = irr_mesh.metadata['direct']
|
|
1108
|
-
face_diff = irr_mesh.metadata['diffuse']
|
|
1109
|
-
face_glob = irr_mesh.metadata['global']
|
|
1110
|
-
|
|
1111
|
-
# If first time, note boundary mask from NaNs
|
|
1112
|
-
if boundary_mask is None:
|
|
1113
|
-
boundary_mask = np.isnan(face_glob)
|
|
1114
|
-
|
|
1115
|
-
# Convert from W/m² to Wh/m² by multiplying time_step_hours
|
|
1116
|
-
face_cum_direct += np.nan_to_num(face_dir) * time_step_hours
|
|
1117
|
-
face_cum_diffuse += np.nan_to_num(face_diff) * time_step_hours
|
|
1118
|
-
face_cum_global += np.nan_to_num(face_glob) * time_step_hours
|
|
1119
|
-
|
|
1120
|
-
# Reapply NaN for boundary
|
|
1121
|
-
if boundary_mask is not None:
|
|
1122
|
-
face_cum_direct[boundary_mask] = np.nan
|
|
1123
|
-
face_cum_diffuse[boundary_mask] = np.nan
|
|
1124
|
-
face_cum_global[boundary_mask] = np.nan
|
|
1125
|
-
|
|
1126
|
-
# Create a new mesh with cumulative results
|
|
1127
|
-
cumulative_mesh = building_svf_mesh.copy()
|
|
1128
|
-
if not hasattr(cumulative_mesh, 'metadata'):
|
|
1129
|
-
cumulative_mesh.metadata = {}
|
|
1130
|
-
|
|
1131
|
-
# If original mesh had SVF
|
|
1132
|
-
if 'svf' in building_svf_mesh.metadata:
|
|
1133
|
-
cumulative_mesh.metadata['svf'] = building_svf_mesh.metadata['svf']
|
|
1134
|
-
|
|
1135
|
-
cumulative_mesh.metadata['direct'] = face_cum_direct
|
|
1136
|
-
cumulative_mesh.metadata['diffuse'] = face_cum_diffuse
|
|
1137
|
-
cumulative_mesh.metadata['global'] = face_cum_global
|
|
1138
|
-
|
|
1139
|
-
cumulative_mesh.name = "Cumulative Solar Irradiance (Wh/m²)"
|
|
1140
|
-
|
|
1141
|
-
# Optional export
|
|
1142
|
-
obj_export = kwargs.get("obj_export", False)
|
|
1143
|
-
if obj_export:
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
return cumulative_mesh
|
|
1151
|
-
|
|
1152
|
-
def get_building_global_solar_irradiance_using_epw(
|
|
1153
|
-
voxel_data,
|
|
1154
|
-
meshsize,
|
|
1155
|
-
calc_type='instantaneous',
|
|
1156
|
-
direct_normal_irradiance_scaling=1.0,
|
|
1157
|
-
diffuse_irradiance_scaling=1.0,
|
|
1158
|
-
**kwargs
|
|
1159
|
-
):
|
|
1160
|
-
"""
|
|
1161
|
-
Compute global solar irradiance on building surfaces using EPW weather data, either for a single time or cumulatively.
|
|
1162
|
-
|
|
1163
|
-
The function:
|
|
1164
|
-
1. Optionally downloads and reads EPW weather data
|
|
1165
|
-
2. Handles timezone conversions and solar position calculations
|
|
1166
|
-
3. Computes either instantaneous or cumulative irradiance on building surfaces
|
|
1167
|
-
4. Supports visualization and export options
|
|
1168
|
-
|
|
1169
|
-
Args:
|
|
1170
|
-
voxel_data (ndarray): 3D array of voxel values.
|
|
1171
|
-
meshsize (float): Size of each voxel in meters.
|
|
1172
|
-
building_svf_mesh (trimesh.Trimesh): Building mesh with pre-calculated SVF values in metadata.
|
|
1173
|
-
calc_type (str): 'instantaneous' or 'cumulative'.
|
|
1174
|
-
direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
|
|
1175
|
-
diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
|
|
1176
|
-
**kwargs: Additional arguments including:
|
|
1177
|
-
- download_nearest_epw (bool): Whether to download nearest EPW file
|
|
1178
|
-
- epw_file_path (str): Path to EPW file
|
|
1179
|
-
- rectangle_vertices (list): List of (lon,lat) coordinates for EPW download
|
|
1180
|
-
- output_dir (str): Directory for EPW download
|
|
1181
|
-
- calc_time (str): Time for instantaneous calculation ('MM-DD HH:MM:SS')
|
|
1182
|
-
- period_start (str): Start time for cumulative calculation ('MM-DD HH:MM:SS')
|
|
1183
|
-
- period_end (str): End time for cumulative calculation ('MM-DD HH:MM:SS')
|
|
1184
|
-
- time_step_hours (float): Time step for cumulative calculation
|
|
1185
|
-
- tree_k (float): Tree extinction coefficient
|
|
1186
|
-
- tree_lad (float): Leaf area density in m^-1
|
|
1187
|
-
- show_each_timestep (bool): Whether to show plots for each timestep
|
|
1188
|
-
- nan_color (str): Color for NaN values in visualization
|
|
1189
|
-
- colormap (str): Matplotlib colormap name
|
|
1190
|
-
- vmin (float): Minimum value for colormap
|
|
1191
|
-
- vmax (float): Maximum value for colormap
|
|
1192
|
-
- obj_export (bool): Whether to export as OBJ file
|
|
1193
|
-
- output_directory (str): Directory for OBJ export
|
|
1194
|
-
- output_file_name (str): Filename for OBJ export
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
#
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
else:
|
|
1371
|
-
raise ValueError("calc_type must be either 'instantaneous' or 'cumulative'")
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
from numba import njit, prange
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import pytz
|
|
7
|
+
from astral import Observer
|
|
8
|
+
from astral.sun import elevation, azimuth
|
|
9
|
+
|
|
10
|
+
from .view import trace_ray_generic, compute_vi_map_generic, get_sky_view_factor_map, get_surface_view_factor
|
|
11
|
+
from ..utils.weather import get_nearest_epw_from_climate_onebuilding, read_epw_for_solar_simulation
|
|
12
|
+
from ..exporter.obj import grid_to_obj, export_obj
|
|
13
|
+
|
|
14
|
+
@njit(parallel=True)
|
|
15
|
+
def compute_direct_solar_irradiance_map_binary(voxel_data, sun_direction, view_point_height, hit_values, meshsize, tree_k, tree_lad, inclusion_mode):
|
|
16
|
+
"""
|
|
17
|
+
Compute a map of direct solar irradiation accounting for tree transmittance.
|
|
18
|
+
|
|
19
|
+
The function:
|
|
20
|
+
1. Places observers at valid locations (empty voxels above ground)
|
|
21
|
+
2. Casts rays from each observer in the sun direction
|
|
22
|
+
3. Computes transmittance through trees using Beer-Lambert law
|
|
23
|
+
4. Returns a 2D map of transmittance values
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
voxel_data (ndarray): 3D array of voxel values.
|
|
27
|
+
sun_direction (tuple): Direction vector of the sun.
|
|
28
|
+
view_point_height (float): Observer height in meters.
|
|
29
|
+
hit_values (tuple): Values considered non-obstacles if inclusion_mode=False.
|
|
30
|
+
meshsize (float): Size of each voxel in meters.
|
|
31
|
+
tree_k (float): Tree extinction coefficient.
|
|
32
|
+
tree_lad (float): Leaf area density in m^-1.
|
|
33
|
+
inclusion_mode (bool): False here, meaning any voxel not in hit_values is an obstacle.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
ndarray: 2D array of transmittance values (0.0-1.0), NaN = invalid observer position.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
view_height_voxel = int(view_point_height / meshsize)
|
|
40
|
+
|
|
41
|
+
nx, ny, nz = voxel_data.shape
|
|
42
|
+
irradiance_map = np.full((nx, ny), np.nan, dtype=np.float64)
|
|
43
|
+
|
|
44
|
+
# Normalize sun direction vector for ray tracing
|
|
45
|
+
sd = np.array(sun_direction, dtype=np.float64)
|
|
46
|
+
sd_len = np.sqrt(sd[0]**2 + sd[1]**2 + sd[2]**2)
|
|
47
|
+
if sd_len == 0.0:
|
|
48
|
+
return np.flipud(irradiance_map)
|
|
49
|
+
sd /= sd_len
|
|
50
|
+
|
|
51
|
+
# Process each x,y position in parallel
|
|
52
|
+
for x in prange(nx):
|
|
53
|
+
for y in range(ny):
|
|
54
|
+
found_observer = False
|
|
55
|
+
# Search upward for valid observer position
|
|
56
|
+
for z in range(1, nz):
|
|
57
|
+
# Check if current voxel is empty/tree and voxel below is solid
|
|
58
|
+
if voxel_data[x, y, z] in (0, -2) and voxel_data[x, y, z - 1] not in (0, -2):
|
|
59
|
+
# Skip if standing on building/vegetation/water
|
|
60
|
+
if (voxel_data[x, y, z - 1] in (7, 8, 9)) or (voxel_data[x, y, z - 1] < 0):
|
|
61
|
+
irradiance_map[x, y] = np.nan
|
|
62
|
+
found_observer = True
|
|
63
|
+
break
|
|
64
|
+
else:
|
|
65
|
+
# Place observer and cast a ray in sun direction
|
|
66
|
+
observer_location = np.array([x, y, z + view_height_voxel], dtype=np.float64)
|
|
67
|
+
hit, transmittance = trace_ray_generic(voxel_data, observer_location, sd,
|
|
68
|
+
hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
|
|
69
|
+
irradiance_map[x, y] = transmittance if not hit else 0.0
|
|
70
|
+
found_observer = True
|
|
71
|
+
break
|
|
72
|
+
if not found_observer:
|
|
73
|
+
irradiance_map[x, y] = np.nan
|
|
74
|
+
|
|
75
|
+
# Flip map vertically to match visualization conventions
|
|
76
|
+
return np.flipud(irradiance_map)
|
|
77
|
+
|
|
78
|
+
def get_direct_solar_irradiance_map(voxel_data, meshsize, azimuth_degrees_ori, elevation_degrees,
|
|
79
|
+
direct_normal_irradiance, show_plot=False, **kwargs):
|
|
80
|
+
"""
|
|
81
|
+
Compute direct solar irradiance map with tree transmittance.
|
|
82
|
+
|
|
83
|
+
The function:
|
|
84
|
+
1. Converts sun angles to direction vector
|
|
85
|
+
2. Computes binary transmittance map
|
|
86
|
+
3. Scales by direct normal irradiance and sun elevation
|
|
87
|
+
4. Optionally visualizes and exports results
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
voxel_data (ndarray): 3D array of voxel values.
|
|
91
|
+
meshsize (float): Size of each voxel in meters.
|
|
92
|
+
azimuth_degrees_ori (float): Sun azimuth angle in degrees (0° = North, 90° = East).
|
|
93
|
+
elevation_degrees (float): Sun elevation angle in degrees above horizon.
|
|
94
|
+
direct_normal_irradiance (float): Direct normal irradiance in W/m².
|
|
95
|
+
show_plot (bool): Whether to display visualization.
|
|
96
|
+
**kwargs: Additional arguments including:
|
|
97
|
+
- view_point_height (float): Observer height in meters (default: 1.5)
|
|
98
|
+
- colormap (str): Matplotlib colormap name (default: 'magma')
|
|
99
|
+
- vmin (float): Minimum value for colormap
|
|
100
|
+
- vmax (float): Maximum value for colormap
|
|
101
|
+
- tree_k (float): Tree extinction coefficient (default: 0.6)
|
|
102
|
+
- tree_lad (float): Leaf area density in m^-1 (default: 1.0)
|
|
103
|
+
- obj_export (bool): Whether to export as OBJ file
|
|
104
|
+
- output_directory (str): Directory for OBJ export
|
|
105
|
+
- output_file_name (str): Filename for OBJ export
|
|
106
|
+
- dem_grid (ndarray): DEM grid for OBJ export
|
|
107
|
+
- num_colors (int): Number of colors for OBJ export
|
|
108
|
+
- alpha (float): Alpha value for OBJ export
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
ndarray: 2D array of direct solar irradiance values (W/m²).
|
|
112
|
+
"""
|
|
113
|
+
view_point_height = kwargs.get("view_point_height", 1.5)
|
|
114
|
+
colormap = kwargs.get("colormap", 'magma')
|
|
115
|
+
vmin = kwargs.get("vmin", 0.0)
|
|
116
|
+
vmax = kwargs.get("vmax", direct_normal_irradiance)
|
|
117
|
+
|
|
118
|
+
# Get tree transmittance parameters
|
|
119
|
+
tree_k = kwargs.get("tree_k", 0.6)
|
|
120
|
+
tree_lad = kwargs.get("tree_lad", 1.0)
|
|
121
|
+
|
|
122
|
+
# Convert sun angles to direction vector
|
|
123
|
+
# Note: azimuth is adjusted by 180° to match coordinate system
|
|
124
|
+
azimuth_degrees = 180 - azimuth_degrees_ori
|
|
125
|
+
azimuth_radians = np.deg2rad(azimuth_degrees)
|
|
126
|
+
elevation_radians = np.deg2rad(elevation_degrees)
|
|
127
|
+
dx = np.cos(elevation_radians) * np.cos(azimuth_radians)
|
|
128
|
+
dy = np.cos(elevation_radians) * np.sin(azimuth_radians)
|
|
129
|
+
dz = np.sin(elevation_radians)
|
|
130
|
+
sun_direction = (dx, dy, dz)
|
|
131
|
+
|
|
132
|
+
# All non-zero voxels are obstacles except for trees which have transmittance
|
|
133
|
+
hit_values = (0,)
|
|
134
|
+
inclusion_mode = False
|
|
135
|
+
|
|
136
|
+
# Compute transmittance map
|
|
137
|
+
transmittance_map = compute_direct_solar_irradiance_map_binary(
|
|
138
|
+
voxel_data, sun_direction, view_point_height, hit_values,
|
|
139
|
+
meshsize, tree_k, tree_lad, inclusion_mode
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Scale by direct normal irradiance and sun elevation
|
|
143
|
+
sin_elev = dz
|
|
144
|
+
direct_map = transmittance_map * direct_normal_irradiance * sin_elev
|
|
145
|
+
|
|
146
|
+
# Optional visualization
|
|
147
|
+
if show_plot:
|
|
148
|
+
cmap = plt.cm.get_cmap(colormap).copy()
|
|
149
|
+
cmap.set_bad(color='lightgray')
|
|
150
|
+
plt.figure(figsize=(10, 8))
|
|
151
|
+
# plt.title("Horizontal Direct Solar Irradiance Map (0° = North)")
|
|
152
|
+
plt.imshow(direct_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
153
|
+
plt.colorbar(label='Direct Solar Irradiance (W/m²)')
|
|
154
|
+
plt.axis('off')
|
|
155
|
+
plt.show()
|
|
156
|
+
|
|
157
|
+
# Optional OBJ export
|
|
158
|
+
obj_export = kwargs.get("obj_export", False)
|
|
159
|
+
if obj_export:
|
|
160
|
+
dem_grid = kwargs.get("dem_grid", np.zeros_like(direct_map))
|
|
161
|
+
output_dir = kwargs.get("output_directory", "output")
|
|
162
|
+
output_file_name = kwargs.get("output_file_name", "direct_solar_irradiance")
|
|
163
|
+
num_colors = kwargs.get("num_colors", 10)
|
|
164
|
+
alpha = kwargs.get("alpha", 1.0)
|
|
165
|
+
grid_to_obj(
|
|
166
|
+
direct_map,
|
|
167
|
+
dem_grid,
|
|
168
|
+
output_dir,
|
|
169
|
+
output_file_name,
|
|
170
|
+
meshsize,
|
|
171
|
+
view_point_height,
|
|
172
|
+
colormap_name=colormap,
|
|
173
|
+
num_colors=num_colors,
|
|
174
|
+
alpha=alpha,
|
|
175
|
+
vmin=vmin,
|
|
176
|
+
vmax=vmax
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return direct_map
|
|
180
|
+
|
|
181
|
+
def get_diffuse_solar_irradiance_map(voxel_data, meshsize, diffuse_irradiance=1.0, show_plot=False, **kwargs):
|
|
182
|
+
"""
|
|
183
|
+
Compute diffuse solar irradiance map using the Sky View Factor (SVF) with tree transmittance.
|
|
184
|
+
|
|
185
|
+
The function:
|
|
186
|
+
1. Computes SVF map accounting for tree transmittance
|
|
187
|
+
2. Scales SVF by diffuse horizontal irradiance
|
|
188
|
+
3. Optionally visualizes and exports results
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
voxel_data (ndarray): 3D array of voxel values.
|
|
192
|
+
meshsize (float): Size of each voxel in meters.
|
|
193
|
+
diffuse_irradiance (float): Diffuse horizontal irradiance in W/m².
|
|
194
|
+
show_plot (bool): Whether to display visualization.
|
|
195
|
+
**kwargs: Additional arguments including:
|
|
196
|
+
- view_point_height (float): Observer height in meters (default: 1.5)
|
|
197
|
+
- colormap (str): Matplotlib colormap name (default: 'magma')
|
|
198
|
+
- vmin (float): Minimum value for colormap
|
|
199
|
+
- vmax (float): Maximum value for colormap
|
|
200
|
+
- tree_k (float): Tree extinction coefficient
|
|
201
|
+
- tree_lad (float): Leaf area density in m^-1
|
|
202
|
+
- obj_export (bool): Whether to export as OBJ file
|
|
203
|
+
- output_directory (str): Directory for OBJ export
|
|
204
|
+
- output_file_name (str): Filename for OBJ export
|
|
205
|
+
- dem_grid (ndarray): DEM grid for OBJ export
|
|
206
|
+
- num_colors (int): Number of colors for OBJ export
|
|
207
|
+
- alpha (float): Alpha value for OBJ export
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
ndarray: 2D array of diffuse solar irradiance values (W/m²).
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
view_point_height = kwargs.get("view_point_height", 1.5)
|
|
214
|
+
colormap = kwargs.get("colormap", 'magma')
|
|
215
|
+
vmin = kwargs.get("vmin", 0.0)
|
|
216
|
+
vmax = kwargs.get("vmax", diffuse_irradiance)
|
|
217
|
+
|
|
218
|
+
# Pass tree transmittance parameters to SVF calculation
|
|
219
|
+
svf_kwargs = kwargs.copy()
|
|
220
|
+
svf_kwargs["colormap"] = "BuPu_r"
|
|
221
|
+
svf_kwargs["vmin"] = 0
|
|
222
|
+
svf_kwargs["vmax"] = 1
|
|
223
|
+
|
|
224
|
+
# SVF calculation now handles tree transmittance internally
|
|
225
|
+
SVF_map = get_sky_view_factor_map(voxel_data, meshsize, **svf_kwargs)
|
|
226
|
+
diffuse_map = SVF_map * diffuse_irradiance
|
|
227
|
+
|
|
228
|
+
# Optional visualization
|
|
229
|
+
if show_plot:
|
|
230
|
+
vmin = kwargs.get("vmin", 0.0)
|
|
231
|
+
vmax = kwargs.get("vmax", diffuse_irradiance)
|
|
232
|
+
cmap = plt.cm.get_cmap(colormap).copy()
|
|
233
|
+
cmap.set_bad(color='lightgray')
|
|
234
|
+
plt.figure(figsize=(10, 8))
|
|
235
|
+
# plt.title("Diffuse Solar Irradiance Map")
|
|
236
|
+
plt.imshow(diffuse_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
237
|
+
plt.colorbar(label='Diffuse Solar Irradiance (W/m²)')
|
|
238
|
+
plt.axis('off')
|
|
239
|
+
plt.show()
|
|
240
|
+
|
|
241
|
+
# Optional OBJ export
|
|
242
|
+
obj_export = kwargs.get("obj_export", False)
|
|
243
|
+
if obj_export:
|
|
244
|
+
dem_grid = kwargs.get("dem_grid", np.zeros_like(diffuse_map))
|
|
245
|
+
output_dir = kwargs.get("output_directory", "output")
|
|
246
|
+
output_file_name = kwargs.get("output_file_name", "diffuse_solar_irradiance")
|
|
247
|
+
num_colors = kwargs.get("num_colors", 10)
|
|
248
|
+
alpha = kwargs.get("alpha", 1.0)
|
|
249
|
+
grid_to_obj(
|
|
250
|
+
diffuse_map,
|
|
251
|
+
dem_grid,
|
|
252
|
+
output_dir,
|
|
253
|
+
output_file_name,
|
|
254
|
+
meshsize,
|
|
255
|
+
view_point_height,
|
|
256
|
+
colormap_name=colormap,
|
|
257
|
+
num_colors=num_colors,
|
|
258
|
+
alpha=alpha,
|
|
259
|
+
vmin=vmin,
|
|
260
|
+
vmax=vmax
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
return diffuse_map
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def get_global_solar_irradiance_map(
|
|
267
|
+
voxel_data,
|
|
268
|
+
meshsize,
|
|
269
|
+
azimuth_degrees,
|
|
270
|
+
elevation_degrees,
|
|
271
|
+
direct_normal_irradiance,
|
|
272
|
+
diffuse_irradiance,
|
|
273
|
+
show_plot=False,
|
|
274
|
+
**kwargs
|
|
275
|
+
):
|
|
276
|
+
"""
|
|
277
|
+
Compute global solar irradiance (direct + diffuse) on a horizontal plane at each valid observer location.
|
|
278
|
+
|
|
279
|
+
The function:
|
|
280
|
+
1. Computes direct solar irradiance map
|
|
281
|
+
2. Computes diffuse solar irradiance map
|
|
282
|
+
3. Combines maps and optionally visualizes/exports results
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
voxel_data (ndarray): 3D voxel array.
|
|
286
|
+
meshsize (float): Voxel size in meters.
|
|
287
|
+
azimuth_degrees (float): Sun azimuth angle in degrees (0° = North, 90° = East).
|
|
288
|
+
elevation_degrees (float): Sun elevation angle in degrees above horizon.
|
|
289
|
+
direct_normal_irradiance (float): Direct normal irradiance in W/m².
|
|
290
|
+
diffuse_irradiance (float): Diffuse horizontal irradiance in W/m².
|
|
291
|
+
show_plot (bool): Whether to display visualization.
|
|
292
|
+
**kwargs: Additional arguments including:
|
|
293
|
+
- view_point_height (float): Observer height in meters (default: 1.5)
|
|
294
|
+
- colormap (str): Matplotlib colormap name (default: 'magma')
|
|
295
|
+
- vmin (float): Minimum value for colormap
|
|
296
|
+
- vmax (float): Maximum value for colormap
|
|
297
|
+
- tree_k (float): Tree extinction coefficient
|
|
298
|
+
- tree_lad (float): Leaf area density in m^-1
|
|
299
|
+
- obj_export (bool): Whether to export as OBJ file
|
|
300
|
+
- output_directory (str): Directory for OBJ export
|
|
301
|
+
- output_file_name (str): Filename for OBJ export
|
|
302
|
+
- dem_grid (ndarray): DEM grid for OBJ export
|
|
303
|
+
- num_colors (int): Number of colors for OBJ export
|
|
304
|
+
- alpha (float): Alpha value for OBJ export
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
ndarray: 2D array of global solar irradiance values (W/m²).
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
colormap = kwargs.get("colormap", 'magma')
|
|
311
|
+
|
|
312
|
+
# Create kwargs for diffuse calculation
|
|
313
|
+
direct_diffuse_kwargs = kwargs.copy()
|
|
314
|
+
direct_diffuse_kwargs.update({
|
|
315
|
+
'show_plot': True,
|
|
316
|
+
'obj_export': False
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
# Compute direct irradiance map (no mode/hit_values/inclusion_mode needed)
|
|
320
|
+
direct_map = get_direct_solar_irradiance_map(
|
|
321
|
+
voxel_data,
|
|
322
|
+
meshsize,
|
|
323
|
+
azimuth_degrees,
|
|
324
|
+
elevation_degrees,
|
|
325
|
+
direct_normal_irradiance,
|
|
326
|
+
**direct_diffuse_kwargs
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Compute diffuse irradiance map
|
|
330
|
+
diffuse_map = get_diffuse_solar_irradiance_map(
|
|
331
|
+
voxel_data,
|
|
332
|
+
meshsize,
|
|
333
|
+
diffuse_irradiance=diffuse_irradiance,
|
|
334
|
+
**direct_diffuse_kwargs
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Sum the two components
|
|
338
|
+
global_map = direct_map + diffuse_map
|
|
339
|
+
|
|
340
|
+
vmin = kwargs.get("vmin", np.nanmin(global_map))
|
|
341
|
+
vmax = kwargs.get("vmax", np.nanmax(global_map))
|
|
342
|
+
|
|
343
|
+
# Optional visualization
|
|
344
|
+
if show_plot:
|
|
345
|
+
cmap = plt.cm.get_cmap(colormap).copy()
|
|
346
|
+
cmap.set_bad(color='lightgray')
|
|
347
|
+
plt.figure(figsize=(10, 8))
|
|
348
|
+
# plt.title("Global Solar Irradiance Map")
|
|
349
|
+
plt.imshow(global_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
350
|
+
plt.colorbar(label='Global Solar Irradiance (W/m²)')
|
|
351
|
+
plt.axis('off')
|
|
352
|
+
plt.show()
|
|
353
|
+
|
|
354
|
+
# Optional OBJ export
|
|
355
|
+
obj_export = kwargs.get("obj_export", False)
|
|
356
|
+
if obj_export:
|
|
357
|
+
dem_grid = kwargs.get("dem_grid", np.zeros_like(global_map))
|
|
358
|
+
output_dir = kwargs.get("output_directory", "output")
|
|
359
|
+
output_file_name = kwargs.get("output_file_name", "global_solar_irradiance")
|
|
360
|
+
num_colors = kwargs.get("num_colors", 10)
|
|
361
|
+
alpha = kwargs.get("alpha", 1.0)
|
|
362
|
+
meshsize_param = kwargs.get("meshsize", meshsize)
|
|
363
|
+
view_point_height = kwargs.get("view_point_height", 1.5)
|
|
364
|
+
grid_to_obj(
|
|
365
|
+
global_map,
|
|
366
|
+
dem_grid,
|
|
367
|
+
output_dir,
|
|
368
|
+
output_file_name,
|
|
369
|
+
meshsize_param,
|
|
370
|
+
view_point_height,
|
|
371
|
+
colormap_name=colormap,
|
|
372
|
+
num_colors=num_colors,
|
|
373
|
+
alpha=alpha,
|
|
374
|
+
vmin=vmin,
|
|
375
|
+
vmax=vmax
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
return global_map
|
|
379
|
+
|
|
380
|
+
def get_solar_positions_astral(times, lon, lat):
|
|
381
|
+
"""
|
|
382
|
+
Compute solar azimuth and elevation using Astral for given times and location.
|
|
383
|
+
|
|
384
|
+
The function:
|
|
385
|
+
1. Creates an Astral observer at the specified location
|
|
386
|
+
2. Computes sun position for each timestamp
|
|
387
|
+
3. Returns DataFrame with azimuth and elevation angles
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
times (DatetimeIndex): Array of timezone-aware datetime objects.
|
|
391
|
+
lon (float): Longitude in degrees.
|
|
392
|
+
lat (float): Latitude in degrees.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
DataFrame: DataFrame with columns 'azimuth' and 'elevation' containing solar positions.
|
|
396
|
+
"""
|
|
397
|
+
observer = Observer(latitude=lat, longitude=lon)
|
|
398
|
+
df_pos = pd.DataFrame(index=times, columns=['azimuth', 'elevation'], dtype=float)
|
|
399
|
+
|
|
400
|
+
for t in times:
|
|
401
|
+
# t is already timezone-aware; no need to replace tzinfo
|
|
402
|
+
el = elevation(observer=observer, dateandtime=t)
|
|
403
|
+
az = azimuth(observer=observer, dateandtime=t)
|
|
404
|
+
df_pos.at[t, 'elevation'] = el
|
|
405
|
+
df_pos.at[t, 'azimuth'] = az
|
|
406
|
+
|
|
407
|
+
return df_pos
|
|
408
|
+
|
|
409
|
+
def get_cumulative_global_solar_irradiance(
|
|
410
|
+
voxel_data,
|
|
411
|
+
meshsize,
|
|
412
|
+
df, lon, lat, tz,
|
|
413
|
+
direct_normal_irradiance_scaling=1.0,
|
|
414
|
+
diffuse_irradiance_scaling=1.0,
|
|
415
|
+
**kwargs
|
|
416
|
+
):
|
|
417
|
+
"""
|
|
418
|
+
Compute cumulative global solar irradiance over a specified period using data from an EPW file.
|
|
419
|
+
|
|
420
|
+
The function:
|
|
421
|
+
1. Filters EPW data for specified time period
|
|
422
|
+
2. Computes sun positions for each timestep
|
|
423
|
+
3. Calculates and accumulates global irradiance maps
|
|
424
|
+
4. Handles tree transmittance and visualization
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
voxel_data (ndarray): 3D array of voxel values.
|
|
428
|
+
meshsize (float): Size of each voxel in meters.
|
|
429
|
+
df (DataFrame): EPW weather data.
|
|
430
|
+
lon (float): Longitude in degrees.
|
|
431
|
+
lat (float): Latitude in degrees.
|
|
432
|
+
tz (float): Timezone offset in hours.
|
|
433
|
+
direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
|
|
434
|
+
diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
|
|
435
|
+
**kwargs: Additional arguments including:
|
|
436
|
+
- view_point_height (float): Observer height in meters (default: 1.5)
|
|
437
|
+
- start_time (str): Start time in format 'MM-DD HH:MM:SS'
|
|
438
|
+
- end_time (str): End time in format 'MM-DD HH:MM:SS'
|
|
439
|
+
- tree_k (float): Tree extinction coefficient
|
|
440
|
+
- tree_lad (float): Leaf area density in m^-1
|
|
441
|
+
- show_plot (bool): Whether to show final plot
|
|
442
|
+
- show_each_timestep (bool): Whether to show plots for each timestep
|
|
443
|
+
- colormap (str): Matplotlib colormap name
|
|
444
|
+
- vmin (float): Minimum value for colormap
|
|
445
|
+
- vmax (float): Maximum value for colormap
|
|
446
|
+
- obj_export (bool): Whether to export as OBJ file
|
|
447
|
+
- output_directory (str): Directory for OBJ export
|
|
448
|
+
- output_file_name (str): Filename for OBJ export
|
|
449
|
+
- dem_grid (ndarray): DEM grid for OBJ export
|
|
450
|
+
- num_colors (int): Number of colors for OBJ export
|
|
451
|
+
- alpha (float): Alpha value for OBJ export
|
|
452
|
+
|
|
453
|
+
Returns:
|
|
454
|
+
ndarray: 2D array of cumulative global solar irradiance values (W/m²·hour).
|
|
455
|
+
"""
|
|
456
|
+
view_point_height = kwargs.get("view_point_height", 1.5)
|
|
457
|
+
colormap = kwargs.get("colormap", 'magma')
|
|
458
|
+
start_time = kwargs.get("start_time", "01-01 05:00:00")
|
|
459
|
+
end_time = kwargs.get("end_time", "01-01 20:00:00")
|
|
460
|
+
|
|
461
|
+
if df.empty:
|
|
462
|
+
raise ValueError("No data in EPW file.")
|
|
463
|
+
|
|
464
|
+
# Parse start and end times without year
|
|
465
|
+
try:
|
|
466
|
+
start_dt = datetime.strptime(start_time, "%m-%d %H:%M:%S")
|
|
467
|
+
end_dt = datetime.strptime(end_time, "%m-%d %H:%M:%S")
|
|
468
|
+
except ValueError as ve:
|
|
469
|
+
raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
|
|
470
|
+
|
|
471
|
+
# Add hour of year column and filter data
|
|
472
|
+
df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
|
|
473
|
+
|
|
474
|
+
# Convert dates to day of year and hour
|
|
475
|
+
start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
|
|
476
|
+
end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
|
|
477
|
+
|
|
478
|
+
start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
|
|
479
|
+
end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
|
|
480
|
+
|
|
481
|
+
# Handle period crossing year boundary
|
|
482
|
+
if start_hour <= end_hour:
|
|
483
|
+
df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
|
|
484
|
+
else:
|
|
485
|
+
df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
|
|
486
|
+
|
|
487
|
+
# Filter by minutes within start/end hours
|
|
488
|
+
df_period = df_period[
|
|
489
|
+
((df_period.index.hour != start_dt.hour) | (df_period.index.minute >= start_dt.minute)) &
|
|
490
|
+
((df_period.index.hour != end_dt.hour) | (df_period.index.minute <= end_dt.minute))
|
|
491
|
+
]
|
|
492
|
+
|
|
493
|
+
if df_period.empty:
|
|
494
|
+
raise ValueError("No EPW data in the specified period.")
|
|
495
|
+
|
|
496
|
+
# Handle timezone conversion
|
|
497
|
+
offset_minutes = int(tz * 60)
|
|
498
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
499
|
+
df_period_local = df_period.copy()
|
|
500
|
+
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
501
|
+
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
502
|
+
|
|
503
|
+
# Compute solar positions for period
|
|
504
|
+
solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
|
|
505
|
+
|
|
506
|
+
# Create kwargs for diffuse calculation
|
|
507
|
+
diffuse_kwargs = kwargs.copy()
|
|
508
|
+
diffuse_kwargs.update({
|
|
509
|
+
'show_plot': False,
|
|
510
|
+
'obj_export': False
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
# Compute base diffuse map once with diffuse_irradiance=1.0
|
|
514
|
+
base_diffuse_map = get_diffuse_solar_irradiance_map(
|
|
515
|
+
voxel_data,
|
|
516
|
+
meshsize,
|
|
517
|
+
diffuse_irradiance=1.0,
|
|
518
|
+
**diffuse_kwargs
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# Initialize accumulation maps
|
|
522
|
+
cumulative_map = np.zeros((voxel_data.shape[0], voxel_data.shape[1]))
|
|
523
|
+
mask_map = np.ones((voxel_data.shape[0], voxel_data.shape[1]), dtype=bool)
|
|
524
|
+
|
|
525
|
+
# Create kwargs for direct calculation
|
|
526
|
+
direct_kwargs = kwargs.copy()
|
|
527
|
+
direct_kwargs.update({
|
|
528
|
+
'show_plot': False,
|
|
529
|
+
'view_point_height': view_point_height,
|
|
530
|
+
'obj_export': False
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
# Process each timestep
|
|
534
|
+
for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
|
|
535
|
+
# Get scaled irradiance values
|
|
536
|
+
DNI = row['DNI'] * direct_normal_irradiance_scaling
|
|
537
|
+
DHI = row['DHI'] * diffuse_irradiance_scaling
|
|
538
|
+
time_local = df_period_local.index[idx]
|
|
539
|
+
|
|
540
|
+
# Get solar position for timestep
|
|
541
|
+
solpos = solar_positions.loc[time_utc]
|
|
542
|
+
azimuth_degrees = solpos['azimuth']
|
|
543
|
+
elevation_degrees = solpos['elevation']
|
|
544
|
+
|
|
545
|
+
# Compute direct irradiance map with transmittance
|
|
546
|
+
direct_map = get_direct_solar_irradiance_map(
|
|
547
|
+
voxel_data,
|
|
548
|
+
meshsize,
|
|
549
|
+
azimuth_degrees,
|
|
550
|
+
elevation_degrees,
|
|
551
|
+
direct_normal_irradiance=DNI,
|
|
552
|
+
**direct_kwargs
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
# Scale base diffuse map by actual DHI
|
|
556
|
+
diffuse_map = base_diffuse_map * DHI
|
|
557
|
+
|
|
558
|
+
# Combine direct and diffuse components
|
|
559
|
+
global_map = direct_map + diffuse_map
|
|
560
|
+
|
|
561
|
+
# Update valid pixel mask
|
|
562
|
+
mask_map &= ~np.isnan(global_map)
|
|
563
|
+
|
|
564
|
+
# Replace NaN with 0 for accumulation
|
|
565
|
+
global_map_filled = np.nan_to_num(global_map, nan=0.0)
|
|
566
|
+
cumulative_map += global_map_filled
|
|
567
|
+
|
|
568
|
+
# Optional timestep visualization
|
|
569
|
+
show_each_timestep = kwargs.get("show_each_timestep", False)
|
|
570
|
+
if show_each_timestep:
|
|
571
|
+
colormap = kwargs.get("colormap", 'viridis')
|
|
572
|
+
vmin = kwargs.get("vmin", 0.0)
|
|
573
|
+
vmax = kwargs.get("vmax", max(direct_normal_irradiance_scaling, diffuse_irradiance_scaling) * 1000)
|
|
574
|
+
cmap = plt.cm.get_cmap(colormap).copy()
|
|
575
|
+
cmap.set_bad(color='lightgray')
|
|
576
|
+
plt.figure(figsize=(10, 8))
|
|
577
|
+
# plt.title(f"Global Solar Irradiance at {time_local.strftime('%Y-%m-%d %H:%M:%S')}")
|
|
578
|
+
plt.imshow(global_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
579
|
+
plt.axis('off')
|
|
580
|
+
plt.colorbar(label='Global Solar Irradiance (W/m²)')
|
|
581
|
+
plt.show()
|
|
582
|
+
|
|
583
|
+
# Apply mask to final result
|
|
584
|
+
cumulative_map[~mask_map] = np.nan
|
|
585
|
+
|
|
586
|
+
# Final visualization
|
|
587
|
+
show_plot = kwargs.get("show_plot", True)
|
|
588
|
+
if show_plot:
|
|
589
|
+
colormap = kwargs.get("colormap", 'magma')
|
|
590
|
+
vmin = kwargs.get("vmin", np.nanmin(cumulative_map))
|
|
591
|
+
vmax = kwargs.get("vmax", np.nanmax(cumulative_map))
|
|
592
|
+
cmap = plt.cm.get_cmap(colormap).copy()
|
|
593
|
+
cmap.set_bad(color='lightgray')
|
|
594
|
+
plt.figure(figsize=(10, 8))
|
|
595
|
+
# plt.title("Cumulative Global Solar Irradiance Map")
|
|
596
|
+
plt.imshow(cumulative_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
|
|
597
|
+
plt.colorbar(label='Cumulative Global Solar Irradiance (W/m²·hour)')
|
|
598
|
+
plt.axis('off')
|
|
599
|
+
plt.show()
|
|
600
|
+
|
|
601
|
+
# Optional OBJ export
|
|
602
|
+
obj_export = kwargs.get("obj_export", False)
|
|
603
|
+
if obj_export:
|
|
604
|
+
colormap = kwargs.get("colormap", "magma")
|
|
605
|
+
vmin = kwargs.get("vmin", np.nanmin(cumulative_map))
|
|
606
|
+
vmax = kwargs.get("vmax", np.nanmax(cumulative_map))
|
|
607
|
+
dem_grid = kwargs.get("dem_grid", np.zeros_like(cumulative_map))
|
|
608
|
+
output_dir = kwargs.get("output_directory", "output")
|
|
609
|
+
output_file_name = kwargs.get("output_file_name", "cummurative_global_solar_irradiance")
|
|
610
|
+
num_colors = kwargs.get("num_colors", 10)
|
|
611
|
+
alpha = kwargs.get("alpha", 1.0)
|
|
612
|
+
grid_to_obj(
|
|
613
|
+
cumulative_map,
|
|
614
|
+
dem_grid,
|
|
615
|
+
output_dir,
|
|
616
|
+
output_file_name,
|
|
617
|
+
meshsize,
|
|
618
|
+
view_point_height,
|
|
619
|
+
colormap_name=colormap,
|
|
620
|
+
num_colors=num_colors,
|
|
621
|
+
alpha=alpha,
|
|
622
|
+
vmin=vmin,
|
|
623
|
+
vmax=vmax
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
return cumulative_map
|
|
627
|
+
|
|
628
|
+
def get_global_solar_irradiance_using_epw(
|
|
629
|
+
voxel_data,
|
|
630
|
+
meshsize,
|
|
631
|
+
calc_type='instantaneous',
|
|
632
|
+
direct_normal_irradiance_scaling=1.0,
|
|
633
|
+
diffuse_irradiance_scaling=1.0,
|
|
634
|
+
**kwargs
|
|
635
|
+
):
|
|
636
|
+
"""
|
|
637
|
+
Compute global solar irradiance using EPW weather data, either for a single time or cumulatively over a period.
|
|
638
|
+
|
|
639
|
+
The function:
|
|
640
|
+
1. Optionally downloads and reads EPW weather data
|
|
641
|
+
2. Handles timezone conversions and solar position calculations
|
|
642
|
+
3. Computes either instantaneous or cumulative irradiance maps
|
|
643
|
+
4. Supports visualization and export options
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
voxel_data (ndarray): 3D array of voxel values.
|
|
647
|
+
meshsize (float): Size of each voxel in meters.
|
|
648
|
+
calc_type (str): 'instantaneous' or 'cumulative'.
|
|
649
|
+
direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
|
|
650
|
+
diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
|
|
651
|
+
**kwargs: Additional arguments including:
|
|
652
|
+
- download_nearest_epw (bool): Whether to download nearest EPW file
|
|
653
|
+
- epw_file_path (str): Path to EPW file
|
|
654
|
+
- rectangle_vertices (list): List of (lat,lon) coordinates for EPW download
|
|
655
|
+
- output_dir (str): Directory for EPW download
|
|
656
|
+
- calc_time (str): Time for instantaneous calculation ('MM-DD HH:MM:SS')
|
|
657
|
+
- start_time (str): Start time for cumulative calculation
|
|
658
|
+
- end_time (str): End time for cumulative calculation
|
|
659
|
+
- start_hour (int): Starting hour for daily time window (0-23)
|
|
660
|
+
- end_hour (int): Ending hour for daily time window (0-23)
|
|
661
|
+
- view_point_height (float): Observer height in meters
|
|
662
|
+
- tree_k (float): Tree extinction coefficient
|
|
663
|
+
- tree_lad (float): Leaf area density in m^-1
|
|
664
|
+
- show_plot (bool): Whether to show visualization
|
|
665
|
+
- show_each_timestep (bool): Whether to show timestep plots
|
|
666
|
+
- colormap (str): Matplotlib colormap name
|
|
667
|
+
- obj_export (bool): Whether to export as OBJ file
|
|
668
|
+
|
|
669
|
+
Returns:
|
|
670
|
+
ndarray: 2D array of solar irradiance values (W/m²).
|
|
671
|
+
"""
|
|
672
|
+
view_point_height = kwargs.get("view_point_height", 1.5)
|
|
673
|
+
colormap = kwargs.get("colormap", 'magma')
|
|
674
|
+
|
|
675
|
+
# Get EPW file
|
|
676
|
+
download_nearest_epw = kwargs.get("download_nearest_epw", False)
|
|
677
|
+
rectangle_vertices = kwargs.get("rectangle_vertices", None)
|
|
678
|
+
epw_file_path = kwargs.get("epw_file_path", None)
|
|
679
|
+
if download_nearest_epw:
|
|
680
|
+
if rectangle_vertices is None:
|
|
681
|
+
print("rectangle_vertices is required to download nearest EPW file")
|
|
682
|
+
return None
|
|
683
|
+
else:
|
|
684
|
+
# Calculate center point of rectangle
|
|
685
|
+
lons = [coord[0] for coord in rectangle_vertices]
|
|
686
|
+
lats = [coord[1] for coord in rectangle_vertices]
|
|
687
|
+
center_lon = (min(lons) + max(lons)) / 2
|
|
688
|
+
center_lat = (min(lats) + max(lats)) / 2
|
|
689
|
+
target_point = (center_lon, center_lat)
|
|
690
|
+
|
|
691
|
+
# Optional: specify maximum distance in kilometers
|
|
692
|
+
max_distance = 100 # None for no limit
|
|
693
|
+
|
|
694
|
+
output_dir = kwargs.get("output_dir", "output")
|
|
695
|
+
|
|
696
|
+
epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
|
|
697
|
+
longitude=center_lon,
|
|
698
|
+
latitude=center_lat,
|
|
699
|
+
output_dir=output_dir,
|
|
700
|
+
max_distance=max_distance,
|
|
701
|
+
extract_zip=True,
|
|
702
|
+
load_data=True
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
# Read EPW data
|
|
706
|
+
df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
|
|
707
|
+
if df.empty:
|
|
708
|
+
raise ValueError("No data in EPW file.")
|
|
709
|
+
|
|
710
|
+
if calc_type == 'instantaneous':
|
|
711
|
+
|
|
712
|
+
calc_time = kwargs.get("calc_time", "01-01 12:00:00")
|
|
713
|
+
|
|
714
|
+
# Parse start and end times without year
|
|
715
|
+
try:
|
|
716
|
+
calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
|
|
717
|
+
except ValueError as ve:
|
|
718
|
+
raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
|
|
719
|
+
|
|
720
|
+
df_period = df[
|
|
721
|
+
(df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
|
|
722
|
+
]
|
|
723
|
+
|
|
724
|
+
if df_period.empty:
|
|
725
|
+
raise ValueError("No EPW data at the specified time.")
|
|
726
|
+
|
|
727
|
+
# Prepare timezone conversion
|
|
728
|
+
offset_minutes = int(tz * 60)
|
|
729
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
730
|
+
df_period_local = df_period.copy()
|
|
731
|
+
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
732
|
+
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
733
|
+
|
|
734
|
+
# Compute solar positions
|
|
735
|
+
solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
|
|
736
|
+
direct_normal_irradiance = df_period_utc.iloc[0]['DNI']
|
|
737
|
+
diffuse_irradiance = df_period_utc.iloc[0]['DHI']
|
|
738
|
+
azimuth_degrees = solar_positions.iloc[0]['azimuth']
|
|
739
|
+
elevation_degrees = solar_positions.iloc[0]['elevation']
|
|
740
|
+
solar_map = get_global_solar_irradiance_map(
|
|
741
|
+
voxel_data, # 3D voxel grid representing the urban environment
|
|
742
|
+
meshsize, # Size of each grid cell in meters
|
|
743
|
+
azimuth_degrees, # Sun's azimuth angle
|
|
744
|
+
elevation_degrees, # Sun's elevation angle
|
|
745
|
+
direct_normal_irradiance, # Direct Normal Irradiance value
|
|
746
|
+
diffuse_irradiance, # Diffuse irradiance value
|
|
747
|
+
show_plot=True, # Display visualization of results
|
|
748
|
+
**kwargs
|
|
749
|
+
)
|
|
750
|
+
if calc_type == 'cumulative':
|
|
751
|
+
# Get time window parameters
|
|
752
|
+
start_hour = kwargs.get("start_hour", 0) # Default to midnight
|
|
753
|
+
end_hour = kwargs.get("end_hour", 23) # Default to 11 PM
|
|
754
|
+
|
|
755
|
+
# Filter dataframe for specified hours
|
|
756
|
+
df_filtered = df[(df.index.hour >= start_hour) & (df.index.hour <= end_hour)]
|
|
757
|
+
|
|
758
|
+
solar_map = get_cumulative_global_solar_irradiance(
|
|
759
|
+
voxel_data,
|
|
760
|
+
meshsize,
|
|
761
|
+
df_filtered, lon, lat, tz,
|
|
762
|
+
**kwargs
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
return solar_map
|
|
766
|
+
|
|
767
|
+
import numpy as np
|
|
768
|
+
import trimesh
|
|
769
|
+
import time
|
|
770
|
+
from numba import njit
|
|
771
|
+
|
|
772
|
+
##############################################################################
|
|
773
|
+
# 1) New Numba helper: per-face solar irradiance computation
|
|
774
|
+
##############################################################################
|
|
775
|
+
@njit
|
|
776
|
+
def compute_solar_irradiance_for_all_faces(
|
|
777
|
+
face_centers,
|
|
778
|
+
face_normals,
|
|
779
|
+
face_svf,
|
|
780
|
+
sun_direction,
|
|
781
|
+
direct_normal_irradiance,
|
|
782
|
+
diffuse_irradiance,
|
|
783
|
+
voxel_data,
|
|
784
|
+
meshsize,
|
|
785
|
+
tree_k,
|
|
786
|
+
tree_lad,
|
|
787
|
+
hit_values,
|
|
788
|
+
inclusion_mode,
|
|
789
|
+
grid_bounds_real,
|
|
790
|
+
boundary_epsilon
|
|
791
|
+
):
|
|
792
|
+
"""
|
|
793
|
+
Numba-compiled function to compute direct, diffuse, and global solar irradiance
|
|
794
|
+
for each face in the mesh.
|
|
795
|
+
|
|
796
|
+
Args:
|
|
797
|
+
face_centers (float64[:, :]): (N x 3) array of face center points
|
|
798
|
+
face_normals (float64[:, :]): (N x 3) array of face normals
|
|
799
|
+
face_svf (float64[:]): (N) array of SVF values for each face
|
|
800
|
+
sun_direction (float64[:]): (3) array for sun direction (dx, dy, dz)
|
|
801
|
+
direct_normal_irradiance (float): Direct normal irradiance (DNI) in W/m²
|
|
802
|
+
diffuse_irradiance (float): Diffuse horizontal irradiance (DHI) in W/m²
|
|
803
|
+
voxel_data (ndarray): 3D array of voxel values
|
|
804
|
+
meshsize (float): Size of each voxel in meters
|
|
805
|
+
tree_k (float): Tree extinction coefficient
|
|
806
|
+
tree_lad (float): Leaf area density
|
|
807
|
+
hit_values (tuple): Values considered 'sky' (e.g. (0,))
|
|
808
|
+
inclusion_mode (bool): Whether we want to "include" or "exclude" these hit_values
|
|
809
|
+
grid_bounds_real (float64[2,3]): [[x_min, y_min, z_min],[x_max, y_max, z_max]]
|
|
810
|
+
boundary_epsilon (float): Distance threshold for bounding-box check
|
|
811
|
+
|
|
812
|
+
Returns:
|
|
813
|
+
(direct_irr, diffuse_irr, global_irr) as three float64[N] arrays
|
|
814
|
+
"""
|
|
815
|
+
n_faces = face_centers.shape[0]
|
|
816
|
+
|
|
817
|
+
face_direct = np.zeros(n_faces, dtype=np.float64)
|
|
818
|
+
face_diffuse = np.zeros(n_faces, dtype=np.float64)
|
|
819
|
+
face_global = np.zeros(n_faces, dtype=np.float64)
|
|
820
|
+
|
|
821
|
+
x_min, y_min, z_min = grid_bounds_real[0, 0], grid_bounds_real[0, 1], grid_bounds_real[0, 2]
|
|
822
|
+
x_max, y_max, z_max = grid_bounds_real[1, 0], grid_bounds_real[1, 1], grid_bounds_real[1, 2]
|
|
823
|
+
|
|
824
|
+
for fidx in range(n_faces):
|
|
825
|
+
center = face_centers[fidx]
|
|
826
|
+
normal = face_normals[fidx]
|
|
827
|
+
svf = face_svf[fidx]
|
|
828
|
+
|
|
829
|
+
# -- 1) Check for vertical boundary face
|
|
830
|
+
is_vertical = (abs(normal[2]) < 0.01)
|
|
831
|
+
|
|
832
|
+
on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
|
|
833
|
+
on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
|
|
834
|
+
on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
|
|
835
|
+
on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
|
|
836
|
+
|
|
837
|
+
is_boundary_vertical = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
|
|
838
|
+
|
|
839
|
+
if is_boundary_vertical:
|
|
840
|
+
face_direct[fidx] = np.nan
|
|
841
|
+
face_diffuse[fidx] = np.nan
|
|
842
|
+
face_global[fidx] = np.nan
|
|
843
|
+
continue
|
|
844
|
+
|
|
845
|
+
# If SVF is NaN, skip (means it was set to boundary or invalid earlier)
|
|
846
|
+
if svf != svf: # NaN check in Numba
|
|
847
|
+
face_direct[fidx] = np.nan
|
|
848
|
+
face_diffuse[fidx] = np.nan
|
|
849
|
+
face_global[fidx] = np.nan
|
|
850
|
+
continue
|
|
851
|
+
|
|
852
|
+
# -- 2) Direct irradiance (if face is oriented towards sun)
|
|
853
|
+
cos_incidence = normal[0]*sun_direction[0] + \
|
|
854
|
+
normal[1]*sun_direction[1] + \
|
|
855
|
+
normal[2]*sun_direction[2]
|
|
856
|
+
|
|
857
|
+
direct_val = 0.0
|
|
858
|
+
if cos_incidence > 0.0:
|
|
859
|
+
# Offset ray origin slightly to avoid self-intersection
|
|
860
|
+
offset_vox = 0.1
|
|
861
|
+
ray_origin_x = center[0]/meshsize + normal[0]*offset_vox
|
|
862
|
+
ray_origin_y = center[1]/meshsize + normal[1]*offset_vox
|
|
863
|
+
ray_origin_z = center[2]/meshsize + normal[2]*offset_vox
|
|
864
|
+
|
|
865
|
+
# Single ray toward the sun
|
|
866
|
+
hit_detected, transmittance = trace_ray_generic(
|
|
867
|
+
voxel_data,
|
|
868
|
+
np.array([ray_origin_x, ray_origin_y, ray_origin_z], dtype=np.float64),
|
|
869
|
+
sun_direction,
|
|
870
|
+
hit_values,
|
|
871
|
+
meshsize,
|
|
872
|
+
tree_k,
|
|
873
|
+
tree_lad,
|
|
874
|
+
inclusion_mode
|
|
875
|
+
)
|
|
876
|
+
if not hit_detected:
|
|
877
|
+
direct_val = direct_normal_irradiance * cos_incidence * transmittance
|
|
878
|
+
|
|
879
|
+
# -- 3) Diffuse irradiance from sky: use SVF * DHI
|
|
880
|
+
diffuse_val = svf * diffuse_irradiance
|
|
881
|
+
if diffuse_val > diffuse_irradiance:
|
|
882
|
+
diffuse_val = diffuse_irradiance
|
|
883
|
+
|
|
884
|
+
# -- 4) Sum up
|
|
885
|
+
face_direct[fidx] = direct_val
|
|
886
|
+
face_diffuse[fidx] = diffuse_val
|
|
887
|
+
face_global[fidx] = direct_val + diffuse_val
|
|
888
|
+
|
|
889
|
+
return face_direct, face_diffuse, face_global
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
##############################################################################
|
|
893
|
+
# 2) Modified get_building_solar_irradiance: main Python wrapper
|
|
894
|
+
##############################################################################
|
|
895
|
+
def get_building_solar_irradiance(
|
|
896
|
+
voxel_data,
|
|
897
|
+
meshsize,
|
|
898
|
+
building_svf_mesh,
|
|
899
|
+
azimuth_degrees,
|
|
900
|
+
elevation_degrees,
|
|
901
|
+
direct_normal_irradiance,
|
|
902
|
+
diffuse_irradiance,
|
|
903
|
+
**kwargs
|
|
904
|
+
):
|
|
905
|
+
"""
|
|
906
|
+
Calculate solar irradiance on building surfaces using SVF,
|
|
907
|
+
with the numeric per-face loop accelerated by Numba.
|
|
908
|
+
|
|
909
|
+
Args:
|
|
910
|
+
voxel_data (ndarray): 3D array of voxel values.
|
|
911
|
+
meshsize (float): Size of each voxel in meters.
|
|
912
|
+
building_svf_mesh (trimesh.Trimesh): Building mesh with SVF values in metadata.
|
|
913
|
+
azimuth_degrees (float): Sun azimuth angle in degrees (0=North, 90=East).
|
|
914
|
+
elevation_degrees (float): Sun elevation angle in degrees above horizon.
|
|
915
|
+
direct_normal_irradiance (float): DNI in W/m².
|
|
916
|
+
diffuse_irradiance (float): DHI in W/m².
|
|
917
|
+
**kwargs: Additional parameters, e.g. tree_k, tree_lad, progress_report, obj_export, etc.
|
|
918
|
+
|
|
919
|
+
Returns:
|
|
920
|
+
trimesh.Trimesh: A copy of the input mesh with direct/diffuse/global irradiance stored in metadata.
|
|
921
|
+
"""
|
|
922
|
+
import time
|
|
923
|
+
|
|
924
|
+
tree_k = kwargs.get("tree_k", 0.6)
|
|
925
|
+
tree_lad = kwargs.get("tree_lad", 1.0)
|
|
926
|
+
progress_report = kwargs.get("progress_report", False)
|
|
927
|
+
|
|
928
|
+
# Sky detection
|
|
929
|
+
hit_values = (0,) # '0' = sky
|
|
930
|
+
inclusion_mode = False
|
|
931
|
+
|
|
932
|
+
# Convert angles -> direction
|
|
933
|
+
az_rad = np.deg2rad(180 - azimuth_degrees)
|
|
934
|
+
el_rad = np.deg2rad(elevation_degrees)
|
|
935
|
+
sun_dx = np.cos(el_rad) * np.cos(az_rad)
|
|
936
|
+
sun_dy = np.cos(el_rad) * np.sin(az_rad)
|
|
937
|
+
sun_dz = np.sin(el_rad)
|
|
938
|
+
sun_direction = np.array([sun_dx, sun_dy, sun_dz], dtype=np.float64)
|
|
939
|
+
|
|
940
|
+
# Extract mesh data
|
|
941
|
+
face_centers = building_svf_mesh.triangles_center
|
|
942
|
+
face_normals = building_svf_mesh.face_normals
|
|
943
|
+
|
|
944
|
+
# Get SVF from metadata
|
|
945
|
+
if hasattr(building_svf_mesh, 'metadata') and ('svf' in building_svf_mesh.metadata):
|
|
946
|
+
face_svf = building_svf_mesh.metadata['svf']
|
|
947
|
+
else:
|
|
948
|
+
face_svf = np.zeros(len(building_svf_mesh.faces), dtype=np.float64)
|
|
949
|
+
|
|
950
|
+
# Prepare boundary checks
|
|
951
|
+
grid_shape = voxel_data.shape
|
|
952
|
+
grid_bounds_voxel = np.array([[0,0,0],[grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=np.float64)
|
|
953
|
+
grid_bounds_real = grid_bounds_voxel * meshsize
|
|
954
|
+
boundary_epsilon = meshsize * 0.05
|
|
955
|
+
|
|
956
|
+
# Call Numba-compiled function
|
|
957
|
+
t0 = time.time()
|
|
958
|
+
face_direct, face_diffuse, face_global = compute_solar_irradiance_for_all_faces(
|
|
959
|
+
face_centers,
|
|
960
|
+
face_normals,
|
|
961
|
+
face_svf,
|
|
962
|
+
sun_direction,
|
|
963
|
+
direct_normal_irradiance,
|
|
964
|
+
diffuse_irradiance,
|
|
965
|
+
voxel_data,
|
|
966
|
+
meshsize,
|
|
967
|
+
tree_k,
|
|
968
|
+
tree_lad,
|
|
969
|
+
hit_values,
|
|
970
|
+
inclusion_mode,
|
|
971
|
+
grid_bounds_real,
|
|
972
|
+
boundary_epsilon
|
|
973
|
+
)
|
|
974
|
+
if progress_report:
|
|
975
|
+
elapsed = time.time() - t0
|
|
976
|
+
print(f"Numba-based solar irradiance calculation took {elapsed:.2f} seconds")
|
|
977
|
+
|
|
978
|
+
# Create a copy of the mesh
|
|
979
|
+
irradiance_mesh = building_svf_mesh.copy()
|
|
980
|
+
if not hasattr(irradiance_mesh, 'metadata'):
|
|
981
|
+
irradiance_mesh.metadata = {}
|
|
982
|
+
|
|
983
|
+
# Store results
|
|
984
|
+
irradiance_mesh.metadata['svf'] = face_svf
|
|
985
|
+
irradiance_mesh.metadata['direct'] = face_direct
|
|
986
|
+
irradiance_mesh.metadata['diffuse'] = face_diffuse
|
|
987
|
+
irradiance_mesh.metadata['global'] = face_global
|
|
988
|
+
|
|
989
|
+
irradiance_mesh.name = "Solar Irradiance (W/m²)"
|
|
990
|
+
|
|
991
|
+
# # Optional OBJ export
|
|
992
|
+
# obj_export = kwargs.get("obj_export", False)
|
|
993
|
+
# if obj_export:
|
|
994
|
+
# _export_solar_irradiance_mesh(
|
|
995
|
+
# irradiance_mesh,
|
|
996
|
+
# face_global,
|
|
997
|
+
# **kwargs
|
|
998
|
+
# )
|
|
999
|
+
|
|
1000
|
+
return irradiance_mesh
|
|
1001
|
+
|
|
1002
|
+
##############################################################################
|
|
1003
|
+
# 4) Modified get_cumulative_building_solar_irradiance
|
|
1004
|
+
##############################################################################
|
|
1005
|
+
def get_cumulative_building_solar_irradiance(
|
|
1006
|
+
voxel_data,
|
|
1007
|
+
meshsize,
|
|
1008
|
+
building_svf_mesh,
|
|
1009
|
+
weather_df,
|
|
1010
|
+
lon, lat, tz,
|
|
1011
|
+
**kwargs
|
|
1012
|
+
):
|
|
1013
|
+
"""
|
|
1014
|
+
Calculate cumulative solar irradiance on building surfaces over a time period.
|
|
1015
|
+
Uses the Numba-accelerated get_building_solar_irradiance for each time step.
|
|
1016
|
+
|
|
1017
|
+
Args:
|
|
1018
|
+
voxel_data (ndarray): 3D array of voxel values.
|
|
1019
|
+
meshsize (float): Size of each voxel in meters.
|
|
1020
|
+
building_svf_mesh (trimesh.Trimesh): Mesh with pre-calculated SVF in metadata.
|
|
1021
|
+
weather_df (DataFrame): Weather data with DNI (W/m²) and DHI (W/m²).
|
|
1022
|
+
lon (float): Longitude in degrees.
|
|
1023
|
+
lat (float): Latitude in degrees.
|
|
1024
|
+
tz (float): Timezone offset in hours.
|
|
1025
|
+
**kwargs: Additional parameters for time range, scaling, OBJ export, etc.
|
|
1026
|
+
|
|
1027
|
+
Returns:
|
|
1028
|
+
trimesh.Trimesh: A mesh with cumulative (Wh/m²) irradiance in metadata.
|
|
1029
|
+
"""
|
|
1030
|
+
import pytz
|
|
1031
|
+
from datetime import datetime
|
|
1032
|
+
|
|
1033
|
+
period_start = kwargs.get("period_start", "01-01 00:00:00")
|
|
1034
|
+
period_end = kwargs.get("period_end", "12-31 23:59:59")
|
|
1035
|
+
time_step_hours = kwargs.get("time_step_hours", 1.0)
|
|
1036
|
+
direct_normal_irradiance_scaling = kwargs.get("direct_normal_irradiance_scaling", 1.0)
|
|
1037
|
+
diffuse_irradiance_scaling = kwargs.get("diffuse_irradiance_scaling", 1.0)
|
|
1038
|
+
|
|
1039
|
+
# Parse times, create local tz
|
|
1040
|
+
try:
|
|
1041
|
+
start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
|
|
1042
|
+
end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
|
|
1043
|
+
except ValueError as ve:
|
|
1044
|
+
raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
|
|
1045
|
+
|
|
1046
|
+
offset_minutes = int(tz * 60)
|
|
1047
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
1048
|
+
|
|
1049
|
+
# Filter weather_df
|
|
1050
|
+
df_period = weather_df[
|
|
1051
|
+
((weather_df.index.month > start_dt.month) |
|
|
1052
|
+
((weather_df.index.month == start_dt.month) &
|
|
1053
|
+
(weather_df.index.day >= start_dt.day) &
|
|
1054
|
+
(weather_df.index.hour >= start_dt.hour))) &
|
|
1055
|
+
((weather_df.index.month < end_dt.month) |
|
|
1056
|
+
((weather_df.index.month == end_dt.month) &
|
|
1057
|
+
(weather_df.index.day <= end_dt.day) &
|
|
1058
|
+
(weather_df.index.hour <= end_dt.hour)))
|
|
1059
|
+
]
|
|
1060
|
+
if df_period.empty:
|
|
1061
|
+
raise ValueError("No weather data in specified period.")
|
|
1062
|
+
|
|
1063
|
+
# Convert to local time, then to UTC
|
|
1064
|
+
df_period_local = df_period.copy()
|
|
1065
|
+
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
1066
|
+
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
1067
|
+
|
|
1068
|
+
# Get solar positions
|
|
1069
|
+
# You presumably have a get_solar_positions_astral(...) that returns az/elev
|
|
1070
|
+
solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
|
|
1071
|
+
|
|
1072
|
+
# Prepare arrays for accumulation
|
|
1073
|
+
n_faces = len(building_svf_mesh.faces)
|
|
1074
|
+
face_cum_direct = np.zeros(n_faces, dtype=np.float64)
|
|
1075
|
+
face_cum_diffuse = np.zeros(n_faces, dtype=np.float64)
|
|
1076
|
+
face_cum_global = np.zeros(n_faces, dtype=np.float64)
|
|
1077
|
+
|
|
1078
|
+
boundary_mask = None
|
|
1079
|
+
|
|
1080
|
+
# Iterate over each timestep
|
|
1081
|
+
for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
|
|
1082
|
+
DNI = row['DNI'] * direct_normal_irradiance_scaling
|
|
1083
|
+
DHI = row['DHI'] * diffuse_irradiance_scaling
|
|
1084
|
+
|
|
1085
|
+
# Sun angles
|
|
1086
|
+
az_deg = solar_positions.loc[time_utc, 'azimuth']
|
|
1087
|
+
el_deg = solar_positions.loc[time_utc, 'elevation']
|
|
1088
|
+
|
|
1089
|
+
# Skip if sun below horizon
|
|
1090
|
+
if el_deg <= 0:
|
|
1091
|
+
continue
|
|
1092
|
+
|
|
1093
|
+
# Call instantaneous function (Numba-accelerated inside)
|
|
1094
|
+
irr_mesh = get_building_solar_irradiance(
|
|
1095
|
+
voxel_data,
|
|
1096
|
+
meshsize,
|
|
1097
|
+
building_svf_mesh,
|
|
1098
|
+
az_deg,
|
|
1099
|
+
el_deg,
|
|
1100
|
+
DNI,
|
|
1101
|
+
DHI,
|
|
1102
|
+
show_plot=False, # or any other flags
|
|
1103
|
+
**kwargs
|
|
1104
|
+
)
|
|
1105
|
+
|
|
1106
|
+
# Extract arrays
|
|
1107
|
+
face_dir = irr_mesh.metadata['direct']
|
|
1108
|
+
face_diff = irr_mesh.metadata['diffuse']
|
|
1109
|
+
face_glob = irr_mesh.metadata['global']
|
|
1110
|
+
|
|
1111
|
+
# If first time, note boundary mask from NaNs
|
|
1112
|
+
if boundary_mask is None:
|
|
1113
|
+
boundary_mask = np.isnan(face_glob)
|
|
1114
|
+
|
|
1115
|
+
# Convert from W/m² to Wh/m² by multiplying time_step_hours
|
|
1116
|
+
face_cum_direct += np.nan_to_num(face_dir) * time_step_hours
|
|
1117
|
+
face_cum_diffuse += np.nan_to_num(face_diff) * time_step_hours
|
|
1118
|
+
face_cum_global += np.nan_to_num(face_glob) * time_step_hours
|
|
1119
|
+
|
|
1120
|
+
# Reapply NaN for boundary
|
|
1121
|
+
if boundary_mask is not None:
|
|
1122
|
+
face_cum_direct[boundary_mask] = np.nan
|
|
1123
|
+
face_cum_diffuse[boundary_mask] = np.nan
|
|
1124
|
+
face_cum_global[boundary_mask] = np.nan
|
|
1125
|
+
|
|
1126
|
+
# Create a new mesh with cumulative results
|
|
1127
|
+
cumulative_mesh = building_svf_mesh.copy()
|
|
1128
|
+
if not hasattr(cumulative_mesh, 'metadata'):
|
|
1129
|
+
cumulative_mesh.metadata = {}
|
|
1130
|
+
|
|
1131
|
+
# If original mesh had SVF
|
|
1132
|
+
if 'svf' in building_svf_mesh.metadata:
|
|
1133
|
+
cumulative_mesh.metadata['svf'] = building_svf_mesh.metadata['svf']
|
|
1134
|
+
|
|
1135
|
+
cumulative_mesh.metadata['direct'] = face_cum_direct
|
|
1136
|
+
cumulative_mesh.metadata['diffuse'] = face_cum_diffuse
|
|
1137
|
+
cumulative_mesh.metadata['global'] = face_cum_global
|
|
1138
|
+
|
|
1139
|
+
cumulative_mesh.name = "Cumulative Solar Irradiance (Wh/m²)"
|
|
1140
|
+
|
|
1141
|
+
# Optional export
|
|
1142
|
+
# obj_export = kwargs.get("obj_export", False)
|
|
1143
|
+
# if obj_export:
|
|
1144
|
+
# _export_solar_irradiance_mesh(
|
|
1145
|
+
# cumulative_mesh,
|
|
1146
|
+
# face_cum_global,
|
|
1147
|
+
# **kwargs
|
|
1148
|
+
# )
|
|
1149
|
+
|
|
1150
|
+
return cumulative_mesh
|
|
1151
|
+
|
|
1152
|
+
def get_building_global_solar_irradiance_using_epw(
|
|
1153
|
+
voxel_data,
|
|
1154
|
+
meshsize,
|
|
1155
|
+
calc_type='instantaneous',
|
|
1156
|
+
direct_normal_irradiance_scaling=1.0,
|
|
1157
|
+
diffuse_irradiance_scaling=1.0,
|
|
1158
|
+
**kwargs
|
|
1159
|
+
):
|
|
1160
|
+
"""
|
|
1161
|
+
Compute global solar irradiance on building surfaces using EPW weather data, either for a single time or cumulatively.
|
|
1162
|
+
|
|
1163
|
+
The function:
|
|
1164
|
+
1. Optionally downloads and reads EPW weather data
|
|
1165
|
+
2. Handles timezone conversions and solar position calculations
|
|
1166
|
+
3. Computes either instantaneous or cumulative irradiance on building surfaces
|
|
1167
|
+
4. Supports visualization and export options
|
|
1168
|
+
|
|
1169
|
+
Args:
|
|
1170
|
+
voxel_data (ndarray): 3D array of voxel values.
|
|
1171
|
+
meshsize (float): Size of each voxel in meters.
|
|
1172
|
+
building_svf_mesh (trimesh.Trimesh): Building mesh with pre-calculated SVF values in metadata.
|
|
1173
|
+
calc_type (str): 'instantaneous' or 'cumulative'.
|
|
1174
|
+
direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
|
|
1175
|
+
diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
|
|
1176
|
+
**kwargs: Additional arguments including:
|
|
1177
|
+
- download_nearest_epw (bool): Whether to download nearest EPW file
|
|
1178
|
+
- epw_file_path (str): Path to EPW file
|
|
1179
|
+
- rectangle_vertices (list): List of (lon,lat) coordinates for EPW download
|
|
1180
|
+
- output_dir (str): Directory for EPW download
|
|
1181
|
+
- calc_time (str): Time for instantaneous calculation ('MM-DD HH:MM:SS')
|
|
1182
|
+
- period_start (str): Start time for cumulative calculation ('MM-DD HH:MM:SS')
|
|
1183
|
+
- period_end (str): End time for cumulative calculation ('MM-DD HH:MM:SS')
|
|
1184
|
+
- time_step_hours (float): Time step for cumulative calculation
|
|
1185
|
+
- tree_k (float): Tree extinction coefficient
|
|
1186
|
+
- tree_lad (float): Leaf area density in m^-1
|
|
1187
|
+
- show_each_timestep (bool): Whether to show plots for each timestep
|
|
1188
|
+
- nan_color (str): Color for NaN values in visualization
|
|
1189
|
+
- colormap (str): Matplotlib colormap name
|
|
1190
|
+
- vmin (float): Minimum value for colormap
|
|
1191
|
+
- vmax (float): Maximum value for colormap
|
|
1192
|
+
- obj_export (bool): Whether to export as OBJ file
|
|
1193
|
+
- output_directory (str): Directory for OBJ export
|
|
1194
|
+
- output_file_name (str): Filename for OBJ export
|
|
1195
|
+
- save_mesh (bool): Whether to save the mesh data using pickle
|
|
1196
|
+
- mesh_output_path (str): Path to save the mesh data (if save_mesh is True)
|
|
1197
|
+
|
|
1198
|
+
Returns:
|
|
1199
|
+
trimesh.Trimesh: Building mesh with irradiance values stored in metadata.
|
|
1200
|
+
"""
|
|
1201
|
+
import numpy as np
|
|
1202
|
+
import pytz
|
|
1203
|
+
from datetime import datetime
|
|
1204
|
+
|
|
1205
|
+
# Get EPW file
|
|
1206
|
+
download_nearest_epw = kwargs.get("download_nearest_epw", False)
|
|
1207
|
+
rectangle_vertices = kwargs.get("rectangle_vertices", None)
|
|
1208
|
+
epw_file_path = kwargs.get("epw_file_path", None)
|
|
1209
|
+
building_id_grid = kwargs.get("building_id_grid", None)
|
|
1210
|
+
|
|
1211
|
+
if download_nearest_epw:
|
|
1212
|
+
if rectangle_vertices is None:
|
|
1213
|
+
print("rectangle_vertices is required to download nearest EPW file")
|
|
1214
|
+
return None
|
|
1215
|
+
else:
|
|
1216
|
+
# Calculate center point of rectangle
|
|
1217
|
+
lons = [coord[0] for coord in rectangle_vertices]
|
|
1218
|
+
lats = [coord[1] for coord in rectangle_vertices]
|
|
1219
|
+
center_lon = (min(lons) + max(lons)) / 2
|
|
1220
|
+
center_lat = (min(lats) + max(lats)) / 2
|
|
1221
|
+
|
|
1222
|
+
# Optional: specify maximum distance in kilometers
|
|
1223
|
+
max_distance = kwargs.get("max_distance", 100) # None for no limit
|
|
1224
|
+
output_dir = kwargs.get("output_dir", "output")
|
|
1225
|
+
|
|
1226
|
+
epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
|
|
1227
|
+
longitude=center_lon,
|
|
1228
|
+
latitude=center_lat,
|
|
1229
|
+
output_dir=output_dir,
|
|
1230
|
+
max_distance=max_distance,
|
|
1231
|
+
extract_zip=True,
|
|
1232
|
+
load_data=True
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
# Read EPW data
|
|
1236
|
+
df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
|
|
1237
|
+
if df.empty:
|
|
1238
|
+
raise ValueError("No data in EPW file.")
|
|
1239
|
+
|
|
1240
|
+
# Step 1: Calculate Sky View Factor for building surfaces
|
|
1241
|
+
print(f"Processing Sky View Factor for building surfaces...")
|
|
1242
|
+
building_svf_mesh = get_surface_view_factor(
|
|
1243
|
+
voxel_data, # Your 3D voxel grid
|
|
1244
|
+
meshsize, # Size of each voxel in meters
|
|
1245
|
+
value_name = 'svf',
|
|
1246
|
+
target_values = (0,),
|
|
1247
|
+
inclusion_mode = False,
|
|
1248
|
+
building_id_grid=building_id_grid,
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
print(f"Processing Solar Irradiance for building surfaces...")
|
|
1252
|
+
result_mesh = None
|
|
1253
|
+
|
|
1254
|
+
if calc_type == 'instantaneous':
|
|
1255
|
+
calc_time = kwargs.get("calc_time", "01-01 12:00:00")
|
|
1256
|
+
|
|
1257
|
+
# Parse calculation time without year
|
|
1258
|
+
try:
|
|
1259
|
+
calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
|
|
1260
|
+
except ValueError as ve:
|
|
1261
|
+
raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
|
|
1262
|
+
|
|
1263
|
+
df_period = df[
|
|
1264
|
+
(df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
|
|
1265
|
+
]
|
|
1266
|
+
|
|
1267
|
+
if df_period.empty:
|
|
1268
|
+
raise ValueError("No EPW data at the specified time.")
|
|
1269
|
+
|
|
1270
|
+
# Prepare timezone conversion
|
|
1271
|
+
offset_minutes = int(tz * 60)
|
|
1272
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
1273
|
+
df_period_local = df_period.copy()
|
|
1274
|
+
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
1275
|
+
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
1276
|
+
|
|
1277
|
+
# Compute solar positions
|
|
1278
|
+
solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
|
|
1279
|
+
|
|
1280
|
+
# Scale irradiance values
|
|
1281
|
+
direct_normal_irradiance = df_period_utc.iloc[0]['DNI'] * direct_normal_irradiance_scaling
|
|
1282
|
+
diffuse_irradiance = df_period_utc.iloc[0]['DHI'] * diffuse_irradiance_scaling
|
|
1283
|
+
|
|
1284
|
+
# Get solar position
|
|
1285
|
+
azimuth_degrees = solar_positions.iloc[0]['azimuth']
|
|
1286
|
+
elevation_degrees = solar_positions.iloc[0]['elevation']
|
|
1287
|
+
|
|
1288
|
+
print(f"Time: {df_period_local.index[0].strftime('%Y-%m-%d %H:%M:%S')}")
|
|
1289
|
+
print(f"Sun position: Azimuth {azimuth_degrees:.1f}°, Elevation {elevation_degrees:.1f}°")
|
|
1290
|
+
print(f"DNI: {direct_normal_irradiance:.1f} W/m², DHI: {diffuse_irradiance:.1f} W/m²")
|
|
1291
|
+
|
|
1292
|
+
# Skip if sun is below horizon
|
|
1293
|
+
if elevation_degrees <= 0:
|
|
1294
|
+
print("Sun is below horizon, skipping calculation.")
|
|
1295
|
+
result_mesh = building_svf_mesh.copy()
|
|
1296
|
+
else:
|
|
1297
|
+
# Compute irradiance
|
|
1298
|
+
result_mesh = get_building_solar_irradiance(
|
|
1299
|
+
voxel_data,
|
|
1300
|
+
meshsize,
|
|
1301
|
+
building_svf_mesh,
|
|
1302
|
+
azimuth_degrees,
|
|
1303
|
+
elevation_degrees,
|
|
1304
|
+
direct_normal_irradiance,
|
|
1305
|
+
diffuse_irradiance,
|
|
1306
|
+
**kwargs
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
elif calc_type == 'cumulative':
|
|
1310
|
+
# Set default parameters
|
|
1311
|
+
period_start = kwargs.get("period_start", "01-01 00:00:00")
|
|
1312
|
+
period_end = kwargs.get("period_end", "12-31 23:59:59")
|
|
1313
|
+
time_step_hours = kwargs.get("time_step_hours", 1.0)
|
|
1314
|
+
|
|
1315
|
+
# Parse start and end times without year
|
|
1316
|
+
try:
|
|
1317
|
+
start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
|
|
1318
|
+
end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
|
|
1319
|
+
except ValueError as ve:
|
|
1320
|
+
raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
|
|
1321
|
+
|
|
1322
|
+
# Create local timezone
|
|
1323
|
+
offset_minutes = int(tz * 60)
|
|
1324
|
+
local_tz = pytz.FixedOffset(offset_minutes)
|
|
1325
|
+
|
|
1326
|
+
# Filter weather data by month, day, hour
|
|
1327
|
+
df_period = df[
|
|
1328
|
+
((df.index.month > start_dt.month) |
|
|
1329
|
+
((df.index.month == start_dt.month) & (df.index.day >= start_dt.day) &
|
|
1330
|
+
(df.index.hour >= start_dt.hour))) &
|
|
1331
|
+
((df.index.month < end_dt.month) |
|
|
1332
|
+
((df.index.month == end_dt.month) & (df.index.day <= end_dt.day) &
|
|
1333
|
+
(df.index.hour <= end_dt.hour)))
|
|
1334
|
+
]
|
|
1335
|
+
|
|
1336
|
+
if df_period.empty:
|
|
1337
|
+
raise ValueError("No weather data available for the specified period.")
|
|
1338
|
+
|
|
1339
|
+
# Convert to local timezone and then to UTC for solar position calculation
|
|
1340
|
+
df_period_local = df_period.copy()
|
|
1341
|
+
df_period_local.index = df_period_local.index.tz_localize(local_tz)
|
|
1342
|
+
df_period_utc = df_period_local.tz_convert(pytz.UTC)
|
|
1343
|
+
|
|
1344
|
+
# Get solar positions for all times
|
|
1345
|
+
solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
|
|
1346
|
+
|
|
1347
|
+
# Create a copy of kwargs without time_step_hours to avoid duplicate argument
|
|
1348
|
+
kwargs_copy = kwargs.copy()
|
|
1349
|
+
if 'time_step_hours' in kwargs_copy:
|
|
1350
|
+
del kwargs_copy['time_step_hours']
|
|
1351
|
+
|
|
1352
|
+
# Get cumulative irradiance - adapt to match expected function signature
|
|
1353
|
+
result_mesh = get_cumulative_building_solar_irradiance(
|
|
1354
|
+
voxel_data,
|
|
1355
|
+
meshsize,
|
|
1356
|
+
building_svf_mesh,
|
|
1357
|
+
df, lon, lat, tz, # Pass only the required 7 positional arguments
|
|
1358
|
+
period_start=period_start,
|
|
1359
|
+
period_end=period_end,
|
|
1360
|
+
time_step_hours=time_step_hours,
|
|
1361
|
+
direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
|
|
1362
|
+
diffuse_irradiance_scaling=diffuse_irradiance_scaling,
|
|
1363
|
+
colormap=kwargs.get('colormap', 'jet'),
|
|
1364
|
+
show_each_timestep=kwargs.get('show_each_timestep', False),
|
|
1365
|
+
obj_export=kwargs.get('obj_export', False),
|
|
1366
|
+
output_directory=kwargs.get('output_directory', 'output'),
|
|
1367
|
+
output_file_name=kwargs.get('output_file_name', 'cumulative_solar')
|
|
1368
|
+
)
|
|
1369
|
+
|
|
1370
|
+
else:
|
|
1371
|
+
raise ValueError("calc_type must be either 'instantaneous' or 'cumulative'")
|
|
1372
|
+
|
|
1373
|
+
# Save mesh data if requested
|
|
1374
|
+
save_mesh = kwargs.get("save_mesh", False)
|
|
1375
|
+
if save_mesh:
|
|
1376
|
+
mesh_output_path = kwargs.get("mesh_output_path", None)
|
|
1377
|
+
if mesh_output_path is None:
|
|
1378
|
+
# Generate default path if none provided
|
|
1379
|
+
output_directory = kwargs.get("output_directory", "output")
|
|
1380
|
+
output_file_name = kwargs.get("output_file_name", f"{calc_type}_solar_irradiance")
|
|
1381
|
+
mesh_output_path = f"{output_directory}/{output_file_name}.pkl"
|
|
1382
|
+
|
|
1383
|
+
save_irradiance_mesh(result_mesh, mesh_output_path)
|
|
1384
|
+
print(f"Saved irradiance mesh data to: {mesh_output_path}")
|
|
1385
|
+
|
|
1386
|
+
return result_mesh
|
|
1387
|
+
|
|
1388
|
+
def save_irradiance_mesh(irradiance_mesh, output_file_path):
|
|
1389
|
+
"""
|
|
1390
|
+
Save the irradiance mesh data to a file using pickle.
|
|
1391
|
+
|
|
1392
|
+
Args:
|
|
1393
|
+
irradiance_mesh (trimesh.Trimesh): Mesh with irradiance data in metadata.
|
|
1394
|
+
output_file_path (str): Path to save the mesh data (recommended extension: .pkl).
|
|
1395
|
+
"""
|
|
1396
|
+
import pickle
|
|
1397
|
+
import os
|
|
1398
|
+
|
|
1399
|
+
# Create output directory if it doesn't exist
|
|
1400
|
+
os.makedirs(os.path.dirname(output_file_path), exist_ok=True)
|
|
1401
|
+
|
|
1402
|
+
# Save mesh data using pickle
|
|
1403
|
+
with open(output_file_path, 'wb') as f:
|
|
1404
|
+
pickle.dump(irradiance_mesh, f)
|
|
1405
|
+
|
|
1406
|
+
def load_irradiance_mesh(input_file_path):
|
|
1407
|
+
"""
|
|
1408
|
+
Load the irradiance mesh data from a file.
|
|
1409
|
+
|
|
1410
|
+
Args:
|
|
1411
|
+
input_file_path (str): Path to the saved mesh data file.
|
|
1412
|
+
|
|
1413
|
+
Returns:
|
|
1414
|
+
trimesh.Trimesh: Mesh with irradiance data in metadata.
|
|
1415
|
+
"""
|
|
1416
|
+
import pickle
|
|
1417
|
+
|
|
1418
|
+
# Load mesh data using pickle
|
|
1419
|
+
with open(input_file_path, 'rb') as f:
|
|
1420
|
+
irradiance_mesh = pickle.load(f)
|
|
1421
|
+
|
|
1422
|
+
return irradiance_mesh
|