voxcity 0.5.12__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.

@@ -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
- # _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
-
1196
- Returns:
1197
- trimesh.Trimesh: Building mesh with irradiance values stored in metadata.
1198
- """
1199
- import numpy as np
1200
- import pytz
1201
- from datetime import datetime
1202
-
1203
- # Get EPW file
1204
- download_nearest_epw = kwargs.get("download_nearest_epw", False)
1205
- rectangle_vertices = kwargs.get("rectangle_vertices", None)
1206
- epw_file_path = kwargs.get("epw_file_path", None)
1207
- building_id_grid = kwargs.get("building_id_grid", None)
1208
-
1209
- if download_nearest_epw:
1210
- if rectangle_vertices is None:
1211
- print("rectangle_vertices is required to download nearest EPW file")
1212
- return None
1213
- else:
1214
- # Calculate center point of rectangle
1215
- lons = [coord[0] for coord in rectangle_vertices]
1216
- lats = [coord[1] for coord in rectangle_vertices]
1217
- center_lon = (min(lons) + max(lons)) / 2
1218
- center_lat = (min(lats) + max(lats)) / 2
1219
-
1220
- # Optional: specify maximum distance in kilometers
1221
- max_distance = kwargs.get("max_distance", 100) # None for no limit
1222
- output_dir = kwargs.get("output_dir", "output")
1223
-
1224
- epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
1225
- longitude=center_lon,
1226
- latitude=center_lat,
1227
- output_dir=output_dir,
1228
- max_distance=max_distance,
1229
- extract_zip=True,
1230
- load_data=True
1231
- )
1232
-
1233
- # Read EPW data
1234
- df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
1235
- if df.empty:
1236
- raise ValueError("No data in EPW file.")
1237
-
1238
- # Step 1: Calculate Sky View Factor for building surfaces
1239
- print(f"Processing Sky View Factor for building surfaces...")
1240
- building_svf_mesh = get_surface_view_factor(
1241
- voxel_data, # Your 3D voxel grid
1242
- meshsize, # Size of each voxel in meters
1243
- value_name = 'svf',
1244
- target_values = (0,),
1245
- inclusion_mode = False,
1246
- building_id_grid=building_id_grid,
1247
- )
1248
-
1249
- print(f"Processing Solar Irradiance for building surfaces...")
1250
- if calc_type == 'instantaneous':
1251
- calc_time = kwargs.get("calc_time", "01-01 12:00:00")
1252
-
1253
- # Parse calculation time without year
1254
- try:
1255
- calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
1256
- except ValueError as ve:
1257
- raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
1258
-
1259
- df_period = df[
1260
- (df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
1261
- ]
1262
-
1263
- if df_period.empty:
1264
- raise ValueError("No EPW data at the specified time.")
1265
-
1266
- # Prepare timezone conversion
1267
- offset_minutes = int(tz * 60)
1268
- local_tz = pytz.FixedOffset(offset_minutes)
1269
- df_period_local = df_period.copy()
1270
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
1271
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
1272
-
1273
- # Compute solar positions
1274
- solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1275
-
1276
- # Scale irradiance values
1277
- direct_normal_irradiance = df_period_utc.iloc[0]['DNI'] * direct_normal_irradiance_scaling
1278
- diffuse_irradiance = df_period_utc.iloc[0]['DHI'] * diffuse_irradiance_scaling
1279
-
1280
- # Get solar position
1281
- azimuth_degrees = solar_positions.iloc[0]['azimuth']
1282
- elevation_degrees = solar_positions.iloc[0]['elevation']
1283
-
1284
- print(f"Time: {df_period_local.index[0].strftime('%Y-%m-%d %H:%M:%S')}")
1285
- print(f"Sun position: Azimuth {azimuth_degrees:.1f}°, Elevation {elevation_degrees:.1f}°")
1286
- print(f"DNI: {direct_normal_irradiance:.1f} W/m², DHI: {diffuse_irradiance:.1f} W/m²")
1287
-
1288
- # Skip if sun is below horizon
1289
- if elevation_degrees <= 0:
1290
- print("Sun is below horizon, skipping calculation.")
1291
- return building_svf_mesh.copy()
1292
-
1293
- # Compute irradiance
1294
- irradiance_mesh = get_building_solar_irradiance(
1295
- voxel_data,
1296
- meshsize,
1297
- building_svf_mesh,
1298
- azimuth_degrees,
1299
- elevation_degrees,
1300
- direct_normal_irradiance,
1301
- diffuse_irradiance,
1302
- **kwargs
1303
- )
1304
-
1305
- return irradiance_mesh
1306
-
1307
- elif calc_type == 'cumulative':
1308
- # Set default parameters
1309
- period_start = kwargs.get("period_start", "01-01 00:00:00")
1310
- period_end = kwargs.get("period_end", "12-31 23:59:59")
1311
- time_step_hours = kwargs.get("time_step_hours", 1.0)
1312
-
1313
- # Parse start and end times without year
1314
- try:
1315
- start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
1316
- end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
1317
- except ValueError as ve:
1318
- raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
1319
-
1320
- # Create local timezone
1321
- offset_minutes = int(tz * 60)
1322
- local_tz = pytz.FixedOffset(offset_minutes)
1323
-
1324
- # Filter weather data by month, day, hour
1325
- df_period = df[
1326
- ((df.index.month > start_dt.month) |
1327
- ((df.index.month == start_dt.month) & (df.index.day >= start_dt.day) &
1328
- (df.index.hour >= start_dt.hour))) &
1329
- ((df.index.month < end_dt.month) |
1330
- ((df.index.month == end_dt.month) & (df.index.day <= end_dt.day) &
1331
- (df.index.hour <= end_dt.hour)))
1332
- ]
1333
-
1334
- if df_period.empty:
1335
- raise ValueError("No weather data available for the specified period.")
1336
-
1337
- # Convert to local timezone and then to UTC for solar position calculation
1338
- df_period_local = df_period.copy()
1339
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
1340
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
1341
-
1342
- # Get solar positions for all times
1343
- solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1344
-
1345
- # Create a copy of kwargs without time_step_hours to avoid duplicate argument
1346
- kwargs_copy = kwargs.copy()
1347
- if 'time_step_hours' in kwargs_copy:
1348
- del kwargs_copy['time_step_hours']
1349
-
1350
- # Get cumulative irradiance - adapt to match expected function signature
1351
- cumulative_mesh = get_cumulative_building_solar_irradiance(
1352
- voxel_data,
1353
- meshsize,
1354
- building_svf_mesh,
1355
- df, lon, lat, tz, # Pass only the required 7 positional arguments
1356
- period_start=period_start,
1357
- period_end=period_end,
1358
- time_step_hours=time_step_hours,
1359
- direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
1360
- diffuse_irradiance_scaling=diffuse_irradiance_scaling,
1361
- colormap=kwargs.get('colormap', 'jet'),
1362
- show_each_timestep=kwargs.get('show_each_timestep', False),
1363
- obj_export=kwargs.get('obj_export', False),
1364
- output_directory=kwargs.get('output_directory', 'output'),
1365
- output_file_name=kwargs.get('output_file_name', 'cumulative_solar')
1366
- )
1367
-
1368
- return cumulative_mesh
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