voxcity 0.5.30__py3-none-any.whl → 0.6.0__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,1821 +1,2253 @@
1
- """
2
- Solar Irradiance Simulation Module
3
-
4
- This module provides comprehensive solar irradiance calculations for urban environments,
5
- including direct and diffuse solar radiation analysis with consideration for tree transmittance
6
- and building shadows. It supports both instantaneous and cumulative irradiance calculations
7
- using weather data from EPW files.
8
-
9
- Key Features:
10
- - Direct solar irradiance with ray tracing and shadow analysis
11
- - Diffuse solar irradiance using Sky View Factor (SVF)
12
- - Tree transmittance modeling using Beer-Lambert law
13
- - Building surface irradiance calculation with 3D mesh support
14
- - Weather data integration from EPW files
15
- - Visualization and export capabilities
16
-
17
- The module uses numba for performance optimization in computationally intensive calculations.
18
- """
19
-
20
- import numpy as np
21
- import pandas as pd
22
- import matplotlib.pyplot as plt
23
- from numba import njit, prange
24
- from datetime import datetime, timezone
25
- import pytz
26
- from astral import Observer
27
- from astral.sun import elevation, azimuth
28
-
29
- # Import custom modules for view analysis and weather data processing
30
- from .view import trace_ray_generic, compute_vi_map_generic, get_sky_view_factor_map, get_surface_view_factor
31
- from ..utils.weather import get_nearest_epw_from_climate_onebuilding, read_epw_for_solar_simulation
32
- from ..exporter.obj import grid_to_obj, export_obj
33
-
34
- @njit(parallel=True)
35
- def compute_direct_solar_irradiance_map_binary(voxel_data, sun_direction, view_point_height, hit_values, meshsize, tree_k, tree_lad, inclusion_mode):
36
- """
37
- Compute a map of direct solar irradiation accounting for tree transmittance.
38
-
39
- This function performs ray tracing from observer positions on the ground surface
40
- towards the sun to determine direct solar irradiance at each location. It accounts
41
- for shadows cast by buildings and vegetation, with special consideration for
42
- tree transmittance using the Beer-Lambert law.
43
-
44
- The function:
45
- 1. Places observers at valid locations (empty voxels above ground)
46
- 2. Casts rays from each observer in the sun direction
47
- 3. Computes transmittance through trees using Beer-Lambert law
48
- 4. Returns a 2D map of transmittance values
49
-
50
- Observer Placement Rules:
51
- - Observers are placed in empty voxels (value 0 or -2 for trees) above solid ground
52
- - Observers are NOT placed on buildings, vegetation, or water surfaces
53
- - Observer height is added above the detected ground surface
54
-
55
- Ray Tracing Process:
56
- - Rays are cast from each valid observer position toward the sun
57
- - Intersections with obstacles (non-sky voxels) are detected
58
- - Tree voxels provide partial transmittance rather than complete blocking
59
- - Final transmittance value represents solar energy reaching the surface
60
-
61
- Args:
62
- voxel_data (ndarray): 3D array of voxel values representing the urban environment.
63
- Common values: 0=sky, 1-6=buildings, 7-9=special surfaces, -2=trees
64
- sun_direction (tuple): Direction vector of the sun (dx, dy, dz), should be normalized
65
- view_point_height (float): Observer height above ground surface in meters
66
- hit_values (tuple): Values considered non-obstacles if inclusion_mode=False
67
- Typically (0,) meaning only sky voxels are transparent
68
- meshsize (float): Size of each voxel in meters (spatial resolution)
69
- tree_k (float): Tree extinction coefficient for Beer-Lambert law (higher = more opaque)
70
- tree_lad (float): Leaf area density in m^-1 (affects light attenuation through trees)
71
- inclusion_mode (bool): False here, meaning any voxel not in hit_values is an obstacle
72
-
73
- Returns:
74
- ndarray: 2D array of transmittance values (0.0-1.0)
75
- - 1.0 = full sun exposure
76
- - 0.0 = complete shadow
77
- - 0.0-1.0 = partial transmittance through trees
78
- - NaN = invalid observer position (cannot place observer)
79
-
80
- Note:
81
- The returned map is vertically flipped to match standard visualization conventions
82
- where the origin is at the bottom-left corner.
83
- """
84
-
85
- # Convert observer height from meters to voxel units
86
- view_height_voxel = int(view_point_height / meshsize)
87
-
88
- # Get dimensions of the voxel grid
89
- nx, ny, nz = voxel_data.shape
90
-
91
- # Initialize irradiance map with NaN (invalid positions)
92
- irradiance_map = np.full((nx, ny), np.nan, dtype=np.float64)
93
-
94
- # Normalize sun direction vector for consistent ray tracing
95
- # This ensures rays travel at unit speed through the voxel grid
96
- sd = np.array(sun_direction, dtype=np.float64)
97
- sd_len = np.sqrt(sd[0]**2 + sd[1]**2 + sd[2]**2)
98
- if sd_len == 0.0:
99
- return np.flipud(irradiance_map)
100
- sd /= sd_len
101
-
102
- # Process each x,y position in parallel for performance
103
- # This is the main computational loop optimized with numba
104
- for x in prange(nx):
105
- for y in range(ny):
106
- found_observer = False
107
-
108
- # Search upward through the vertical column to find valid observer position
109
- # Start from z=1 to ensure we can check the voxel below
110
- for z in range(1, nz):
111
-
112
- # Check if current voxel is empty/tree and voxel below is solid
113
- # This identifies the ground surface where observers can be placed
114
- if voxel_data[x, y, z] in (0, -2) and voxel_data[x, y, z - 1] not in (0, -2):
115
-
116
- # Skip if standing on building/vegetation/water surfaces
117
- # These are considered invalid observer locations
118
- if (voxel_data[x, y, z - 1] in (7, 8, 9)) or (voxel_data[x, y, z - 1] < 0):
119
- irradiance_map[x, y] = np.nan
120
- found_observer = True
121
- break
122
- else:
123
- # Place observer at valid ground location and cast ray toward sun
124
- observer_location = np.array([x, y, z + view_height_voxel], dtype=np.float64)
125
-
126
- # Trace ray from observer to sun, accounting for obstacles and tree transmittance
127
- hit, transmittance = trace_ray_generic(voxel_data, observer_location, sd,
128
- hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
129
-
130
- # Store transmittance value (0 if hit solid obstacle, 0-1 if through trees)
131
- irradiance_map[x, y] = transmittance if not hit else 0.0
132
- found_observer = True
133
- break
134
-
135
- # If no valid observer position found in this column, mark as invalid
136
- if not found_observer:
137
- irradiance_map[x, y] = np.nan
138
-
139
- # Flip map vertically to match visualization conventions (origin at bottom-left)
140
- return np.flipud(irradiance_map)
141
-
142
- def get_direct_solar_irradiance_map(voxel_data, meshsize, azimuth_degrees_ori, elevation_degrees,
143
- direct_normal_irradiance, show_plot=False, **kwargs):
144
- """
145
- Compute direct solar irradiance map with tree transmittance.
146
-
147
- This function converts solar angles to a 3D direction vector, computes the binary
148
- transmittance map using ray tracing, and scales the results by actual solar irradiance
149
- values to produce physically meaningful irradiance measurements.
150
-
151
- Solar Geometry:
152
- - Azimuth: Horizontal angle measured from North (0°) clockwise to East (90°)
153
- - Elevation: Vertical angle above the horizon (0° = horizon, 90° = zenith)
154
- - The coordinate system is adjusted by 180° to match the voxel grid orientation
155
-
156
- Physics Background:
157
- - Direct Normal Irradiance (DNI): Solar energy on a surface perpendicular to sun rays
158
- - Horizontal irradiance: DNI scaled by sine of elevation angle
159
- - Tree transmittance: Applied using Beer-Lambert law for realistic light attenuation
160
-
161
- The function:
162
- 1. Converts sun angles to direction vector using spherical coordinates
163
- 2. Computes binary transmittance map accounting for shadows and tree effects
164
- 3. Scales by direct normal irradiance and sun elevation for horizontal surfaces
165
- 4. Optionally visualizes and exports results in various formats
166
-
167
- Args:
168
- voxel_data (ndarray): 3D array of voxel values representing the urban environment
169
- meshsize (float): Size of each voxel in meters (spatial resolution)
170
- azimuth_degrees_ori (float): Sun azimuth angle in degrees (0° = North, 90° = East)
171
- elevation_degrees (float): Sun elevation angle in degrees above horizon (0-90°)
172
- direct_normal_irradiance (float): Direct normal irradiance in W/m² (from weather data)
173
- show_plot (bool): Whether to display visualization of results
174
- **kwargs: Additional arguments including:
175
- - view_point_height (float): Observer height in meters (default: 1.5)
176
- Height above ground where irradiance is measured
177
- - colormap (str): Matplotlib colormap name for visualization (default: 'magma')
178
- - vmin (float): Minimum value for colormap scaling
179
- - vmax (float): Maximum value for colormap scaling
180
- - tree_k (float): Tree extinction coefficient (default: 0.6)
181
- Higher values mean trees block more light
182
- - tree_lad (float): Leaf area density in m^-1 (default: 1.0)
183
- Affects light attenuation through tree canopies
184
- - obj_export (bool): Whether to export results as 3D OBJ file
185
- - output_directory (str): Directory for file exports
186
- - output_file_name (str): Base filename for exports
187
- - dem_grid (ndarray): Digital elevation model for 3D export
188
- - num_colors (int): Number of discrete colors for OBJ export
189
- - alpha (float): Transparency value for 3D visualization
190
-
191
- Returns:
192
- ndarray: 2D array of direct solar irradiance values in W/m²
193
- - Values represent energy flux on horizontal surfaces
194
- - NaN indicates invalid measurement locations
195
- - Range typically 0 to direct_normal_irradiance * sin(elevation)
196
-
197
- Note:
198
- The azimuth is internally adjusted by 180° to match the coordinate system
199
- where the voxel grid's y-axis points in the opposite direction from geographic north.
200
- """
201
- # Extract parameters with defaults for observer and visualization settings
202
- view_point_height = kwargs.get("view_point_height", 1.5)
203
- colormap = kwargs.get("colormap", 'magma')
204
- vmin = kwargs.get("vmin", 0.0)
205
- vmax = kwargs.get("vmax", direct_normal_irradiance)
206
-
207
- # Get tree transmittance parameters for Beer-Lambert law calculations
208
- tree_k = kwargs.get("tree_k", 0.6)
209
- tree_lad = kwargs.get("tree_lad", 1.0)
210
-
211
- # Convert sun angles to 3D direction vector using spherical coordinates
212
- # Note: azimuth is adjusted by 180° to match coordinate system orientation
213
- azimuth_degrees = 180 - azimuth_degrees_ori
214
- azimuth_radians = np.deg2rad(azimuth_degrees)
215
- elevation_radians = np.deg2rad(elevation_degrees)
216
-
217
- # Calculate direction vector components
218
- # dx, dy: horizontal components, dz: vertical component (upward positive)
219
- dx = np.cos(elevation_radians) * np.cos(azimuth_radians)
220
- dy = np.cos(elevation_radians) * np.sin(azimuth_radians)
221
- dz = np.sin(elevation_radians)
222
- sun_direction = (dx, dy, dz)
223
-
224
- # Define obstacle detection parameters for ray tracing
225
- # All non-zero voxels are obstacles except for trees which have transmittance
226
- hit_values = (0,) # Only sky voxels (value 0) are transparent
227
- inclusion_mode = False # Values NOT in hit_values are considered obstacles
228
-
229
- # Compute transmittance map using optimized ray tracing
230
- transmittance_map = compute_direct_solar_irradiance_map_binary(
231
- voxel_data, sun_direction, view_point_height, hit_values,
232
- meshsize, tree_k, tree_lad, inclusion_mode
233
- )
234
-
235
- # Scale transmittance by solar irradiance and geometry
236
- # For horizontal surfaces: multiply by sine of elevation angle
237
- sin_elev = dz
238
- direct_map = transmittance_map * direct_normal_irradiance * sin_elev
239
-
240
- # Optional visualization of results
241
- if show_plot:
242
- # Set up colormap with special handling for invalid data
243
- cmap = plt.cm.get_cmap(colormap).copy()
244
- cmap.set_bad(color='lightgray') # NaN values shown in gray
245
-
246
- plt.figure(figsize=(10, 8))
247
- # plt.title("Horizontal Direct Solar Irradiance Map (0° = North)")
248
- plt.imshow(direct_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
249
- plt.colorbar(label='Direct Solar Irradiance (W/m²)')
250
- plt.axis('off')
251
- plt.show()
252
-
253
- # Optional export to 3D OBJ format for external visualization
254
- obj_export = kwargs.get("obj_export", False)
255
- if obj_export:
256
- # Get export parameters with defaults
257
- dem_grid = kwargs.get("dem_grid", np.zeros_like(direct_map))
258
- output_dir = kwargs.get("output_directory", "output")
259
- output_file_name = kwargs.get("output_file_name", "direct_solar_irradiance")
260
- num_colors = kwargs.get("num_colors", 10)
261
- alpha = kwargs.get("alpha", 1.0)
262
-
263
- # Export as colored 3D mesh
264
- grid_to_obj(
265
- direct_map,
266
- dem_grid,
267
- output_dir,
268
- output_file_name,
269
- meshsize,
270
- view_point_height,
271
- colormap_name=colormap,
272
- num_colors=num_colors,
273
- alpha=alpha,
274
- vmin=vmin,
275
- vmax=vmax
276
- )
277
-
278
- return direct_map
279
-
280
- def get_diffuse_solar_irradiance_map(voxel_data, meshsize, diffuse_irradiance=1.0, show_plot=False, **kwargs):
281
- """
282
- Compute diffuse solar irradiance map using the Sky View Factor (SVF) with tree transmittance.
283
-
284
- This function calculates the diffuse component of solar radiation, which consists of
285
- sunlight scattered by the atmosphere and reaches surfaces from all directions across
286
- the sky hemisphere. The calculation is based on the Sky View Factor (SVF), which
287
- quantifies how much of the sky dome is visible from each location.
288
-
289
- Physics Background:
290
- - Diffuse radiation: Solar energy scattered by atmospheric particles and clouds
291
- - Sky View Factor (SVF): Fraction of sky hemisphere visible from a point (0.0 to 1.0)
292
- - Isotropic sky model: Assumes uniform diffuse radiation distribution across the sky
293
- - Tree effects: Partial transmittance through canopies reduces effective sky visibility
294
-
295
- SVF Characteristics:
296
- - SVF = 1.0: Completely open sky (maximum diffuse radiation)
297
- - SVF = 0.0: Completely blocked sky (no diffuse radiation)
298
- - SVF = 0.5: Half of sky visible (typical for urban canyons)
299
- - Trees reduce SVF through partial light attenuation rather than complete blocking
300
-
301
- The function:
302
- 1. Computes SVF map accounting for building shadows and tree transmittance
303
- 2. Scales SVF by diffuse horizontal irradiance from weather data
304
- 3. Optionally visualizes and exports results for analysis
305
-
306
- Args:
307
- voxel_data (ndarray): 3D array of voxel values representing the urban environment
308
- meshsize (float): Size of each voxel in meters (spatial resolution)
309
- diffuse_irradiance (float): Diffuse horizontal irradiance in W/m² (from weather data)
310
- Default 1.0 for normalized calculations
311
- show_plot (bool): Whether to display visualization of results
312
- **kwargs: Additional arguments including:
313
- - view_point_height (float): Observer height in meters (default: 1.5)
314
- Height above ground where measurements are taken
315
- - colormap (str): Matplotlib colormap name for visualization (default: 'magma')
316
- - vmin (float): Minimum value for colormap scaling
317
- - vmax (float): Maximum value for colormap scaling
318
- - tree_k (float): Tree extinction coefficient for transmittance calculations
319
- Higher values mean trees block more diffuse light
320
- - tree_lad (float): Leaf area density in m^-1
321
- Affects light attenuation through tree canopies
322
- - obj_export (bool): Whether to export results as 3D OBJ file
323
- - output_directory (str): Directory for file exports
324
- - output_file_name (str): Base filename for exports
325
- - dem_grid (ndarray): Digital elevation model for 3D export
326
- - num_colors (int): Number of discrete colors for OBJ export
327
- - alpha (float): Transparency value for 3D visualization
328
-
329
- Returns:
330
- ndarray: 2D array of diffuse solar irradiance values in W/m²
331
- - Values represent diffuse energy flux on horizontal surfaces
332
- - Range: 0.0 to diffuse_irradiance (input parameter)
333
- - NaN indicates invalid measurement locations
334
-
335
- Note:
336
- The SVF calculation internally handles tree transmittance effects, so trees
337
- contribute partial sky visibility rather than complete obstruction.
338
- """
339
-
340
- # Extract parameters with defaults for observer and visualization settings
341
- view_point_height = kwargs.get("view_point_height", 1.5)
342
- colormap = kwargs.get("colormap", 'magma')
343
- vmin = kwargs.get("vmin", 0.0)
344
- vmax = kwargs.get("vmax", diffuse_irradiance)
345
-
346
- # Prepare parameters for SVF calculation with appropriate visualization settings
347
- # Pass tree transmittance parameters to SVF calculation
348
- svf_kwargs = kwargs.copy()
349
- svf_kwargs["colormap"] = "BuPu_r" # Purple colormap for SVF visualization
350
- svf_kwargs["vmin"] = 0 # SVF ranges from 0 to 1
351
- svf_kwargs["vmax"] = 1
352
-
353
- # Calculate Sky View Factor map accounting for all obstructions
354
- # SVF calculation now handles tree transmittance internally
355
- SVF_map = get_sky_view_factor_map(voxel_data, meshsize, **svf_kwargs)
356
-
357
- # Convert SVF to diffuse irradiance by scaling with weather data
358
- # Each location receives diffuse radiation proportional to its sky visibility
359
- diffuse_map = SVF_map * diffuse_irradiance
360
-
361
- # Optional visualization of diffuse irradiance results
362
- if show_plot:
363
- # Use parameters from kwargs for consistent visualization
364
- vmin = kwargs.get("vmin", 0.0)
365
- vmax = kwargs.get("vmax", diffuse_irradiance)
366
-
367
- # Set up colormap with special handling for invalid data
368
- cmap = plt.cm.get_cmap(colormap).copy()
369
- cmap.set_bad(color='lightgray') # NaN values shown in gray
370
-
371
- plt.figure(figsize=(10, 8))
372
- # plt.title("Diffuse Solar Irradiance Map")
373
- plt.imshow(diffuse_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
374
- plt.colorbar(label='Diffuse Solar Irradiance (W/m²)')
375
- plt.axis('off')
376
- plt.show()
377
-
378
- # Optional export to 3D OBJ format for external visualization
379
- obj_export = kwargs.get("obj_export", False)
380
- if obj_export:
381
- # Get export parameters with defaults
382
- dem_grid = kwargs.get("dem_grid", np.zeros_like(diffuse_map))
383
- output_dir = kwargs.get("output_directory", "output")
384
- output_file_name = kwargs.get("output_file_name", "diffuse_solar_irradiance")
385
- num_colors = kwargs.get("num_colors", 10)
386
- alpha = kwargs.get("alpha", 1.0)
387
-
388
- # Export as colored 3D mesh
389
- grid_to_obj(
390
- diffuse_map,
391
- dem_grid,
392
- output_dir,
393
- output_file_name,
394
- meshsize,
395
- view_point_height,
396
- colormap_name=colormap,
397
- num_colors=num_colors,
398
- alpha=alpha,
399
- vmin=vmin,
400
- vmax=vmax
401
- )
402
-
403
- return diffuse_map
404
-
405
-
406
- def get_global_solar_irradiance_map(
407
- voxel_data,
408
- meshsize,
409
- azimuth_degrees,
410
- elevation_degrees,
411
- direct_normal_irradiance,
412
- diffuse_irradiance,
413
- show_plot=False,
414
- **kwargs
415
- ):
416
- """
417
- Compute global solar irradiance (direct + diffuse) on a horizontal plane at each valid observer location.
418
-
419
- This function combines both direct and diffuse components of solar radiation to calculate
420
- the total solar irradiance at each location. Global horizontal irradiance (GHI) is the
421
- most commonly used metric for solar energy assessment and represents the total solar
422
- energy available on a horizontal surface.
423
-
424
- Global Irradiance Components:
425
- - Direct component: Solar radiation from the sun's disk, affected by shadows and obstacles
426
- - Diffuse component: Solar radiation scattered by the atmosphere, affected by sky view
427
- - Total irradiance: Sum of direct and diffuse components at each location
428
-
429
- Physical Considerations:
430
- - Direct radiation varies with sun position and local obstructions
431
- - Diffuse radiation varies with sky visibility (Sky View Factor)
432
- - Both components are affected by tree transmittance using Beer-Lambert law
433
- - Invalid locations (e.g., on water, buildings) are marked as NaN
434
-
435
- The function:
436
- 1. Computes direct solar irradiance map accounting for sun position and shadows
437
- 2. Computes diffuse solar irradiance map based on Sky View Factor
438
- 3. Combines maps and optionally visualizes/exports results for analysis
439
-
440
- Args:
441
- voxel_data (ndarray): 3D voxel array representing the urban environment
442
- meshsize (float): Voxel size in meters (spatial resolution)
443
- azimuth_degrees (float): Sun azimuth angle in degrees (0° = North, 90° = East)
444
- elevation_degrees (float): Sun elevation angle in degrees above horizon (0-90°)
445
- direct_normal_irradiance (float): Direct normal irradiance in W/m² (from weather data)
446
- diffuse_irradiance (float): Diffuse horizontal irradiance in W/m² (from weather data)
447
- show_plot (bool): Whether to display visualization of results
448
- **kwargs: Additional arguments including:
449
- - view_point_height (float): Observer height in meters (default: 1.5)
450
- Height above ground where measurements are taken
451
- - colormap (str): Matplotlib colormap name for visualization (default: 'magma')
452
- - vmin (float): Minimum value for colormap scaling
453
- - vmax (float): Maximum value for colormap scaling
454
- - tree_k (float): Tree extinction coefficient for transmittance calculations
455
- Higher values mean trees block more light
456
- - tree_lad (float): Leaf area density in m^-1
457
- Affects light attenuation through tree canopies
458
- - obj_export (bool): Whether to export results as 3D OBJ file
459
- - output_directory (str): Directory for file exports
460
- - output_file_name (str): Base filename for exports
461
- - dem_grid (ndarray): Digital elevation model for 3D export
462
- - num_colors (int): Number of discrete colors for OBJ export
463
- - alpha (float): Transparency value for 3D visualization
464
-
465
- Returns:
466
- ndarray: 2D array of global solar irradiance values in W/m²
467
- - Values represent total solar energy flux on horizontal surfaces
468
- - Range: 0.0 to (direct_normal_irradiance * sin(elevation) + diffuse_irradiance)
469
- - NaN indicates invalid measurement locations
470
-
471
- Note:
472
- Global irradiance is the standard metric used for solar energy assessment
473
- and represents the maximum solar energy available at each location.
474
- """
475
-
476
- # Extract visualization parameters
477
- colormap = kwargs.get("colormap", 'magma')
478
-
479
- # Create kwargs for individual component calculations
480
- # Both direct and diffuse calculations use the same base parameters
481
- direct_diffuse_kwargs = kwargs.copy()
482
- direct_diffuse_kwargs.update({
483
- 'show_plot': True, # Show intermediate results for debugging
484
- 'obj_export': False # Don't export intermediate results
485
- })
486
-
487
- # Compute direct irradiance component
488
- # Accounts for sun position, shadows, and tree transmittance
489
- direct_map = get_direct_solar_irradiance_map(
490
- voxel_data,
491
- meshsize,
492
- azimuth_degrees,
493
- elevation_degrees,
494
- direct_normal_irradiance,
495
- **direct_diffuse_kwargs
496
- )
497
-
498
- # Compute diffuse irradiance component
499
- # Based on Sky View Factor and atmospheric scattering
500
- diffuse_map = get_diffuse_solar_irradiance_map(
501
- voxel_data,
502
- meshsize,
503
- diffuse_irradiance=diffuse_irradiance,
504
- **direct_diffuse_kwargs
505
- )
506
-
507
- # Sum the two components to get total global irradiance
508
- # This represents the total solar energy available at each location
509
- global_map = direct_map + diffuse_map
510
-
511
- # Determine colormap scaling range from actual data
512
- vmin = kwargs.get("vmin", np.nanmin(global_map))
513
- vmax = kwargs.get("vmax", np.nanmax(global_map))
514
-
515
- # Optional visualization of combined results
516
- if show_plot:
517
- # Set up colormap with special handling for invalid data
518
- cmap = plt.cm.get_cmap(colormap).copy()
519
- cmap.set_bad(color='lightgray') # NaN values shown in gray
520
-
521
- plt.figure(figsize=(10, 8))
522
- # plt.title("Global Solar Irradiance Map")
523
- plt.imshow(global_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
524
- plt.colorbar(label='Global Solar Irradiance (W/m²)')
525
- plt.axis('off')
526
- plt.show()
527
-
528
- # Optional export to 3D OBJ format for external visualization
529
- obj_export = kwargs.get("obj_export", False)
530
- if obj_export:
531
- # Get export parameters with defaults
532
- dem_grid = kwargs.get("dem_grid", np.zeros_like(global_map))
533
- output_dir = kwargs.get("output_directory", "output")
534
- output_file_name = kwargs.get("output_file_name", "global_solar_irradiance")
535
- num_colors = kwargs.get("num_colors", 10)
536
- alpha = kwargs.get("alpha", 1.0)
537
- meshsize_param = kwargs.get("meshsize", meshsize)
538
- view_point_height = kwargs.get("view_point_height", 1.5)
539
-
540
- # Export as colored 3D mesh
541
- grid_to_obj(
542
- global_map,
543
- dem_grid,
544
- output_dir,
545
- output_file_name,
546
- meshsize_param,
547
- view_point_height,
548
- colormap_name=colormap,
549
- num_colors=num_colors,
550
- alpha=alpha,
551
- vmin=vmin,
552
- vmax=vmax
553
- )
554
-
555
- return global_map
556
-
557
- def get_solar_positions_astral(times, lon, lat):
558
- """
559
- Compute solar azimuth and elevation using Astral for given times and location.
560
-
561
- This function uses the Astral astronomical library to calculate precise solar positions
562
- based on location coordinates and timestamps. The calculations account for Earth's
563
- orbital mechanics, axial tilt, and atmospheric refraction effects.
564
-
565
- Astronomical Background:
566
- - Solar position depends on date, time, and geographic location
567
- - Azimuth: Horizontal angle measured clockwise from North (0°-360°)
568
- - Elevation: Vertical angle above the horizon (-90° to +90°)
569
- - Calculations use standard astronomical algorithms (e.g., NREL SPA)
570
-
571
- Coordinate System:
572
- - Azimuth: 0° = North, 90° = East, 180° = South, 270° = West
573
- - Elevation: 0° = horizon, 90° = zenith, negative values = below horizon
574
- - All angles are in degrees for consistency with weather data formats
575
-
576
- The function:
577
- 1. Creates an Astral observer at the specified geographic location
578
- 2. Computes sun position for each timestamp in the input array
579
- 3. Returns DataFrame with azimuth and elevation angles for further processing
580
-
581
- Args:
582
- times (DatetimeIndex): Array of timezone-aware datetime objects
583
- Must include timezone information for accurate calculations
584
- lon (float): Longitude in degrees (positive = East, negative = West)
585
- Range: -180° to +180°
586
- lat (float): Latitude in degrees (positive = North, negative = South)
587
- Range: -90° to +90°
588
-
589
- Returns:
590
- DataFrame: DataFrame with columns 'azimuth' and 'elevation' containing solar positions
591
- - Index: Input timestamps (timezone-aware)
592
- - 'azimuth': Solar azimuth angles in degrees (0°-360°)
593
- - 'elevation': Solar elevation angles in degrees (-90° to +90°)
594
- - All values are float type for numerical calculations
595
-
596
- Note:
597
- Input times must be timezone-aware. The function preserves the original
598
- timezone information and performs calculations in the specified timezone.
599
- """
600
- # Create an astronomical observer at the specified geographic location
601
- observer = Observer(latitude=lat, longitude=lon)
602
-
603
- # Initialize result DataFrame with appropriate structure
604
- df_pos = pd.DataFrame(index=times, columns=['azimuth', 'elevation'], dtype=float)
605
-
606
- # Calculate solar position for each timestamp
607
- for t in times:
608
- # t is already timezone-aware; no need to replace tzinfo
609
- # Calculate solar elevation (vertical angle above horizon)
610
- el = elevation(observer=observer, dateandtime=t)
611
-
612
- # Calculate solar azimuth (horizontal angle from North)
613
- az = azimuth(observer=observer, dateandtime=t)
614
-
615
- # Store results in DataFrame
616
- df_pos.at[t, 'elevation'] = el
617
- df_pos.at[t, 'azimuth'] = az
618
-
619
- return df_pos
620
-
621
- def get_cumulative_global_solar_irradiance(
622
- voxel_data,
623
- meshsize,
624
- df, lon, lat, tz,
625
- direct_normal_irradiance_scaling=1.0,
626
- diffuse_irradiance_scaling=1.0,
627
- **kwargs
628
- ):
629
- """
630
- Compute cumulative global solar irradiance over a specified period using data from an EPW file.
631
-
632
- This function performs time-series analysis of solar irradiance by processing weather data
633
- over a user-defined period and accumulating irradiance values at each location. The result
634
- represents the total solar energy received during the specified time period, which is
635
- essential for seasonal analysis, solar panel positioning, and energy yield predictions.
636
-
637
- Cumulative Analysis Concept:
638
- - Instantaneous irradiance (W/m²): Power at a specific moment
639
- - Cumulative irradiance (Wh/m²): Energy accumulated over time
640
- - Integration: Sum of (irradiance × time_step) for all timesteps
641
- - Applications: Annual energy yield, seasonal variations, optimal siting
642
-
643
- Time Period Processing:
644
- - Supports flexible time ranges (daily, seasonal, annual analysis)
645
- - Handles timezone conversions between local and UTC time
646
- - Filters weather data based on user-specified start/end times
647
- - Accounts for leap years and varying daylight hours
648
-
649
- Performance Optimization:
650
- - Pre-calculates diffuse map once (scales linearly with DHI)
651
- - Processes direct component for each timestep (varies with sun position)
652
- - Uses efficient memory management for large time series
653
- - Provides optional progress monitoring for long calculations
654
-
655
- The function:
656
- 1. Filters EPW data for specified time period with timezone handling
657
- 2. Computes sun positions for each timestep using astronomical calculations
658
- 3. Calculates and accumulates global irradiance maps over the entire period
659
- 4. Handles tree transmittance and provides visualization/export options
660
-
661
- Args:
662
- voxel_data (ndarray): 3D array of voxel values representing the urban environment
663
- meshsize (float): Size of each voxel in meters (spatial resolution)
664
- df (DataFrame): EPW weather data with columns 'DNI', 'DHI' and datetime index
665
- Must include complete meteorological dataset
666
- lon (float): Longitude in degrees for solar position calculations
667
- lat (float): Latitude in degrees for solar position calculations
668
- tz (float): Timezone offset in hours from UTC (positive = East of UTC)
669
- direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance
670
- Allows sensitivity analysis or unit conversions
671
- diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance
672
- Allows sensitivity analysis or unit conversions
673
- **kwargs: Additional arguments including:
674
- - view_point_height (float): Observer height in meters (default: 1.5)
675
- Height above ground where measurements are taken
676
- - start_time (str): Start time in format 'MM-DD HH:MM:SS'
677
- Defines beginning of analysis period (default: "01-01 05:00:00")
678
- - end_time (str): End time in format 'MM-DD HH:MM:SS'
679
- Defines end of analysis period (default: "01-01 20:00:00")
680
- - tree_k (float): Tree extinction coefficient for transmittance calculations
681
- Higher values mean trees block more light
682
- - tree_lad (float): Leaf area density in m^-1
683
- Affects light attenuation through tree canopies
684
- - show_plot (bool): Whether to show final accumulated results
685
- - show_each_timestep (bool): Whether to show plots for each timestep
686
- Useful for debugging but significantly increases computation time
687
- - colormap (str): Matplotlib colormap name for visualization
688
- - vmin (float): Minimum value for colormap scaling
689
- - vmax (float): Maximum value for colormap scaling
690
- - obj_export (bool): Whether to export results as 3D OBJ file
691
- - output_directory (str): Directory for file exports
692
- - output_file_name (str): Base filename for exports
693
- - dem_grid (ndarray): Digital elevation model for 3D export
694
- - num_colors (int): Number of discrete colors for OBJ export
695
- - alpha (float): Transparency value for 3D visualization
696
-
697
- Returns:
698
- ndarray: 2D array of cumulative global solar irradiance values in W/m²·hour
699
- - Values represent total solar energy received during the analysis period
700
- - Range depends on period length and local climate conditions
701
- - NaN indicates invalid measurement locations (e.g., on buildings, water)
702
-
703
- Note:
704
- The function efficiently handles large time series by pre-computing the diffuse
705
- component once and scaling it for each timestep, significantly reducing
706
- computation time for long-term analysis.
707
- """
708
- # Extract parameters with defaults for observer positioning and visualization
709
- view_point_height = kwargs.get("view_point_height", 1.5)
710
- colormap = kwargs.get("colormap", 'magma')
711
- start_time = kwargs.get("start_time", "01-01 05:00:00")
712
- end_time = kwargs.get("end_time", "01-01 20:00:00")
713
-
714
- # Validate input data
715
- if df.empty:
716
- raise ValueError("No data in EPW file.")
717
-
718
- # Parse start and end times without year (supports multi-year analysis)
719
- try:
720
- start_dt = datetime.strptime(start_time, "%m-%d %H:%M:%S")
721
- end_dt = datetime.strptime(end_time, "%m-%d %H:%M:%S")
722
- except ValueError as ve:
723
- raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
724
-
725
- # Add hour of year column for efficient time filtering
726
- # Hour 1 = January 1st, 00:00; Hour 8760 = December 31st, 23:00
727
- df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
728
-
729
- # Convert parsed dates to day of year and hour for filtering
730
- start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
731
- end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
732
-
733
- start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
734
- end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
735
-
736
- # Handle period crossing year boundary (e.g., Dec 15 to Jan 15)
737
- if start_hour <= end_hour:
738
- # Normal period within single year
739
- df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
740
- else:
741
- # Period crosses year boundary - include end and beginning of year
742
- df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
743
-
744
- # Apply minute-level filtering within start/end hours for precision
745
- df_period = df_period[
746
- ((df_period.index.hour != start_dt.hour) | (df_period.index.minute >= start_dt.minute)) &
747
- ((df_period.index.hour != end_dt.hour) | (df_period.index.minute <= end_dt.minute))
748
- ]
749
-
750
- # Validate filtered data
751
- if df_period.empty:
752
- raise ValueError("No EPW data in the specified period.")
753
-
754
- # Handle timezone conversion for accurate solar position calculations
755
- # Convert local time (from EPW) to UTC for astronomical calculations
756
- offset_minutes = int(tz * 60)
757
- local_tz = pytz.FixedOffset(offset_minutes)
758
- df_period_local = df_period.copy()
759
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
760
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
761
-
762
- # Compute solar positions for entire analysis period
763
- # This is done once to optimize performance
764
- solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
765
-
766
- # Prepare parameters for efficient diffuse irradiance calculation
767
- # Create kwargs for diffuse calculation with visualization disabled
768
- diffuse_kwargs = kwargs.copy()
769
- diffuse_kwargs.update({
770
- 'show_plot': False,
771
- 'obj_export': False
772
- })
773
-
774
- # Pre-compute base diffuse map once with unit irradiance
775
- # This map will be scaled by actual DHI values for each timestep
776
- base_diffuse_map = get_diffuse_solar_irradiance_map(
777
- voxel_data,
778
- meshsize,
779
- diffuse_irradiance=1.0,
780
- **diffuse_kwargs
781
- )
782
-
783
- # Initialize accumulation arrays for energy integration
784
- cumulative_map = np.zeros((voxel_data.shape[0], voxel_data.shape[1]))
785
- mask_map = np.ones((voxel_data.shape[0], voxel_data.shape[1]), dtype=bool)
786
-
787
- # Prepare parameters for direct irradiance calculations
788
- # Create kwargs for direct calculation with visualization disabled
789
- direct_kwargs = kwargs.copy()
790
- direct_kwargs.update({
791
- 'show_plot': False,
792
- 'view_point_height': view_point_height,
793
- 'obj_export': False
794
- })
795
-
796
- # Main processing loop: iterate through each timestep in the analysis period
797
- for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
798
- # Apply scaling factors to weather data
799
- # Allows for sensitivity analysis or unit conversions
800
- DNI = row['DNI'] * direct_normal_irradiance_scaling
801
- DHI = row['DHI'] * diffuse_irradiance_scaling
802
- time_local = df_period_local.index[idx]
803
-
804
- # Get solar position for timestep
805
- solpos = solar_positions.loc[time_utc]
806
- azimuth_degrees = solpos['azimuth']
807
- elevation_degrees = solpos['elevation']
808
-
809
- # Compute direct irradiance map with transmittance
810
- direct_map = get_direct_solar_irradiance_map(
811
- voxel_data,
812
- meshsize,
813
- azimuth_degrees,
814
- elevation_degrees,
815
- direct_normal_irradiance=DNI,
816
- **direct_kwargs
817
- )
818
-
819
- # Scale base diffuse map by actual DHI
820
- diffuse_map = base_diffuse_map * DHI
821
-
822
- # Combine direct and diffuse components
823
- global_map = direct_map + diffuse_map
824
-
825
- # Update valid pixel mask
826
- mask_map &= ~np.isnan(global_map)
827
-
828
- # Replace NaN with 0 for accumulation
829
- global_map_filled = np.nan_to_num(global_map, nan=0.0)
830
- cumulative_map += global_map_filled
831
-
832
- # Optional timestep visualization
833
- show_each_timestep = kwargs.get("show_each_timestep", False)
834
- if show_each_timestep:
835
- colormap = kwargs.get("colormap", 'viridis')
836
- vmin = kwargs.get("vmin", 0.0)
837
- vmax = kwargs.get("vmax", max(direct_normal_irradiance_scaling, diffuse_irradiance_scaling) * 1000)
838
- cmap = plt.cm.get_cmap(colormap).copy()
839
- cmap.set_bad(color='lightgray')
840
- plt.figure(figsize=(10, 8))
841
- # plt.title(f"Global Solar Irradiance at {time_local.strftime('%Y-%m-%d %H:%M:%S')}")
842
- plt.imshow(global_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
843
- plt.axis('off')
844
- plt.colorbar(label='Global Solar Irradiance (W/m²)')
845
- plt.show()
846
-
847
- # Apply mask to final result
848
- cumulative_map[~mask_map] = np.nan
849
-
850
- # Final visualization
851
- show_plot = kwargs.get("show_plot", True)
852
- if show_plot:
853
- colormap = kwargs.get("colormap", 'magma')
854
- vmin = kwargs.get("vmin", np.nanmin(cumulative_map))
855
- vmax = kwargs.get("vmax", np.nanmax(cumulative_map))
856
- cmap = plt.cm.get_cmap(colormap).copy()
857
- cmap.set_bad(color='lightgray')
858
- plt.figure(figsize=(10, 8))
859
- # plt.title("Cumulative Global Solar Irradiance Map")
860
- plt.imshow(cumulative_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
861
- plt.colorbar(label='Cumulative Global Solar Irradiance (W/m²·hour)')
862
- plt.axis('off')
863
- plt.show()
864
-
865
- # Optional OBJ export
866
- obj_export = kwargs.get("obj_export", False)
867
- if obj_export:
868
- colormap = kwargs.get("colormap", "magma")
869
- vmin = kwargs.get("vmin", np.nanmin(cumulative_map))
870
- vmax = kwargs.get("vmax", np.nanmax(cumulative_map))
871
- dem_grid = kwargs.get("dem_grid", np.zeros_like(cumulative_map))
872
- output_dir = kwargs.get("output_directory", "output")
873
- output_file_name = kwargs.get("output_file_name", "cummurative_global_solar_irradiance")
874
- num_colors = kwargs.get("num_colors", 10)
875
- alpha = kwargs.get("alpha", 1.0)
876
- grid_to_obj(
877
- cumulative_map,
878
- dem_grid,
879
- output_dir,
880
- output_file_name,
881
- meshsize,
882
- view_point_height,
883
- colormap_name=colormap,
884
- num_colors=num_colors,
885
- alpha=alpha,
886
- vmin=vmin,
887
- vmax=vmax
888
- )
889
-
890
- return cumulative_map
891
-
892
- def get_global_solar_irradiance_using_epw(
893
- voxel_data,
894
- meshsize,
895
- calc_type='instantaneous',
896
- direct_normal_irradiance_scaling=1.0,
897
- diffuse_irradiance_scaling=1.0,
898
- **kwargs
899
- ):
900
- """
901
- Compute global solar irradiance using EPW weather data, either for a single time or cumulatively over a period.
902
-
903
- The function:
904
- 1. Optionally downloads and reads EPW weather data
905
- 2. Handles timezone conversions and solar position calculations
906
- 3. Computes either instantaneous or cumulative irradiance maps
907
- 4. Supports visualization and export options
908
-
909
- Args:
910
- voxel_data (ndarray): 3D array of voxel values.
911
- meshsize (float): Size of each voxel in meters.
912
- calc_type (str): 'instantaneous' or 'cumulative'.
913
- direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
914
- diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
915
- **kwargs: Additional arguments including:
916
- - download_nearest_epw (bool): Whether to download nearest EPW file
917
- - epw_file_path (str): Path to EPW file
918
- - rectangle_vertices (list): List of (lat,lon) coordinates for EPW download
919
- - output_dir (str): Directory for EPW download
920
- - calc_time (str): Time for instantaneous calculation ('MM-DD HH:MM:SS')
921
- - start_time (str): Start time for cumulative calculation
922
- - end_time (str): End time for cumulative calculation
923
- - start_hour (int): Starting hour for daily time window (0-23)
924
- - end_hour (int): Ending hour for daily time window (0-23)
925
- - view_point_height (float): Observer height in meters
926
- - tree_k (float): Tree extinction coefficient
927
- - tree_lad (float): Leaf area density in m^-1
928
- - show_plot (bool): Whether to show visualization
929
- - show_each_timestep (bool): Whether to show timestep plots
930
- - colormap (str): Matplotlib colormap name
931
- - obj_export (bool): Whether to export as OBJ file
932
-
933
- Returns:
934
- ndarray: 2D array of solar irradiance values (W/m²).
935
- """
936
- view_point_height = kwargs.get("view_point_height", 1.5)
937
- colormap = kwargs.get("colormap", 'magma')
938
-
939
- # Get EPW file
940
- download_nearest_epw = kwargs.get("download_nearest_epw", False)
941
- rectangle_vertices = kwargs.get("rectangle_vertices", None)
942
- epw_file_path = kwargs.get("epw_file_path", None)
943
- if download_nearest_epw:
944
- if rectangle_vertices is None:
945
- print("rectangle_vertices is required to download nearest EPW file")
946
- return None
947
- else:
948
- # Calculate center point of rectangle
949
- lons = [coord[0] for coord in rectangle_vertices]
950
- lats = [coord[1] for coord in rectangle_vertices]
951
- center_lon = (min(lons) + max(lons)) / 2
952
- center_lat = (min(lats) + max(lats)) / 2
953
- target_point = (center_lon, center_lat)
954
-
955
- # Optional: specify maximum distance in kilometers
956
- max_distance = 100 # None for no limit
957
-
958
- output_dir = kwargs.get("output_dir", "output")
959
-
960
- epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
961
- longitude=center_lon,
962
- latitude=center_lat,
963
- output_dir=output_dir,
964
- max_distance=max_distance,
965
- extract_zip=True,
966
- load_data=True
967
- )
968
-
969
- # Read EPW data
970
- df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
971
- if df.empty:
972
- raise ValueError("No data in EPW file.")
973
-
974
- if calc_type == 'instantaneous':
975
-
976
- calc_time = kwargs.get("calc_time", "01-01 12:00:00")
977
-
978
- # Parse start and end times without year
979
- try:
980
- calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
981
- except ValueError as ve:
982
- raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
983
-
984
- df_period = df[
985
- (df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
986
- ]
987
-
988
- if df_period.empty:
989
- raise ValueError("No EPW data at the specified time.")
990
-
991
- # Prepare timezone conversion
992
- offset_minutes = int(tz * 60)
993
- local_tz = pytz.FixedOffset(offset_minutes)
994
- df_period_local = df_period.copy()
995
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
996
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
997
-
998
- # Compute solar positions
999
- solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1000
- direct_normal_irradiance = df_period_utc.iloc[0]['DNI']
1001
- diffuse_irradiance = df_period_utc.iloc[0]['DHI']
1002
- azimuth_degrees = solar_positions.iloc[0]['azimuth']
1003
- elevation_degrees = solar_positions.iloc[0]['elevation']
1004
- solar_map = get_global_solar_irradiance_map(
1005
- voxel_data, # 3D voxel grid representing the urban environment
1006
- meshsize, # Size of each grid cell in meters
1007
- azimuth_degrees, # Sun's azimuth angle
1008
- elevation_degrees, # Sun's elevation angle
1009
- direct_normal_irradiance, # Direct Normal Irradiance value
1010
- diffuse_irradiance, # Diffuse irradiance value
1011
- show_plot=True, # Display visualization of results
1012
- **kwargs
1013
- )
1014
- if calc_type == 'cumulative':
1015
- # Get time window parameters
1016
- start_hour = kwargs.get("start_hour", 0) # Default to midnight
1017
- end_hour = kwargs.get("end_hour", 23) # Default to 11 PM
1018
-
1019
- # Filter dataframe for specified hours
1020
- df_filtered = df[(df.index.hour >= start_hour) & (df.index.hour <= end_hour)]
1021
-
1022
- solar_map = get_cumulative_global_solar_irradiance(
1023
- voxel_data,
1024
- meshsize,
1025
- df_filtered, lon, lat, tz,
1026
- **kwargs
1027
- )
1028
-
1029
- return solar_map
1030
-
1031
- import numpy as np
1032
- import trimesh
1033
- import time
1034
- from numba import njit
1035
-
1036
- ##############################################################################
1037
- # 1) New Numba helper: per-face solar irradiance computation
1038
- ##############################################################################
1039
- @njit
1040
- def compute_solar_irradiance_for_all_faces(
1041
- face_centers,
1042
- face_normals,
1043
- face_svf,
1044
- sun_direction,
1045
- direct_normal_irradiance,
1046
- diffuse_irradiance,
1047
- voxel_data,
1048
- meshsize,
1049
- tree_k,
1050
- tree_lad,
1051
- hit_values,
1052
- inclusion_mode,
1053
- grid_bounds_real,
1054
- boundary_epsilon
1055
- ):
1056
- """
1057
- Numba-compiled function to compute direct, diffuse, and global solar irradiance
1058
- for each face in a 3D building mesh.
1059
-
1060
- This optimized function processes all mesh faces in parallel to calculate solar
1061
- irradiance components. It handles both direct radiation (dependent on sun position
1062
- and surface orientation) and diffuse radiation (dependent on sky visibility).
1063
- The function is compiled with Numba for high-performance computation on large meshes.
1064
-
1065
- Surface Irradiance Physics:
1066
- - Direct component: DNI × cos(incidence_angle) × transmittance
1067
- - Diffuse component: DHI × sky_view_factor
1068
- - Incidence angle: Angle between sun direction and surface normal
1069
- - Transmittance: Attenuation factor from obstacles and vegetation
1070
-
1071
- Boundary Condition Handling:
1072
- - Vertical boundary faces are excluded (mesh edges touching domain boundaries)
1073
- - Invalid faces (NaN SVF) are skipped to maintain data consistency
1074
- - Surface orientation affects direct radiation calculation
1075
-
1076
- Performance Optimizations:
1077
- - Numba JIT compilation for near C-speed execution
1078
- - Parallel processing of face calculations
1079
- - Efficient geometric computations using vectorized operations
1080
- - Memory-optimized array operations
1081
-
1082
- Args:
1083
- face_centers (float64[:, :]): (N x 3) array of face center coordinates in real-world units
1084
- face_normals (float64[:, :]): (N x 3) array of outward-pointing unit normal vectors
1085
- face_svf (float64[:]): (N,) array of Sky View Factor values for each face (0.0-1.0)
1086
- sun_direction (float64[:]): (3,) array for normalized sun direction vector (dx, dy, dz)
1087
- direct_normal_irradiance (float): Direct normal irradiance (DNI) in W/m²
1088
- diffuse_irradiance (float): Diffuse horizontal irradiance (DHI) in W/m²
1089
- voxel_data (ndarray): 3D array of voxel values for obstacle detection
1090
- meshsize (float): Size of each voxel in meters (spatial resolution)
1091
- tree_k (float): Tree extinction coefficient for Beer-Lambert law
1092
- tree_lad (float): Leaf area density in m^-1
1093
- hit_values (tuple): Values considered 'sky' for ray tracing (e.g. (0,))
1094
- inclusion_mode (bool): Whether hit_values are included (True) or excluded (False)
1095
- grid_bounds_real (float64[2,3]): Domain boundaries [[x_min,y_min,z_min],[x_max,y_max,z_max]]
1096
- boundary_epsilon (float): Distance threshold for boundary face detection
1097
-
1098
- Returns:
1099
- tuple: Three float64[N] arrays containing:
1100
- - direct_irr: Direct solar irradiance for each face (W/m²)
1101
- - diffuse_irr: Diffuse solar irradiance for each face (W/m²)
1102
- - global_irr: Global solar irradiance for each face (W/m²)
1103
-
1104
- Note:
1105
- This function is optimized with Numba and should not be called directly.
1106
- Use the higher-level wrapper functions for normal operation.
1107
- """
1108
- n_faces = face_centers.shape[0]
1109
-
1110
- # Initialize output arrays for each irradiance component
1111
- face_direct = np.zeros(n_faces, dtype=np.float64)
1112
- face_diffuse = np.zeros(n_faces, dtype=np.float64)
1113
- face_global = np.zeros(n_faces, dtype=np.float64)
1114
-
1115
- # Extract domain boundaries for boundary face detection
1116
- x_min, y_min, z_min = grid_bounds_real[0, 0], grid_bounds_real[0, 1], grid_bounds_real[0, 2]
1117
- x_max, y_max, z_max = grid_bounds_real[1, 0], grid_bounds_real[1, 1], grid_bounds_real[1, 2]
1118
-
1119
- # Process each face individually (Numba optimizes this loop)
1120
- for fidx in range(n_faces):
1121
- center = face_centers[fidx]
1122
- normal = face_normals[fidx]
1123
- svf = face_svf[fidx]
1124
-
1125
- # Check for vertical boundary faces that should be excluded
1126
- # These are mesh edges at domain boundaries, not actual building surfaces
1127
- is_vertical = (abs(normal[2]) < 0.01) # Nearly vertical normal
1128
-
1129
- # Check if face center is at domain boundary
1130
- on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
1131
- on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
1132
- on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
1133
- on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
1134
-
1135
- is_boundary_vertical = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
1136
-
1137
- # Skip boundary faces to avoid artifacts
1138
- if is_boundary_vertical:
1139
- face_direct[fidx] = np.nan
1140
- face_diffuse[fidx] = np.nan
1141
- face_global[fidx] = np.nan
1142
- continue
1143
-
1144
- # Skip faces with invalid SVF data
1145
- if svf != svf: # NaN check in Numba-compatible way
1146
- face_direct[fidx] = np.nan
1147
- face_diffuse[fidx] = np.nan
1148
- face_global[fidx] = np.nan
1149
- continue
1150
-
1151
- # Calculate direct irradiance component
1152
- # Only surfaces oriented towards the sun receive direct radiation
1153
- cos_incidence = normal[0]*sun_direction[0] + \
1154
- normal[1]*sun_direction[1] + \
1155
- normal[2]*sun_direction[2]
1156
-
1157
- direct_val = 0.0
1158
- if cos_incidence > 0.0: # Surface faces towards sun
1159
- # Offset ray origin slightly along normal to avoid self-intersection
1160
- offset_vox = 0.1 # Small offset in voxel units
1161
- ray_origin_x = center[0]/meshsize + normal[0]*offset_vox
1162
- ray_origin_y = center[1]/meshsize + normal[1]*offset_vox
1163
- ray_origin_z = center[2]/meshsize + normal[2]*offset_vox
1164
-
1165
- # Cast ray toward the sun to check for obstructions
1166
- hit_detected, transmittance = trace_ray_generic(
1167
- voxel_data,
1168
- np.array([ray_origin_x, ray_origin_y, ray_origin_z], dtype=np.float64),
1169
- sun_direction,
1170
- hit_values,
1171
- meshsize,
1172
- tree_k,
1173
- tree_lad,
1174
- inclusion_mode
1175
- )
1176
-
1177
- # Calculate direct irradiance if path to sun is clear/partially clear
1178
- if not hit_detected:
1179
- direct_val = direct_normal_irradiance * cos_incidence * transmittance
1180
-
1181
- # Calculate diffuse irradiance component using Sky View Factor
1182
- # All surfaces receive diffuse radiation proportional to their sky visibility
1183
- diffuse_val = svf * diffuse_irradiance
1184
-
1185
- # Ensure diffuse irradiance doesn't exceed theoretical maximum
1186
- if diffuse_val > diffuse_irradiance:
1187
- diffuse_val = diffuse_irradiance
1188
-
1189
- # Store results for this face
1190
- face_direct[fidx] = direct_val
1191
- face_diffuse[fidx] = diffuse_val
1192
- face_global[fidx] = direct_val + diffuse_val
1193
-
1194
- return face_direct, face_diffuse, face_global
1195
-
1196
-
1197
- ##############################################################################
1198
- # 2) Modified get_building_solar_irradiance: main Python wrapper
1199
- ##############################################################################
1200
- def get_building_solar_irradiance(
1201
- voxel_data,
1202
- meshsize,
1203
- building_svf_mesh,
1204
- azimuth_degrees,
1205
- elevation_degrees,
1206
- direct_normal_irradiance,
1207
- diffuse_irradiance,
1208
- **kwargs
1209
- ):
1210
- """
1211
- Calculate solar irradiance on building surfaces using Sky View Factor (SVF) analysis,
1212
- with high-performance computation accelerated by Numba JIT compilation.
1213
-
1214
- This function performs detailed solar irradiance analysis on 3D building surfaces
1215
- represented as triangulated meshes. It calculates both direct and diffuse components
1216
- of solar radiation for each mesh face, accounting for surface orientation, shadows,
1217
- and sky visibility. The computation is optimized for large urban models using
1218
- efficient algorithms and parallel processing.
1219
-
1220
- Mesh-Based Analysis Advantages:
1221
- - Surface-specific calculations for facades, roofs, and complex geometries
1222
- - Accurate accounting of surface orientation and local shading effects
1223
- - Integration with 3D visualization and CAD workflows
1224
- - Detailed irradiance data for building energy modeling
1225
-
1226
- Performance Features:
1227
- - Numba JIT compilation for near C-speed execution
1228
- - Parallel processing of mesh faces
1229
- - Efficient ray tracing with tree transmittance
1230
- - Memory-optimized operations for large datasets
1231
-
1232
- Physical Modeling:
1233
- - Direct irradiance: Based on sun position and surface orientation
1234
- - Diffuse irradiance: Based on Sky View Factor from each surface
1235
- - Tree effects: Partial transmittance using Beer-Lambert law
1236
- - Boundary handling: Automatic exclusion of domain boundary artifacts
1237
-
1238
- Args:
1239
- voxel_data (ndarray): 3D array of voxel values representing the urban environment
1240
- meshsize (float): Size of each voxel in meters (spatial resolution)
1241
- building_svf_mesh (trimesh.Trimesh): Building mesh with pre-calculated SVF values in metadata
1242
- Must have 'svf' array in mesh.metadata
1243
- azimuth_degrees (float): Sun azimuth angle in degrees (0=North, 90=East)
1244
- elevation_degrees (float): Sun elevation angle in degrees above horizon (0-90°)
1245
- direct_normal_irradiance (float): Direct normal irradiance (DNI) in W/m² from weather data
1246
- diffuse_irradiance (float): Diffuse horizontal irradiance (DHI) in W/m² from weather data
1247
- **kwargs: Additional parameters including:
1248
- - tree_k (float): Tree extinction coefficient (default: 0.6)
1249
- Higher values mean trees block more light
1250
- - tree_lad (float): Leaf area density in m^-1 (default: 1.0)
1251
- Affects light attenuation through tree canopies
1252
- - progress_report (bool): Whether to print timing information (default: False)
1253
- - obj_export (bool): Whether to export results as OBJ file
1254
- - output_directory (str): Directory for file exports
1255
- - output_file_name (str): Base filename for exports
1256
-
1257
- Returns:
1258
- trimesh.Trimesh: A copy of the input mesh with irradiance data stored in metadata:
1259
- - 'svf': Sky View Factor for each face (preserved from input)
1260
- - 'direct': Direct solar irradiance for each face (W/m²)
1261
- - 'diffuse': Diffuse solar irradiance for each face (W/m²)
1262
- - 'global': Global solar irradiance for each face (W/m²)
1263
-
1264
- Note:
1265
- The input mesh must have SVF values pre-calculated and stored in metadata.
1266
- Use get_surface_view_factor() to compute SVF before calling this function.
1267
- """
1268
- import time
1269
-
1270
- # Extract tree transmittance parameters with defaults
1271
- tree_k = kwargs.get("tree_k", 0.6)
1272
- tree_lad = kwargs.get("tree_lad", 1.0)
1273
- progress_report = kwargs.get("progress_report", False)
1274
-
1275
- # Define sky detection parameters for ray tracing
1276
- hit_values = (0,) # '0' = sky voxel value
1277
- inclusion_mode = False # Treat non-sky values as obstacles
1278
-
1279
- # Convert solar angles to 3D direction vector using spherical coordinates
1280
- az_rad = np.deg2rad(180 - azimuth_degrees) # Adjust for coordinate system
1281
- el_rad = np.deg2rad(elevation_degrees)
1282
- sun_dx = np.cos(el_rad) * np.cos(az_rad)
1283
- sun_dy = np.cos(el_rad) * np.sin(az_rad)
1284
- sun_dz = np.sin(el_rad)
1285
- sun_direction = np.array([sun_dx, sun_dy, sun_dz], dtype=np.float64)
1286
-
1287
- # Extract mesh geometry data for processing
1288
- face_centers = building_svf_mesh.triangles_center # Center point of each face
1289
- face_normals = building_svf_mesh.face_normals # Normal vector for each face
1290
-
1291
- # Extract Sky View Factor data from mesh metadata
1292
- if hasattr(building_svf_mesh, 'metadata') and ('svf' in building_svf_mesh.metadata):
1293
- face_svf = building_svf_mesh.metadata['svf']
1294
- else:
1295
- # Initialize with zeros if SVF not available (should be pre-calculated)
1296
- face_svf = np.zeros(len(building_svf_mesh.faces), dtype=np.float64)
1297
-
1298
- # Set up domain boundaries for boundary face detection
1299
- grid_shape = voxel_data.shape
1300
- grid_bounds_voxel = np.array([[0,0,0],[grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=np.float64)
1301
- grid_bounds_real = grid_bounds_voxel * meshsize
1302
- boundary_epsilon = meshsize * 0.05 # Small tolerance for boundary detection
1303
-
1304
- # Call high-performance Numba-compiled calculation function
1305
- t0 = time.time()
1306
- face_direct, face_diffuse, face_global = compute_solar_irradiance_for_all_faces(
1307
- face_centers,
1308
- face_normals,
1309
- face_svf,
1310
- sun_direction,
1311
- direct_normal_irradiance,
1312
- diffuse_irradiance,
1313
- voxel_data,
1314
- meshsize,
1315
- tree_k,
1316
- tree_lad,
1317
- hit_values,
1318
- inclusion_mode,
1319
- grid_bounds_real,
1320
- boundary_epsilon
1321
- )
1322
-
1323
- # Report performance timing if requested
1324
- if progress_report:
1325
- elapsed = time.time() - t0
1326
- print(f"Numba-based solar irradiance calculation took {elapsed:.2f} seconds")
1327
-
1328
- # Create a copy of the input mesh to store results
1329
- irradiance_mesh = building_svf_mesh.copy()
1330
- if not hasattr(irradiance_mesh, 'metadata'):
1331
- irradiance_mesh.metadata = {}
1332
-
1333
- # Store results
1334
- irradiance_mesh.metadata['svf'] = face_svf
1335
- irradiance_mesh.metadata['direct'] = face_direct
1336
- irradiance_mesh.metadata['diffuse'] = face_diffuse
1337
- irradiance_mesh.metadata['global'] = face_global
1338
-
1339
- irradiance_mesh.name = "Solar Irradiance (W/m²)"
1340
-
1341
- # # Optional OBJ export
1342
- # obj_export = kwargs.get("obj_export", False)
1343
- # if obj_export:
1344
- # # Get export parameters
1345
- # output_dir = kwargs.get("output_directory", "output")
1346
- # output_file_name = kwargs.get("output_file_name", "solar_irradiance")
1347
-
1348
- # # Export the mesh directly
1349
- # irradiance_mesh.export(f"{output_dir}/{output_file_name}.obj")
1350
-
1351
- return irradiance_mesh
1352
-
1353
- ##############################################################################
1354
- # 4) Modified get_cumulative_building_solar_irradiance
1355
- ##############################################################################
1356
- def get_cumulative_building_solar_irradiance(
1357
- voxel_data,
1358
- meshsize,
1359
- building_svf_mesh,
1360
- weather_df,
1361
- lon, lat, tz,
1362
- **kwargs
1363
- ):
1364
- """
1365
- Calculate cumulative solar irradiance on building surfaces over a time period.
1366
- Uses the Numba-accelerated get_building_solar_irradiance for each time step.
1367
-
1368
- Args:
1369
- voxel_data (ndarray): 3D array of voxel values.
1370
- meshsize (float): Size of each voxel in meters.
1371
- building_svf_mesh (trimesh.Trimesh): Mesh with pre-calculated SVF in metadata.
1372
- weather_df (DataFrame): Weather data with DNI (W/m²) and DHI (W/m²).
1373
- lon (float): Longitude in degrees.
1374
- lat (float): Latitude in degrees.
1375
- tz (float): Timezone offset in hours.
1376
- **kwargs: Additional parameters for time range, scaling, OBJ export, etc.
1377
-
1378
- Returns:
1379
- trimesh.Trimesh: A mesh with cumulative (Wh/m²) irradiance in metadata.
1380
- """
1381
- import pytz
1382
- from datetime import datetime
1383
-
1384
- period_start = kwargs.get("period_start", "01-01 00:00:00")
1385
- period_end = kwargs.get("period_end", "12-31 23:59:59")
1386
- time_step_hours = kwargs.get("time_step_hours", 1.0)
1387
- direct_normal_irradiance_scaling = kwargs.get("direct_normal_irradiance_scaling", 1.0)
1388
- diffuse_irradiance_scaling = kwargs.get("diffuse_irradiance_scaling", 1.0)
1389
-
1390
- # Parse times, create local tz
1391
- try:
1392
- start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
1393
- end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
1394
- except ValueError as ve:
1395
- raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
1396
-
1397
- offset_minutes = int(tz * 60)
1398
- local_tz = pytz.FixedOffset(offset_minutes)
1399
-
1400
- # Filter weather_df
1401
- df_period = weather_df[
1402
- ((weather_df.index.month > start_dt.month) |
1403
- ((weather_df.index.month == start_dt.month) &
1404
- (weather_df.index.day >= start_dt.day) &
1405
- (weather_df.index.hour >= start_dt.hour))) &
1406
- ((weather_df.index.month < end_dt.month) |
1407
- ((weather_df.index.month == end_dt.month) &
1408
- (weather_df.index.day <= end_dt.day) &
1409
- (weather_df.index.hour <= end_dt.hour)))
1410
- ]
1411
- if df_period.empty:
1412
- raise ValueError("No weather data in specified period.")
1413
-
1414
- # Convert to local time, then to UTC
1415
- df_period_local = df_period.copy()
1416
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
1417
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
1418
-
1419
- # Get solar positions
1420
- # You presumably have a get_solar_positions_astral(...) that returns az/elev
1421
- solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1422
-
1423
- # Prepare arrays for accumulation
1424
- n_faces = len(building_svf_mesh.faces)
1425
- face_cum_direct = np.zeros(n_faces, dtype=np.float64)
1426
- face_cum_diffuse = np.zeros(n_faces, dtype=np.float64)
1427
- face_cum_global = np.zeros(n_faces, dtype=np.float64)
1428
-
1429
- boundary_mask = None
1430
- instant_kwargs = kwargs.copy()
1431
- instant_kwargs['obj_export'] = False
1432
-
1433
- # Iterate over each timestep
1434
- for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
1435
- DNI = row['DNI'] * direct_normal_irradiance_scaling
1436
- DHI = row['DHI'] * diffuse_irradiance_scaling
1437
-
1438
- # Sun angles
1439
- az_deg = solar_positions.loc[time_utc, 'azimuth']
1440
- el_deg = solar_positions.loc[time_utc, 'elevation']
1441
-
1442
- # Skip if sun below horizon
1443
- if el_deg <= 0:
1444
- continue
1445
-
1446
- # Call instantaneous function (Numba-accelerated inside)
1447
- irr_mesh = get_building_solar_irradiance(
1448
- voxel_data,
1449
- meshsize,
1450
- building_svf_mesh,
1451
- az_deg,
1452
- el_deg,
1453
- DNI,
1454
- DHI,
1455
- show_plot=False, # or any other flags
1456
- **instant_kwargs
1457
- )
1458
-
1459
- # Extract arrays
1460
- face_dir = irr_mesh.metadata['direct']
1461
- face_diff = irr_mesh.metadata['diffuse']
1462
- face_glob = irr_mesh.metadata['global']
1463
-
1464
- # If first time, note boundary mask from NaNs
1465
- if boundary_mask is None:
1466
- boundary_mask = np.isnan(face_glob)
1467
-
1468
- # Convert from W/m² to Wh/m² by multiplying time_step_hours
1469
- face_cum_direct += np.nan_to_num(face_dir) * time_step_hours
1470
- face_cum_diffuse += np.nan_to_num(face_diff) * time_step_hours
1471
- face_cum_global += np.nan_to_num(face_glob) * time_step_hours
1472
-
1473
- # Reapply NaN for boundary
1474
- if boundary_mask is not None:
1475
- face_cum_direct[boundary_mask] = np.nan
1476
- face_cum_diffuse[boundary_mask] = np.nan
1477
- face_cum_global[boundary_mask] = np.nan
1478
-
1479
- # Create a new mesh with cumulative results
1480
- cumulative_mesh = building_svf_mesh.copy()
1481
- if not hasattr(cumulative_mesh, 'metadata'):
1482
- cumulative_mesh.metadata = {}
1483
-
1484
- # If original mesh had SVF
1485
- if 'svf' in building_svf_mesh.metadata:
1486
- cumulative_mesh.metadata['svf'] = building_svf_mesh.metadata['svf']
1487
-
1488
- cumulative_mesh.metadata['direct'] = face_cum_direct
1489
- cumulative_mesh.metadata['diffuse'] = face_cum_diffuse
1490
- cumulative_mesh.metadata['global'] = face_cum_global
1491
-
1492
- cumulative_mesh.name = "Cumulative Solar Irradiance (Wh/m²)"
1493
-
1494
- # # Optional OBJ export
1495
- # obj_export = kwargs.get("obj_export", False)
1496
- # if obj_export:
1497
- # # Get export parameters
1498
- # output_dir = kwargs.get("output_directory", "output")
1499
- # output_file_name = kwargs.get("output_file_name", "solar_irradiance")
1500
-
1501
- # # Export the mesh directly
1502
- # irradiance_mesh.export(f"{output_dir}/{output_file_name}.obj")
1503
-
1504
- return cumulative_mesh
1505
-
1506
- def get_building_global_solar_irradiance_using_epw(
1507
- voxel_data,
1508
- meshsize,
1509
- calc_type='instantaneous',
1510
- direct_normal_irradiance_scaling=1.0,
1511
- diffuse_irradiance_scaling=1.0,
1512
- **kwargs
1513
- ):
1514
- """
1515
- Compute global solar irradiance on building surfaces using EPW weather data, either for a single time or cumulatively.
1516
-
1517
- The function:
1518
- 1. Optionally downloads and reads EPW weather data
1519
- 2. Handles timezone conversions and solar position calculations
1520
- 3. Computes either instantaneous or cumulative irradiance on building surfaces
1521
- 4. Supports visualization and export options
1522
-
1523
- Args:
1524
- voxel_data (ndarray): 3D array of voxel values.
1525
- meshsize (float): Size of each voxel in meters.
1526
- building_svf_mesh (trimesh.Trimesh): Building mesh with pre-calculated SVF values in metadata.
1527
- calc_type (str): 'instantaneous' or 'cumulative'.
1528
- direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
1529
- diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
1530
- **kwargs: Additional arguments including:
1531
- - download_nearest_epw (bool): Whether to download nearest EPW file
1532
- - epw_file_path (str): Path to EPW file
1533
- - rectangle_vertices (list): List of (lon,lat) coordinates for EPW download
1534
- - output_dir (str): Directory for EPW download
1535
- - calc_time (str): Time for instantaneous calculation ('MM-DD HH:MM:SS')
1536
- - period_start (str): Start time for cumulative calculation ('MM-DD HH:MM:SS')
1537
- - period_end (str): End time for cumulative calculation ('MM-DD HH:MM:SS')
1538
- - time_step_hours (float): Time step for cumulative calculation
1539
- - tree_k (float): Tree extinction coefficient
1540
- - tree_lad (float): Leaf area density in m^-1
1541
- - show_each_timestep (bool): Whether to show plots for each timestep
1542
- - nan_color (str): Color for NaN values in visualization
1543
- - colormap (str): Matplotlib colormap name
1544
- - vmin (float): Minimum value for colormap
1545
- - vmax (float): Maximum value for colormap
1546
- - obj_export (bool): Whether to export as OBJ file
1547
- - output_directory (str): Directory for OBJ export
1548
- - output_file_name (str): Filename for OBJ export
1549
- - save_mesh (bool): Whether to save the mesh data using pickle
1550
- - mesh_output_path (str): Path to save the mesh data (if save_mesh is True)
1551
-
1552
- Returns:
1553
- trimesh.Trimesh: Building mesh with irradiance values stored in metadata.
1554
- """
1555
- import numpy as np
1556
- import pytz
1557
- from datetime import datetime
1558
-
1559
- # Get EPW file
1560
- download_nearest_epw = kwargs.get("download_nearest_epw", False)
1561
- rectangle_vertices = kwargs.get("rectangle_vertices", None)
1562
- epw_file_path = kwargs.get("epw_file_path", None)
1563
- building_id_grid = kwargs.get("building_id_grid", None)
1564
-
1565
- if download_nearest_epw:
1566
- if rectangle_vertices is None:
1567
- print("rectangle_vertices is required to download nearest EPW file")
1568
- return None
1569
- else:
1570
- # Calculate center point of rectangle
1571
- lons = [coord[0] for coord in rectangle_vertices]
1572
- lats = [coord[1] for coord in rectangle_vertices]
1573
- center_lon = (min(lons) + max(lons)) / 2
1574
- center_lat = (min(lats) + max(lats)) / 2
1575
-
1576
- # Optional: specify maximum distance in kilometers
1577
- max_distance = kwargs.get("max_distance", 100) # None for no limit
1578
- output_dir = kwargs.get("output_dir", "output")
1579
-
1580
- epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
1581
- longitude=center_lon,
1582
- latitude=center_lat,
1583
- output_dir=output_dir,
1584
- max_distance=max_distance,
1585
- extract_zip=True,
1586
- load_data=True
1587
- )
1588
-
1589
- # Read EPW data
1590
- df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
1591
- if df.empty:
1592
- raise ValueError("No data in EPW file.")
1593
-
1594
- # Step 1: Calculate Sky View Factor for building surfaces
1595
- print(f"Processing Sky View Factor for building surfaces...")
1596
- building_svf_mesh = get_surface_view_factor(
1597
- voxel_data, # Your 3D voxel grid
1598
- meshsize, # Size of each voxel in meters
1599
- value_name = 'svf',
1600
- target_values = (0,),
1601
- inclusion_mode = False,
1602
- building_id_grid=building_id_grid,
1603
- )
1604
-
1605
- print(f"Processing Solar Irradiance for building surfaces...")
1606
- result_mesh = None
1607
-
1608
- if calc_type == 'instantaneous':
1609
- calc_time = kwargs.get("calc_time", "01-01 12:00:00")
1610
-
1611
- # Parse calculation time without year
1612
- try:
1613
- calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
1614
- except ValueError as ve:
1615
- raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
1616
-
1617
- df_period = df[
1618
- (df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
1619
- ]
1620
-
1621
- if df_period.empty:
1622
- raise ValueError("No EPW data at the specified time.")
1623
-
1624
- # Prepare timezone conversion
1625
- offset_minutes = int(tz * 60)
1626
- local_tz = pytz.FixedOffset(offset_minutes)
1627
- df_period_local = df_period.copy()
1628
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
1629
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
1630
-
1631
- # Compute solar positions
1632
- solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1633
-
1634
- # Scale irradiance values
1635
- direct_normal_irradiance = df_period_utc.iloc[0]['DNI'] * direct_normal_irradiance_scaling
1636
- diffuse_irradiance = df_period_utc.iloc[0]['DHI'] * diffuse_irradiance_scaling
1637
-
1638
- # Get solar position
1639
- azimuth_degrees = solar_positions.iloc[0]['azimuth']
1640
- elevation_degrees = solar_positions.iloc[0]['elevation']
1641
-
1642
- print(f"Time: {df_period_local.index[0].strftime('%Y-%m-%d %H:%M:%S')}")
1643
- print(f"Sun position: Azimuth {azimuth_degrees:.1f}°, Elevation {elevation_degrees:.1f}°")
1644
- print(f"DNI: {direct_normal_irradiance:.1f} W/m², DHI: {diffuse_irradiance:.1f} W/m²")
1645
-
1646
- # Skip if sun is below horizon
1647
- if elevation_degrees <= 0:
1648
- print("Sun is below horizon, skipping calculation.")
1649
- result_mesh = building_svf_mesh.copy()
1650
- else:
1651
- # Compute irradiance
1652
- result_mesh = get_building_solar_irradiance(
1653
- voxel_data,
1654
- meshsize,
1655
- building_svf_mesh,
1656
- azimuth_degrees,
1657
- elevation_degrees,
1658
- direct_normal_irradiance,
1659
- diffuse_irradiance,
1660
- **kwargs
1661
- )
1662
-
1663
- elif calc_type == 'cumulative':
1664
- # Set default parameters
1665
- period_start = kwargs.get("period_start", "01-01 00:00:00")
1666
- period_end = kwargs.get("period_end", "12-31 23:59:59")
1667
- time_step_hours = kwargs.get("time_step_hours", 1.0)
1668
-
1669
- # Parse start and end times without year
1670
- try:
1671
- start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
1672
- end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
1673
- except ValueError as ve:
1674
- raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
1675
-
1676
- # Create local timezone
1677
- offset_minutes = int(tz * 60)
1678
- local_tz = pytz.FixedOffset(offset_minutes)
1679
-
1680
- # Filter weather data by month, day, hour
1681
- df_period = df[
1682
- ((df.index.month > start_dt.month) |
1683
- ((df.index.month == start_dt.month) & (df.index.day >= start_dt.day) &
1684
- (df.index.hour >= start_dt.hour))) &
1685
- ((df.index.month < end_dt.month) |
1686
- ((df.index.month == end_dt.month) & (df.index.day <= end_dt.day) &
1687
- (df.index.hour <= end_dt.hour)))
1688
- ]
1689
-
1690
- if df_period.empty:
1691
- raise ValueError("No weather data available for the specified period.")
1692
-
1693
- # Convert to local timezone and then to UTC for solar position calculation
1694
- df_period_local = df_period.copy()
1695
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
1696
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
1697
-
1698
- # Get solar positions for all times
1699
- solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1700
-
1701
- # Create a copy of kwargs without time_step_hours to avoid duplicate argument
1702
- kwargs_copy = kwargs.copy()
1703
- if 'time_step_hours' in kwargs_copy:
1704
- del kwargs_copy['time_step_hours']
1705
-
1706
- # Get cumulative irradiance - adapt to match expected function signature
1707
- result_mesh = get_cumulative_building_solar_irradiance(
1708
- voxel_data,
1709
- meshsize,
1710
- building_svf_mesh,
1711
- df, lon, lat, tz, # Pass only the required 7 positional arguments
1712
- period_start=period_start,
1713
- period_end=period_end,
1714
- time_step_hours=time_step_hours,
1715
- direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
1716
- diffuse_irradiance_scaling=diffuse_irradiance_scaling,
1717
- colormap=kwargs.get('colormap', 'jet'),
1718
- show_each_timestep=kwargs.get('show_each_timestep', False),
1719
- obj_export=kwargs.get('obj_export', False),
1720
- output_directory=kwargs.get('output_directory', 'output'),
1721
- output_file_name=kwargs.get('output_file_name', 'cumulative_solar')
1722
- )
1723
-
1724
- else:
1725
- raise ValueError("calc_type must be either 'instantaneous' or 'cumulative'")
1726
-
1727
- # Save mesh data if requested
1728
- save_mesh = kwargs.get("save_mesh", False)
1729
- if save_mesh:
1730
- mesh_output_path = kwargs.get("mesh_output_path", None)
1731
- if mesh_output_path is None:
1732
- # Generate default path if none provided
1733
- output_directory = kwargs.get("output_directory", "output")
1734
- output_file_name = kwargs.get("output_file_name", f"{calc_type}_solar_irradiance")
1735
- mesh_output_path = f"{output_directory}/{output_file_name}.pkl"
1736
-
1737
- save_irradiance_mesh(result_mesh, mesh_output_path)
1738
- print(f"Saved irradiance mesh data to: {mesh_output_path}")
1739
-
1740
- return result_mesh
1741
-
1742
- def save_irradiance_mesh(irradiance_mesh, output_file_path):
1743
- """
1744
- Save the irradiance mesh data to a file using pickle serialization.
1745
-
1746
- This function provides persistent storage for computed irradiance results,
1747
- enabling reuse of expensive calculations and sharing of results between
1748
- analysis sessions. The mesh data includes all geometry, irradiance values,
1749
- and metadata required for visualization and further analysis.
1750
-
1751
- Serialization Benefits:
1752
- - Preserves complete mesh structure with all computed data
1753
- - Enables offline analysis and visualization workflows
1754
- - Supports sharing results between different tools and users
1755
- - Avoids recomputation of expensive irradiance calculations
1756
-
1757
- Data Preservation:
1758
- - All mesh geometry (vertices, faces, normals)
1759
- - Computed irradiance values (direct, diffuse, global)
1760
- - Sky View Factor data and other metadata
1761
- - Material properties and visualization settings
1762
-
1763
- Args:
1764
- irradiance_mesh (trimesh.Trimesh): Mesh with irradiance data in metadata
1765
- Should contain computed irradiance results
1766
- output_file_path (str): Path to save the mesh data file
1767
- Recommended extension: .pkl for clarity
1768
-
1769
- Note:
1770
- The function automatically creates the output directory if it doesn't exist.
1771
- Use pickle format for maximum compatibility with Python data structures.
1772
- """
1773
- import pickle
1774
- import os
1775
-
1776
- # Create output directory structure if it doesn't exist
1777
- os.makedirs(os.path.dirname(output_file_path), exist_ok=True)
1778
-
1779
- # Serialize mesh data using pickle for complete data preservation
1780
- with open(output_file_path, 'wb') as f:
1781
- pickle.dump(irradiance_mesh, f)
1782
-
1783
- def load_irradiance_mesh(input_file_path):
1784
- """
1785
- Load previously saved irradiance mesh data from a file.
1786
-
1787
- This function restores complete mesh data including geometry, computed
1788
- irradiance values, and all associated metadata. It enables continuation
1789
- of analysis workflows and reuse of expensive computation results.
1790
-
1791
- Restoration Capabilities:
1792
- - Complete mesh geometry with all topological information
1793
- - All computed irradiance data (direct, diffuse, global components)
1794
- - Sky View Factor values and analysis metadata
1795
- - Visualization settings and material properties
1796
-
1797
- Workflow Integration:
1798
- - Load results from previous analysis sessions
1799
- - Share computed data between team members
1800
- - Perform post-processing and visualization
1801
- - Compare results from different scenarios
1802
-
1803
- Args:
1804
- input_file_path (str): Path to the saved mesh data file
1805
- Should be a file created by save_irradiance_mesh()
1806
-
1807
- Returns:
1808
- trimesh.Trimesh: Complete mesh with all irradiance data in metadata
1809
- Ready for visualization, analysis, or further processing
1810
-
1811
- Note:
1812
- The loaded mesh maintains all original data structure and can be used
1813
- immediately for visualization or additional analysis operations.
1814
- """
1815
- import pickle
1816
-
1817
- # Deserialize mesh data preserving all original structure
1818
- with open(input_file_path, 'rb') as f:
1819
- irradiance_mesh = pickle.load(f)
1820
-
1
+ """
2
+ Solar Irradiance Simulation Module
3
+
4
+ This module provides comprehensive solar irradiance calculations for urban environments,
5
+ including direct and diffuse solar radiation analysis with consideration for tree transmittance
6
+ and building shadows. It supports both instantaneous and cumulative irradiance calculations
7
+ using weather data from EPW files.
8
+
9
+ Key Features:
10
+ - Direct solar irradiance with ray tracing and shadow analysis
11
+ - Diffuse solar irradiance using Sky View Factor (SVF)
12
+ - Tree transmittance modeling using Beer-Lambert law
13
+ - Building surface irradiance calculation with 3D mesh support
14
+ - Weather data integration from EPW files
15
+ - Visualization and export capabilities
16
+
17
+ The module uses numba for performance optimization in computationally intensive calculations.
18
+ """
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+ import matplotlib.pyplot as plt
23
+ from numba import njit, prange
24
+ import os
25
+ import numba
26
+ from datetime import datetime, timezone
27
+ import pytz
28
+ from astral import Observer
29
+ from astral.sun import elevation, azimuth
30
+
31
+ # Import custom modules for view analysis and weather data processing
32
+ from .view import trace_ray_generic, compute_vi_map_generic, get_sky_view_factor_map, get_surface_view_factor
33
+ from ..utils.weather import get_nearest_epw_from_climate_onebuilding, read_epw_for_solar_simulation
34
+ from ..exporter.obj import grid_to_obj, export_obj
35
+
36
+ @njit(parallel=True)
37
+ def compute_direct_solar_irradiance_map_binary(voxel_data, sun_direction, view_point_height, hit_values, meshsize, tree_k, tree_lad, inclusion_mode):
38
+ """
39
+ Compute a map of direct solar irradiation accounting for tree transmittance.
40
+
41
+ This function performs ray tracing from observer positions on the ground surface
42
+ towards the sun to determine direct solar irradiance at each location. It accounts
43
+ for shadows cast by buildings and vegetation, with special consideration for
44
+ tree transmittance using the Beer-Lambert law.
45
+
46
+ The function:
47
+ 1. Places observers at valid locations (empty voxels above ground)
48
+ 2. Casts rays from each observer in the sun direction
49
+ 3. Computes transmittance through trees using Beer-Lambert law
50
+ 4. Returns a 2D map of transmittance values
51
+
52
+ Observer Placement Rules:
53
+ - Observers are placed in empty voxels (value 0 or -2 for trees) above solid ground
54
+ - Observers are NOT placed on buildings, vegetation, or water surfaces
55
+ - Observer height is added above the detected ground surface
56
+
57
+ Ray Tracing Process:
58
+ - Rays are cast from each valid observer position toward the sun
59
+ - Intersections with obstacles (non-sky voxels) are detected
60
+ - Tree voxels provide partial transmittance rather than complete blocking
61
+ - Final transmittance value represents solar energy reaching the surface
62
+
63
+ Args:
64
+ voxel_data (ndarray): 3D array of voxel values representing the urban environment.
65
+ Common values: 0=sky, 1-6=buildings, 7-9=special surfaces, -2=trees
66
+ sun_direction (tuple): Direction vector of the sun (dx, dy, dz), should be normalized
67
+ view_point_height (float): Observer height above ground surface in meters
68
+ hit_values (tuple): Values considered non-obstacles if inclusion_mode=False
69
+ Typically (0,) meaning only sky voxels are transparent
70
+ meshsize (float): Size of each voxel in meters (spatial resolution)
71
+ tree_k (float): Tree extinction coefficient for Beer-Lambert law (higher = more opaque)
72
+ tree_lad (float): Leaf area density in m^-1 (affects light attenuation through trees)
73
+ inclusion_mode (bool): False here, meaning any voxel not in hit_values is an obstacle
74
+
75
+ Returns:
76
+ ndarray: 2D array of transmittance values (0.0-1.0)
77
+ - 1.0 = full sun exposure
78
+ - 0.0 = complete shadow
79
+ - 0.0-1.0 = partial transmittance through trees
80
+ - NaN = invalid observer position (cannot place observer)
81
+
82
+ Note:
83
+ The returned map is vertically flipped to match standard visualization conventions
84
+ where the origin is at the bottom-left corner.
85
+ """
86
+
87
+ # Convert observer height from meters to voxel units
88
+ view_height_voxel = int(view_point_height / meshsize)
89
+
90
+ # Get dimensions of the voxel grid
91
+ nx, ny, nz = voxel_data.shape
92
+
93
+ # Initialize irradiance map with NaN (invalid positions)
94
+ irradiance_map = np.full((nx, ny), np.nan, dtype=np.float64)
95
+
96
+ # Normalize sun direction vector for consistent ray tracing
97
+ # This ensures rays travel at unit speed through the voxel grid
98
+ sd = np.array(sun_direction, dtype=np.float64)
99
+ sd_len = np.sqrt(sd[0]**2 + sd[1]**2 + sd[2]**2)
100
+ if sd_len == 0.0:
101
+ return np.flipud(irradiance_map)
102
+ sd /= sd_len
103
+
104
+ # Process each x,y position in parallel for performance
105
+ # This is the main computational loop optimized with numba
106
+ for x in prange(nx):
107
+ for y in range(ny):
108
+ found_observer = False
109
+
110
+ # Search upward through the vertical column to find valid observer position
111
+ # Start from z=1 to ensure we can check the voxel below
112
+ for z in range(1, nz):
113
+
114
+ # Check if current voxel is empty/tree and voxel below is solid
115
+ # This identifies the ground surface where observers can be placed
116
+ if voxel_data[x, y, z] in (0, -2) and voxel_data[x, y, z - 1] not in (0, -2):
117
+
118
+ # Skip if standing on building/vegetation/water surfaces
119
+ # These are considered invalid observer locations
120
+ if (voxel_data[x, y, z - 1] in (7, 8, 9)) or (voxel_data[x, y, z - 1] < 0):
121
+ irradiance_map[x, y] = np.nan
122
+ found_observer = True
123
+ break
124
+ else:
125
+ # Place observer at valid ground location and cast ray toward sun
126
+ observer_location = np.array([x, y, z + view_height_voxel], dtype=np.float64)
127
+
128
+ # Trace ray from observer to sun, accounting for obstacles and tree transmittance
129
+ hit, transmittance = trace_ray_generic(voxel_data, observer_location, sd,
130
+ hit_values, meshsize, tree_k, tree_lad, inclusion_mode)
131
+
132
+ # Store transmittance value (0 if hit solid obstacle, 0-1 if through trees)
133
+ irradiance_map[x, y] = transmittance if not hit else 0.0
134
+ found_observer = True
135
+ break
136
+
137
+ # If no valid observer position found in this column, mark as invalid
138
+ if not found_observer:
139
+ irradiance_map[x, y] = np.nan
140
+
141
+ # Flip map vertically to match visualization conventions (origin at bottom-left)
142
+ return np.flipud(irradiance_map)
143
+
144
+ def get_direct_solar_irradiance_map(voxel_data, meshsize, azimuth_degrees_ori, elevation_degrees,
145
+ direct_normal_irradiance, show_plot=False, **kwargs):
146
+ """
147
+ Compute direct solar irradiance map with tree transmittance.
148
+
149
+ This function converts solar angles to a 3D direction vector, computes the binary
150
+ transmittance map using ray tracing, and scales the results by actual solar irradiance
151
+ values to produce physically meaningful irradiance measurements.
152
+
153
+ Solar Geometry:
154
+ - Azimuth: Horizontal angle measured from North (0°) clockwise to East (90°)
155
+ - Elevation: Vertical angle above the horizon (0° = horizon, 90° = zenith)
156
+ - The coordinate system is adjusted by 180° to match the voxel grid orientation
157
+
158
+ Physics Background:
159
+ - Direct Normal Irradiance (DNI): Solar energy on a surface perpendicular to sun rays
160
+ - Horizontal irradiance: DNI scaled by sine of elevation angle
161
+ - Tree transmittance: Applied using Beer-Lambert law for realistic light attenuation
162
+
163
+ The function:
164
+ 1. Converts sun angles to direction vector using spherical coordinates
165
+ 2. Computes binary transmittance map accounting for shadows and tree effects
166
+ 3. Scales by direct normal irradiance and sun elevation for horizontal surfaces
167
+ 4. Optionally visualizes and exports results in various formats
168
+
169
+ Args:
170
+ voxel_data (ndarray): 3D array of voxel values representing the urban environment
171
+ meshsize (float): Size of each voxel in meters (spatial resolution)
172
+ azimuth_degrees_ori (float): Sun azimuth angle in degrees ( = North, 90° = East)
173
+ elevation_degrees (float): Sun elevation angle in degrees above horizon (0-90°)
174
+ direct_normal_irradiance (float): Direct normal irradiance in W/m² (from weather data)
175
+ show_plot (bool): Whether to display visualization of results
176
+ **kwargs: Additional arguments including:
177
+ - view_point_height (float): Observer height in meters (default: 1.5)
178
+ Height above ground where irradiance is measured
179
+ - colormap (str): Matplotlib colormap name for visualization (default: 'magma')
180
+ - vmin (float): Minimum value for colormap scaling
181
+ - vmax (float): Maximum value for colormap scaling
182
+ - tree_k (float): Tree extinction coefficient (default: 0.6)
183
+ Higher values mean trees block more light
184
+ - tree_lad (float): Leaf area density in m^-1 (default: 1.0)
185
+ Affects light attenuation through tree canopies
186
+ - obj_export (bool): Whether to export results as 3D OBJ file
187
+ - output_directory (str): Directory for file exports
188
+ - output_file_name (str): Base filename for exports
189
+ - dem_grid (ndarray): Digital elevation model for 3D export
190
+ - num_colors (int): Number of discrete colors for OBJ export
191
+ - alpha (float): Transparency value for 3D visualization
192
+
193
+ Returns:
194
+ ndarray: 2D array of direct solar irradiance values in W/m²
195
+ - Values represent energy flux on horizontal surfaces
196
+ - NaN indicates invalid measurement locations
197
+ - Range typically 0 to direct_normal_irradiance * sin(elevation)
198
+
199
+ Note:
200
+ The azimuth is internally adjusted by 180° to match the coordinate system
201
+ where the voxel grid's y-axis points in the opposite direction from geographic north.
202
+ """
203
+ # Extract parameters with defaults for observer and visualization settings
204
+ view_point_height = kwargs.get("view_point_height", 1.5)
205
+ colormap = kwargs.get("colormap", 'magma')
206
+ vmin = kwargs.get("vmin", 0.0)
207
+ vmax = kwargs.get("vmax", direct_normal_irradiance)
208
+
209
+ # Get tree transmittance parameters for Beer-Lambert law calculations
210
+ tree_k = kwargs.get("tree_k", 0.6)
211
+ tree_lad = kwargs.get("tree_lad", 1.0)
212
+
213
+ # Convert sun angles to 3D direction vector using spherical coordinates
214
+ # Note: azimuth is adjusted by 180° to match coordinate system orientation
215
+ azimuth_degrees = 180 - azimuth_degrees_ori
216
+ azimuth_radians = np.deg2rad(azimuth_degrees)
217
+ elevation_radians = np.deg2rad(elevation_degrees)
218
+
219
+ # Calculate direction vector components
220
+ # dx, dy: horizontal components, dz: vertical component (upward positive)
221
+ dx = np.cos(elevation_radians) * np.cos(azimuth_radians)
222
+ dy = np.cos(elevation_radians) * np.sin(azimuth_radians)
223
+ dz = np.sin(elevation_radians)
224
+ sun_direction = (dx, dy, dz)
225
+
226
+ # Define obstacle detection parameters for ray tracing
227
+ # All non-zero voxels are obstacles except for trees which have transmittance
228
+ hit_values = (0,) # Only sky voxels (value 0) are transparent
229
+ inclusion_mode = False # Values NOT in hit_values are considered obstacles
230
+
231
+ # Compute transmittance map using optimized ray tracing
232
+ transmittance_map = compute_direct_solar_irradiance_map_binary(
233
+ voxel_data, sun_direction, view_point_height, hit_values,
234
+ meshsize, tree_k, tree_lad, inclusion_mode
235
+ )
236
+
237
+ # Scale transmittance by solar irradiance and geometry
238
+ # For horizontal surfaces: multiply by sine of elevation angle
239
+ sin_elev = dz
240
+ direct_map = transmittance_map * direct_normal_irradiance * sin_elev
241
+
242
+ # Optional visualization of results
243
+ if show_plot:
244
+ # Set up colormap with special handling for invalid data
245
+ cmap = plt.cm.get_cmap(colormap).copy()
246
+ cmap.set_bad(color='lightgray') # NaN values shown in gray
247
+
248
+ plt.figure(figsize=(10, 8))
249
+ # plt.title("Horizontal Direct Solar Irradiance Map (0° = North)")
250
+ plt.imshow(direct_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
251
+ plt.colorbar(label='Direct Solar Irradiance (W/m²)')
252
+ plt.axis('off')
253
+ plt.show()
254
+
255
+ # Optional export to 3D OBJ format for external visualization
256
+ obj_export = kwargs.get("obj_export", False)
257
+ if obj_export:
258
+ # Get export parameters with defaults
259
+ dem_grid = kwargs.get("dem_grid", np.zeros_like(direct_map))
260
+ output_dir = kwargs.get("output_directory", "output")
261
+ output_file_name = kwargs.get("output_file_name", "direct_solar_irradiance")
262
+ num_colors = kwargs.get("num_colors", 10)
263
+ alpha = kwargs.get("alpha", 1.0)
264
+
265
+ # Export as colored 3D mesh
266
+ grid_to_obj(
267
+ direct_map,
268
+ dem_grid,
269
+ output_dir,
270
+ output_file_name,
271
+ meshsize,
272
+ view_point_height,
273
+ colormap_name=colormap,
274
+ num_colors=num_colors,
275
+ alpha=alpha,
276
+ vmin=vmin,
277
+ vmax=vmax
278
+ )
279
+
280
+ return direct_map
281
+
282
+ def get_diffuse_solar_irradiance_map(voxel_data, meshsize, diffuse_irradiance=1.0, show_plot=False, **kwargs):
283
+ """
284
+ Compute diffuse solar irradiance map using the Sky View Factor (SVF) with tree transmittance.
285
+
286
+ This function calculates the diffuse component of solar radiation, which consists of
287
+ sunlight scattered by the atmosphere and reaches surfaces from all directions across
288
+ the sky hemisphere. The calculation is based on the Sky View Factor (SVF), which
289
+ quantifies how much of the sky dome is visible from each location.
290
+
291
+ Physics Background:
292
+ - Diffuse radiation: Solar energy scattered by atmospheric particles and clouds
293
+ - Sky View Factor (SVF): Fraction of sky hemisphere visible from a point (0.0 to 1.0)
294
+ - Isotropic sky model: Assumes uniform diffuse radiation distribution across the sky
295
+ - Tree effects: Partial transmittance through canopies reduces effective sky visibility
296
+
297
+ SVF Characteristics:
298
+ - SVF = 1.0: Completely open sky (maximum diffuse radiation)
299
+ - SVF = 0.0: Completely blocked sky (no diffuse radiation)
300
+ - SVF = 0.5: Half of sky visible (typical for urban canyons)
301
+ - Trees reduce SVF through partial light attenuation rather than complete blocking
302
+
303
+ The function:
304
+ 1. Computes SVF map accounting for building shadows and tree transmittance
305
+ 2. Scales SVF by diffuse horizontal irradiance from weather data
306
+ 3. Optionally visualizes and exports results for analysis
307
+
308
+ Args:
309
+ voxel_data (ndarray): 3D array of voxel values representing the urban environment
310
+ meshsize (float): Size of each voxel in meters (spatial resolution)
311
+ diffuse_irradiance (float): Diffuse horizontal irradiance in W/m² (from weather data)
312
+ Default 1.0 for normalized calculations
313
+ show_plot (bool): Whether to display visualization of results
314
+ **kwargs: Additional arguments including:
315
+ - view_point_height (float): Observer height in meters (default: 1.5)
316
+ Height above ground where measurements are taken
317
+ - colormap (str): Matplotlib colormap name for visualization (default: 'magma')
318
+ - vmin (float): Minimum value for colormap scaling
319
+ - vmax (float): Maximum value for colormap scaling
320
+ - tree_k (float): Tree extinction coefficient for transmittance calculations
321
+ Higher values mean trees block more diffuse light
322
+ - tree_lad (float): Leaf area density in m^-1
323
+ Affects light attenuation through tree canopies
324
+ - obj_export (bool): Whether to export results as 3D OBJ file
325
+ - output_directory (str): Directory for file exports
326
+ - output_file_name (str): Base filename for exports
327
+ - dem_grid (ndarray): Digital elevation model for 3D export
328
+ - num_colors (int): Number of discrete colors for OBJ export
329
+ - alpha (float): Transparency value for 3D visualization
330
+
331
+ Returns:
332
+ ndarray: 2D array of diffuse solar irradiance values in W/m²
333
+ - Values represent diffuse energy flux on horizontal surfaces
334
+ - Range: 0.0 to diffuse_irradiance (input parameter)
335
+ - NaN indicates invalid measurement locations
336
+
337
+ Note:
338
+ The SVF calculation internally handles tree transmittance effects, so trees
339
+ contribute partial sky visibility rather than complete obstruction.
340
+ """
341
+
342
+ # Extract parameters with defaults for observer and visualization settings
343
+ view_point_height = kwargs.get("view_point_height", 1.5)
344
+ colormap = kwargs.get("colormap", 'magma')
345
+ vmin = kwargs.get("vmin", 0.0)
346
+ vmax = kwargs.get("vmax", diffuse_irradiance)
347
+
348
+ # Prepare parameters for SVF calculation with appropriate visualization settings
349
+ # Pass tree transmittance parameters to SVF calculation
350
+ svf_kwargs = kwargs.copy()
351
+ svf_kwargs["colormap"] = "BuPu_r" # Purple colormap for SVF visualization
352
+ svf_kwargs["vmin"] = 0 # SVF ranges from 0 to 1
353
+ svf_kwargs["vmax"] = 1
354
+
355
+ # Calculate Sky View Factor map accounting for all obstructions
356
+ # SVF calculation now handles tree transmittance internally
357
+ SVF_map = get_sky_view_factor_map(voxel_data, meshsize, **svf_kwargs)
358
+
359
+ # Convert SVF to diffuse irradiance by scaling with weather data
360
+ # Each location receives diffuse radiation proportional to its sky visibility
361
+ diffuse_map = SVF_map * diffuse_irradiance
362
+
363
+ # Optional visualization of diffuse irradiance results
364
+ if show_plot:
365
+ # Use parameters from kwargs for consistent visualization
366
+ vmin = kwargs.get("vmin", 0.0)
367
+ vmax = kwargs.get("vmax", diffuse_irradiance)
368
+
369
+ # Set up colormap with special handling for invalid data
370
+ cmap = plt.cm.get_cmap(colormap).copy()
371
+ cmap.set_bad(color='lightgray') # NaN values shown in gray
372
+
373
+ plt.figure(figsize=(10, 8))
374
+ # plt.title("Diffuse Solar Irradiance Map")
375
+ plt.imshow(diffuse_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
376
+ plt.colorbar(label='Diffuse Solar Irradiance (W/m²)')
377
+ plt.axis('off')
378
+ plt.show()
379
+
380
+ # Optional export to 3D OBJ format for external visualization
381
+ obj_export = kwargs.get("obj_export", False)
382
+ if obj_export:
383
+ # Get export parameters with defaults
384
+ dem_grid = kwargs.get("dem_grid", np.zeros_like(diffuse_map))
385
+ output_dir = kwargs.get("output_directory", "output")
386
+ output_file_name = kwargs.get("output_file_name", "diffuse_solar_irradiance")
387
+ num_colors = kwargs.get("num_colors", 10)
388
+ alpha = kwargs.get("alpha", 1.0)
389
+
390
+ # Export as colored 3D mesh
391
+ grid_to_obj(
392
+ diffuse_map,
393
+ dem_grid,
394
+ output_dir,
395
+ output_file_name,
396
+ meshsize,
397
+ view_point_height,
398
+ colormap_name=colormap,
399
+ num_colors=num_colors,
400
+ alpha=alpha,
401
+ vmin=vmin,
402
+ vmax=vmax
403
+ )
404
+
405
+ return diffuse_map
406
+
407
+
408
+ def get_global_solar_irradiance_map(
409
+ voxel_data,
410
+ meshsize,
411
+ azimuth_degrees,
412
+ elevation_degrees,
413
+ direct_normal_irradiance,
414
+ diffuse_irradiance,
415
+ show_plot=False,
416
+ **kwargs
417
+ ):
418
+ """
419
+ Compute global solar irradiance (direct + diffuse) on a horizontal plane at each valid observer location.
420
+
421
+ This function combines both direct and diffuse components of solar radiation to calculate
422
+ the total solar irradiance at each location. Global horizontal irradiance (GHI) is the
423
+ most commonly used metric for solar energy assessment and represents the total solar
424
+ energy available on a horizontal surface.
425
+
426
+ Global Irradiance Components:
427
+ - Direct component: Solar radiation from the sun's disk, affected by shadows and obstacles
428
+ - Diffuse component: Solar radiation scattered by the atmosphere, affected by sky view
429
+ - Total irradiance: Sum of direct and diffuse components at each location
430
+
431
+ Physical Considerations:
432
+ - Direct radiation varies with sun position and local obstructions
433
+ - Diffuse radiation varies with sky visibility (Sky View Factor)
434
+ - Both components are affected by tree transmittance using Beer-Lambert law
435
+ - Invalid locations (e.g., on water, buildings) are marked as NaN
436
+
437
+ The function:
438
+ 1. Computes direct solar irradiance map accounting for sun position and shadows
439
+ 2. Computes diffuse solar irradiance map based on Sky View Factor
440
+ 3. Combines maps and optionally visualizes/exports results for analysis
441
+
442
+ Args:
443
+ voxel_data (ndarray): 3D voxel array representing the urban environment
444
+ meshsize (float): Voxel size in meters (spatial resolution)
445
+ azimuth_degrees (float): Sun azimuth angle in degrees ( = North, 90° = East)
446
+ elevation_degrees (float): Sun elevation angle in degrees above horizon (0-90°)
447
+ direct_normal_irradiance (float): Direct normal irradiance in W/m² (from weather data)
448
+ diffuse_irradiance (float): Diffuse horizontal irradiance in W/m² (from weather data)
449
+ show_plot (bool): Whether to display visualization of results
450
+ **kwargs: Additional arguments including:
451
+ - view_point_height (float): Observer height in meters (default: 1.5)
452
+ Height above ground where measurements are taken
453
+ - colormap (str): Matplotlib colormap name for visualization (default: 'magma')
454
+ - vmin (float): Minimum value for colormap scaling
455
+ - vmax (float): Maximum value for colormap scaling
456
+ - tree_k (float): Tree extinction coefficient for transmittance calculations
457
+ Higher values mean trees block more light
458
+ - tree_lad (float): Leaf area density in m^-1
459
+ Affects light attenuation through tree canopies
460
+ - obj_export (bool): Whether to export results as 3D OBJ file
461
+ - output_directory (str): Directory for file exports
462
+ - output_file_name (str): Base filename for exports
463
+ - dem_grid (ndarray): Digital elevation model for 3D export
464
+ - num_colors (int): Number of discrete colors for OBJ export
465
+ - alpha (float): Transparency value for 3D visualization
466
+
467
+ Returns:
468
+ ndarray: 2D array of global solar irradiance values in W/m²
469
+ - Values represent total solar energy flux on horizontal surfaces
470
+ - Range: 0.0 to (direct_normal_irradiance * sin(elevation) + diffuse_irradiance)
471
+ - NaN indicates invalid measurement locations
472
+
473
+ Note:
474
+ Global irradiance is the standard metric used for solar energy assessment
475
+ and represents the maximum solar energy available at each location.
476
+ """
477
+
478
+ # Extract visualization parameters
479
+ colormap = kwargs.get("colormap", 'magma')
480
+
481
+ # Create kwargs for individual component calculations
482
+ # Both direct and diffuse calculations use the same base parameters
483
+ direct_diffuse_kwargs = kwargs.copy()
484
+ direct_diffuse_kwargs.update({
485
+ 'show_plot': True, # Show intermediate results for debugging
486
+ 'obj_export': False # Don't export intermediate results
487
+ })
488
+
489
+ # Compute direct irradiance component
490
+ # Accounts for sun position, shadows, and tree transmittance
491
+ direct_map = get_direct_solar_irradiance_map(
492
+ voxel_data,
493
+ meshsize,
494
+ azimuth_degrees,
495
+ elevation_degrees,
496
+ direct_normal_irradiance,
497
+ **direct_diffuse_kwargs
498
+ )
499
+
500
+ # Compute diffuse irradiance component
501
+ # Based on Sky View Factor and atmospheric scattering
502
+ diffuse_map = get_diffuse_solar_irradiance_map(
503
+ voxel_data,
504
+ meshsize,
505
+ diffuse_irradiance=diffuse_irradiance,
506
+ **direct_diffuse_kwargs
507
+ )
508
+
509
+ # Sum the two components to get total global irradiance
510
+ # This represents the total solar energy available at each location
511
+ global_map = direct_map + diffuse_map
512
+
513
+ # Determine colormap scaling range from actual data
514
+ vmin = kwargs.get("vmin", np.nanmin(global_map))
515
+ vmax = kwargs.get("vmax", np.nanmax(global_map))
516
+
517
+ # Optional visualization of combined results
518
+ if show_plot:
519
+ # Set up colormap with special handling for invalid data
520
+ cmap = plt.cm.get_cmap(colormap).copy()
521
+ cmap.set_bad(color='lightgray') # NaN values shown in gray
522
+
523
+ plt.figure(figsize=(10, 8))
524
+ # plt.title("Global Solar Irradiance Map")
525
+ plt.imshow(global_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
526
+ plt.colorbar(label='Global Solar Irradiance (W/m²)')
527
+ plt.axis('off')
528
+ plt.show()
529
+
530
+ # Optional export to 3D OBJ format for external visualization
531
+ obj_export = kwargs.get("obj_export", False)
532
+ if obj_export:
533
+ # Get export parameters with defaults
534
+ dem_grid = kwargs.get("dem_grid", np.zeros_like(global_map))
535
+ output_dir = kwargs.get("output_directory", "output")
536
+ output_file_name = kwargs.get("output_file_name", "global_solar_irradiance")
537
+ num_colors = kwargs.get("num_colors", 10)
538
+ alpha = kwargs.get("alpha", 1.0)
539
+ meshsize_param = kwargs.get("meshsize", meshsize)
540
+ view_point_height = kwargs.get("view_point_height", 1.5)
541
+
542
+ # Export as colored 3D mesh
543
+ grid_to_obj(
544
+ global_map,
545
+ dem_grid,
546
+ output_dir,
547
+ output_file_name,
548
+ meshsize_param,
549
+ view_point_height,
550
+ colormap_name=colormap,
551
+ num_colors=num_colors,
552
+ alpha=alpha,
553
+ vmin=vmin,
554
+ vmax=vmax
555
+ )
556
+
557
+ return global_map
558
+
559
+ def get_solar_positions_astral(times, lon, lat):
560
+ """
561
+ Compute solar azimuth and elevation using Astral for given times and location.
562
+
563
+ This function uses the Astral astronomical library to calculate precise solar positions
564
+ based on location coordinates and timestamps. The calculations account for Earth's
565
+ orbital mechanics, axial tilt, and atmospheric refraction effects.
566
+
567
+ Astronomical Background:
568
+ - Solar position depends on date, time, and geographic location
569
+ - Azimuth: Horizontal angle measured clockwise from North (0°-360°)
570
+ - Elevation: Vertical angle above the horizon (-90° to +90°)
571
+ - Calculations use standard astronomical algorithms (e.g., NREL SPA)
572
+
573
+ Coordinate System:
574
+ - Azimuth: = North, 90° = East, 180° = South, 270° = West
575
+ - Elevation: 0° = horizon, 90° = zenith, negative values = below horizon
576
+ - All angles are in degrees for consistency with weather data formats
577
+
578
+ The function:
579
+ 1. Creates an Astral observer at the specified geographic location
580
+ 2. Computes sun position for each timestamp in the input array
581
+ 3. Returns DataFrame with azimuth and elevation angles for further processing
582
+
583
+ Args:
584
+ times (DatetimeIndex): Array of timezone-aware datetime objects
585
+ Must include timezone information for accurate calculations
586
+ lon (float): Longitude in degrees (positive = East, negative = West)
587
+ Range: -180° to +180°
588
+ lat (float): Latitude in degrees (positive = North, negative = South)
589
+ Range: -90° to +90°
590
+
591
+ Returns:
592
+ DataFrame: DataFrame with columns 'azimuth' and 'elevation' containing solar positions
593
+ - Index: Input timestamps (timezone-aware)
594
+ - 'azimuth': Solar azimuth angles in degrees (0°-360°)
595
+ - 'elevation': Solar elevation angles in degrees (-90° to +90°)
596
+ - All values are float type for numerical calculations
597
+
598
+ Note:
599
+ Input times must be timezone-aware. The function preserves the original
600
+ timezone information and performs calculations in the specified timezone.
601
+ """
602
+ # Create an astronomical observer at the specified geographic location
603
+ observer = Observer(latitude=lat, longitude=lon)
604
+
605
+ # Initialize result DataFrame with appropriate structure
606
+ df_pos = pd.DataFrame(index=times, columns=['azimuth', 'elevation'], dtype=float)
607
+
608
+ # Calculate solar position for each timestamp
609
+ for t in times:
610
+ # t is already timezone-aware; no need to replace tzinfo
611
+ # Calculate solar elevation (vertical angle above horizon)
612
+ el = elevation(observer=observer, dateandtime=t)
613
+
614
+ # Calculate solar azimuth (horizontal angle from North)
615
+ az = azimuth(observer=observer, dateandtime=t)
616
+
617
+ # Store results in DataFrame
618
+ df_pos.at[t, 'elevation'] = el
619
+ df_pos.at[t, 'azimuth'] = az
620
+
621
+ return df_pos
622
+
623
+ def _configure_num_threads(desired_threads=None, progress=False):
624
+ try:
625
+ cores = os.cpu_count() or 4
626
+ except Exception:
627
+ cores = 4
628
+ used = desired_threads if desired_threads is not None else cores
629
+ try:
630
+ numba.set_num_threads(int(used))
631
+ except Exception:
632
+ pass
633
+ # Best-effort oversubscription guards (only set defaults if unset)
634
+ os.environ.setdefault('MKL_NUM_THREADS', '1')
635
+ if 'OMP_NUM_THREADS' not in os.environ:
636
+ os.environ['OMP_NUM_THREADS'] = str(int(used))
637
+ if progress:
638
+ try:
639
+ print(f"Numba threads: {numba.get_num_threads()} (requested {used})")
640
+ except Exception:
641
+ print(f"Numba threads set to {used}")
642
+ return used
643
+
644
+ def _auto_time_batch_size(n_faces, total_steps, user_value=None):
645
+ if user_value is not None:
646
+ return max(1, int(user_value))
647
+ # Heuristic based on face count
648
+ if total_steps <= 0:
649
+ return 1
650
+ if n_faces <= 5_000:
651
+ batches = 2
652
+ elif n_faces <= 50_000:
653
+ batches = 8
654
+ elif n_faces <= 200_000:
655
+ batches = 16
656
+ else:
657
+ batches = 32
658
+ batches = min(batches, total_steps)
659
+ return max(1, total_steps // batches)
660
+
661
+ def get_cumulative_global_solar_irradiance(
662
+ voxel_data,
663
+ meshsize,
664
+ df, lon, lat, tz,
665
+ direct_normal_irradiance_scaling=1.0,
666
+ diffuse_irradiance_scaling=1.0,
667
+ **kwargs
668
+ ):
669
+ """
670
+ Compute cumulative global solar irradiance over a specified period using data from an EPW file.
671
+
672
+ This function performs time-series analysis of solar irradiance by processing weather data
673
+ over a user-defined period and accumulating irradiance values at each location. The result
674
+ represents the total solar energy received during the specified time period, which is
675
+ essential for seasonal analysis, solar panel positioning, and energy yield predictions.
676
+
677
+ Cumulative Analysis Concept:
678
+ - Instantaneous irradiance (W/m²): Power at a specific moment
679
+ - Cumulative irradiance (Wh/m²): Energy accumulated over time
680
+ - Integration: Sum of (irradiance × time_step) for all timesteps
681
+ - Applications: Annual energy yield, seasonal variations, optimal siting
682
+
683
+ Time Period Processing:
684
+ - Supports flexible time ranges (daily, seasonal, annual analysis)
685
+ - Handles timezone conversions between local and UTC time
686
+ - Filters weather data based on user-specified start/end times
687
+ - Accounts for leap years and varying daylight hours
688
+
689
+ Performance Optimization:
690
+ - Pre-calculates diffuse map once (scales linearly with DHI)
691
+ - Processes direct component for each timestep (varies with sun position)
692
+ - Uses efficient memory management for large time series
693
+ - Provides optional progress monitoring for long calculations
694
+
695
+ The function:
696
+ 1. Filters EPW data for specified time period with timezone handling
697
+ 2. Computes sun positions for each timestep using astronomical calculations
698
+ 3. Calculates and accumulates global irradiance maps over the entire period
699
+ 4. Handles tree transmittance and provides visualization/export options
700
+
701
+ Args:
702
+ voxel_data (ndarray): 3D array of voxel values representing the urban environment
703
+ meshsize (float): Size of each voxel in meters (spatial resolution)
704
+ df (DataFrame): EPW weather data with columns 'DNI', 'DHI' and datetime index
705
+ Must include complete meteorological dataset
706
+ lon (float): Longitude in degrees for solar position calculations
707
+ lat (float): Latitude in degrees for solar position calculations
708
+ tz (float): Timezone offset in hours from UTC (positive = East of UTC)
709
+ direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance
710
+ Allows sensitivity analysis or unit conversions
711
+ diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance
712
+ Allows sensitivity analysis or unit conversions
713
+ **kwargs: Additional arguments including:
714
+ - view_point_height (float): Observer height in meters (default: 1.5)
715
+ Height above ground where measurements are taken
716
+ - start_time (str): Start time in format 'MM-DD HH:MM:SS'
717
+ Defines beginning of analysis period (default: "01-01 05:00:00")
718
+ - end_time (str): End time in format 'MM-DD HH:MM:SS'
719
+ Defines end of analysis period (default: "01-01 20:00:00")
720
+ - tree_k (float): Tree extinction coefficient for transmittance calculations
721
+ Higher values mean trees block more light
722
+ - tree_lad (float): Leaf area density in m^-1
723
+ Affects light attenuation through tree canopies
724
+ - show_plot (bool): Whether to show final accumulated results
725
+ - show_each_timestep (bool): Whether to show plots for each timestep
726
+ Useful for debugging but significantly increases computation time
727
+ - colormap (str): Matplotlib colormap name for visualization
728
+ - vmin (float): Minimum value for colormap scaling
729
+ - vmax (float): Maximum value for colormap scaling
730
+ - obj_export (bool): Whether to export results as 3D OBJ file
731
+ - output_directory (str): Directory for file exports
732
+ - output_file_name (str): Base filename for exports
733
+ - dem_grid (ndarray): Digital elevation model for 3D export
734
+ - num_colors (int): Number of discrete colors for OBJ export
735
+ - alpha (float): Transparency value for 3D visualization
736
+
737
+ Returns:
738
+ ndarray: 2D array of cumulative global solar irradiance values in W/m²·hour
739
+ - Values represent total solar energy received during the analysis period
740
+ - Range depends on period length and local climate conditions
741
+ - NaN indicates invalid measurement locations (e.g., on buildings, water)
742
+
743
+ Note:
744
+ The function efficiently handles large time series by pre-computing the diffuse
745
+ component once and scaling it for each timestep, significantly reducing
746
+ computation time for long-term analysis.
747
+ """
748
+ # Extract parameters with defaults for observer positioning and visualization
749
+ view_point_height = kwargs.get("view_point_height", 1.5)
750
+ colormap = kwargs.get("colormap", 'magma')
751
+ start_time = kwargs.get("start_time", "01-01 05:00:00")
752
+ end_time = kwargs.get("end_time", "01-01 20:00:00")
753
+ # Optional: configure num threads here as well when called directly
754
+ desired_threads = kwargs.get("numba_num_threads", None)
755
+ progress_report = kwargs.get("progress_report", False)
756
+ _configure_num_threads(desired_threads, progress=progress_report)
757
+
758
+ # Validate input data
759
+ if df.empty:
760
+ raise ValueError("No data in EPW file.")
761
+
762
+ # Parse start and end times without year (supports multi-year analysis)
763
+ try:
764
+ start_dt = datetime.strptime(start_time, "%m-%d %H:%M:%S")
765
+ end_dt = datetime.strptime(end_time, "%m-%d %H:%M:%S")
766
+ except ValueError as ve:
767
+ raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
768
+
769
+ # Add hour of year column for efficient time filtering
770
+ # Hour 1 = January 1st, 00:00; Hour 8760 = December 31st, 23:00
771
+ df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
772
+
773
+ # Convert parsed dates to day of year and hour for filtering
774
+ start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
775
+ end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
776
+
777
+ start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
778
+ end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
779
+
780
+ # Handle period crossing year boundary (e.g., Dec 15 to Jan 15)
781
+ if start_hour <= end_hour:
782
+ # Normal period within single year
783
+ df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
784
+ else:
785
+ # Period crosses year boundary - include end and beginning of year
786
+ df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
787
+
788
+ # Apply minute-level filtering within start/end hours for precision
789
+ df_period = df_period[
790
+ ((df_period.index.hour != start_dt.hour) | (df_period.index.minute >= start_dt.minute)) &
791
+ ((df_period.index.hour != end_dt.hour) | (df_period.index.minute <= end_dt.minute))
792
+ ]
793
+
794
+ # Validate filtered data
795
+ if df_period.empty:
796
+ raise ValueError("No EPW data in the specified period.")
797
+
798
+ # Handle timezone conversion for accurate solar position calculations
799
+ # Convert local time (from EPW) to UTC for astronomical calculations
800
+ offset_minutes = int(tz * 60)
801
+ local_tz = pytz.FixedOffset(offset_minutes)
802
+ df_period_local = df_period.copy()
803
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
804
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
805
+
806
+ # Compute solar positions for entire analysis period
807
+ # This is done once to optimize performance
808
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
809
+
810
+ # Prepare parameters for efficient diffuse irradiance calculation
811
+ # Create kwargs for diffuse calculation with visualization disabled
812
+ diffuse_kwargs = kwargs.copy()
813
+ diffuse_kwargs.update({
814
+ 'show_plot': False,
815
+ 'obj_export': False
816
+ })
817
+
818
+ # Pre-compute base diffuse map once with unit irradiance
819
+ # This map will be scaled by actual DHI values for each timestep
820
+ base_diffuse_map = get_diffuse_solar_irradiance_map(
821
+ voxel_data,
822
+ meshsize,
823
+ diffuse_irradiance=1.0,
824
+ **diffuse_kwargs
825
+ )
826
+
827
+ # Initialize accumulation arrays for energy integration
828
+ cumulative_map = np.zeros((voxel_data.shape[0], voxel_data.shape[1]))
829
+ mask_map = np.ones((voxel_data.shape[0], voxel_data.shape[1]), dtype=bool)
830
+
831
+ # Prepare parameters for direct irradiance calculations
832
+ # Create kwargs for direct calculation with visualization disabled
833
+ direct_kwargs = kwargs.copy()
834
+ direct_kwargs.update({
835
+ 'show_plot': False,
836
+ 'view_point_height': view_point_height,
837
+ 'obj_export': False
838
+ })
839
+
840
+ # Main processing loop: iterate through each timestep in the analysis period
841
+ for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
842
+ # Apply scaling factors to weather data
843
+ # Allows for sensitivity analysis or unit conversions
844
+ DNI = row['DNI'] * direct_normal_irradiance_scaling
845
+ DHI = row['DHI'] * diffuse_irradiance_scaling
846
+ time_local = df_period_local.index[idx]
847
+
848
+ # Get solar position for timestep
849
+ solpos = solar_positions.loc[time_utc]
850
+ azimuth_degrees = solpos['azimuth']
851
+ elevation_degrees = solpos['elevation']
852
+
853
+ # Compute direct irradiance map with transmittance
854
+ direct_map = get_direct_solar_irradiance_map(
855
+ voxel_data,
856
+ meshsize,
857
+ azimuth_degrees,
858
+ elevation_degrees,
859
+ direct_normal_irradiance=DNI,
860
+ **direct_kwargs
861
+ )
862
+
863
+ # Scale base diffuse map by actual DHI
864
+ diffuse_map = base_diffuse_map * DHI
865
+
866
+ # Combine direct and diffuse components
867
+ global_map = direct_map + diffuse_map
868
+
869
+ # Update valid pixel mask
870
+ mask_map &= ~np.isnan(global_map)
871
+
872
+ # Replace NaN with 0 for accumulation
873
+ global_map_filled = np.nan_to_num(global_map, nan=0.0)
874
+ cumulative_map += global_map_filled
875
+
876
+ # Optional timestep visualization
877
+ show_each_timestep = kwargs.get("show_each_timestep", False)
878
+ if show_each_timestep:
879
+ colormap = kwargs.get("colormap", 'viridis')
880
+ vmin = kwargs.get("vmin", 0.0)
881
+ vmax = kwargs.get("vmax", max(direct_normal_irradiance_scaling, diffuse_irradiance_scaling) * 1000)
882
+ cmap = plt.cm.get_cmap(colormap).copy()
883
+ cmap.set_bad(color='lightgray')
884
+ plt.figure(figsize=(10, 8))
885
+ # plt.title(f"Global Solar Irradiance at {time_local.strftime('%Y-%m-%d %H:%M:%S')}")
886
+ plt.imshow(global_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
887
+ plt.axis('off')
888
+ plt.colorbar(label='Global Solar Irradiance (W/m²)')
889
+ plt.show()
890
+
891
+ # Apply mask to final result
892
+ cumulative_map[~mask_map] = np.nan
893
+
894
+ # Final visualization
895
+ show_plot = kwargs.get("show_plot", True)
896
+ if show_plot:
897
+ colormap = kwargs.get("colormap", 'magma')
898
+ vmin = kwargs.get("vmin", np.nanmin(cumulative_map))
899
+ vmax = kwargs.get("vmax", np.nanmax(cumulative_map))
900
+ cmap = plt.cm.get_cmap(colormap).copy()
901
+ cmap.set_bad(color='lightgray')
902
+ plt.figure(figsize=(10, 8))
903
+ # plt.title("Cumulative Global Solar Irradiance Map")
904
+ plt.imshow(cumulative_map, origin='lower', cmap=cmap, vmin=vmin, vmax=vmax)
905
+ plt.colorbar(label='Cumulative Global Solar Irradiance (W/m²·hour)')
906
+ plt.axis('off')
907
+ plt.show()
908
+
909
+ # Optional OBJ export
910
+ obj_export = kwargs.get("obj_export", False)
911
+ if obj_export:
912
+ colormap = kwargs.get("colormap", "magma")
913
+ vmin = kwargs.get("vmin", np.nanmin(cumulative_map))
914
+ vmax = kwargs.get("vmax", np.nanmax(cumulative_map))
915
+ dem_grid = kwargs.get("dem_grid", np.zeros_like(cumulative_map))
916
+ output_dir = kwargs.get("output_directory", "output")
917
+ output_file_name = kwargs.get("output_file_name", "cummurative_global_solar_irradiance")
918
+ num_colors = kwargs.get("num_colors", 10)
919
+ alpha = kwargs.get("alpha", 1.0)
920
+ grid_to_obj(
921
+ cumulative_map,
922
+ dem_grid,
923
+ output_dir,
924
+ output_file_name,
925
+ meshsize,
926
+ view_point_height,
927
+ colormap_name=colormap,
928
+ num_colors=num_colors,
929
+ alpha=alpha,
930
+ vmin=vmin,
931
+ vmax=vmax
932
+ )
933
+
934
+ return cumulative_map
935
+
936
+ def get_global_solar_irradiance_using_epw(
937
+ voxel_data,
938
+ meshsize,
939
+ calc_type='instantaneous',
940
+ direct_normal_irradiance_scaling=1.0,
941
+ diffuse_irradiance_scaling=1.0,
942
+ **kwargs
943
+ ):
944
+ """
945
+ Compute global solar irradiance using EPW weather data, either for a single time or cumulatively over a period.
946
+
947
+ The function:
948
+ 1. Optionally downloads and reads EPW weather data
949
+ 2. Handles timezone conversions and solar position calculations
950
+ 3. Computes either instantaneous or cumulative irradiance maps
951
+ 4. Supports visualization and export options
952
+
953
+ Args:
954
+ voxel_data (ndarray): 3D array of voxel values.
955
+ meshsize (float): Size of each voxel in meters.
956
+ calc_type (str): 'instantaneous' or 'cumulative'.
957
+ direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
958
+ diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
959
+ **kwargs: Additional arguments including:
960
+ - download_nearest_epw (bool): Whether to download nearest EPW file
961
+ - epw_file_path (str): Path to EPW file
962
+ - rectangle_vertices (list): List of (lat,lon) coordinates for EPW download
963
+ - output_dir (str): Directory for EPW download
964
+ - calc_time (str): Time for instantaneous calculation ('MM-DD HH:MM:SS')
965
+ - start_time (str): Start time for cumulative calculation
966
+ - end_time (str): End time for cumulative calculation
967
+ - start_hour (int): Starting hour for daily time window (0-23)
968
+ - end_hour (int): Ending hour for daily time window (0-23)
969
+ - view_point_height (float): Observer height in meters
970
+ - tree_k (float): Tree extinction coefficient
971
+ - tree_lad (float): Leaf area density in m^-1
972
+ - show_plot (bool): Whether to show visualization
973
+ - show_each_timestep (bool): Whether to show timestep plots
974
+ - colormap (str): Matplotlib colormap name
975
+ - obj_export (bool): Whether to export as OBJ file
976
+
977
+ Returns:
978
+ ndarray: 2D array of solar irradiance values (W/m²).
979
+ """
980
+ view_point_height = kwargs.get("view_point_height", 1.5)
981
+ colormap = kwargs.get("colormap", 'magma')
982
+
983
+ # Get EPW file
984
+ download_nearest_epw = kwargs.get("download_nearest_epw", False)
985
+ rectangle_vertices = kwargs.get("rectangle_vertices", None)
986
+ epw_file_path = kwargs.get("epw_file_path", None)
987
+ if download_nearest_epw:
988
+ if rectangle_vertices is None:
989
+ print("rectangle_vertices is required to download nearest EPW file")
990
+ return None
991
+ else:
992
+ # Calculate center point of rectangle
993
+ lons = [coord[0] for coord in rectangle_vertices]
994
+ lats = [coord[1] for coord in rectangle_vertices]
995
+ center_lon = (min(lons) + max(lons)) / 2
996
+ center_lat = (min(lats) + max(lats)) / 2
997
+ target_point = (center_lon, center_lat)
998
+
999
+ # Optional: specify maximum distance in kilometers
1000
+ max_distance = 100 # None for no limit
1001
+
1002
+ output_dir = kwargs.get("output_dir", "output")
1003
+
1004
+ epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
1005
+ longitude=center_lon,
1006
+ latitude=center_lat,
1007
+ output_dir=output_dir,
1008
+ max_distance=max_distance,
1009
+ extract_zip=True,
1010
+ load_data=True
1011
+ )
1012
+
1013
+ # Read EPW data
1014
+ df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
1015
+ if df.empty:
1016
+ raise ValueError("No data in EPW file.")
1017
+
1018
+ if calc_type == 'instantaneous':
1019
+
1020
+ calc_time = kwargs.get("calc_time", "01-01 12:00:00")
1021
+
1022
+ # Parse start and end times without year
1023
+ try:
1024
+ calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
1025
+ except ValueError as ve:
1026
+ raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
1027
+
1028
+ df_period = df[
1029
+ (df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
1030
+ ]
1031
+
1032
+ if df_period.empty:
1033
+ raise ValueError("No EPW data at the specified time.")
1034
+
1035
+ # Prepare timezone conversion
1036
+ offset_minutes = int(tz * 60)
1037
+ local_tz = pytz.FixedOffset(offset_minutes)
1038
+ df_period_local = df_period.copy()
1039
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
1040
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
1041
+
1042
+ # Compute solar positions
1043
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1044
+ direct_normal_irradiance = df_period_utc.iloc[0]['DNI']
1045
+ diffuse_irradiance = df_period_utc.iloc[0]['DHI']
1046
+ azimuth_degrees = solar_positions.iloc[0]['azimuth']
1047
+ elevation_degrees = solar_positions.iloc[0]['elevation']
1048
+ solar_map = get_global_solar_irradiance_map(
1049
+ voxel_data, # 3D voxel grid representing the urban environment
1050
+ meshsize, # Size of each grid cell in meters
1051
+ azimuth_degrees, # Sun's azimuth angle
1052
+ elevation_degrees, # Sun's elevation angle
1053
+ direct_normal_irradiance, # Direct Normal Irradiance value
1054
+ diffuse_irradiance, # Diffuse irradiance value
1055
+ show_plot=True, # Display visualization of results
1056
+ **kwargs
1057
+ )
1058
+ if calc_type == 'cumulative':
1059
+ # Get time window parameters
1060
+ start_hour = kwargs.get("start_hour", 0) # Default to midnight
1061
+ end_hour = kwargs.get("end_hour", 23) # Default to 11 PM
1062
+
1063
+ # Filter dataframe for specified hours
1064
+ df_filtered = df[(df.index.hour >= start_hour) & (df.index.hour <= end_hour)]
1065
+
1066
+ solar_map = get_cumulative_global_solar_irradiance(
1067
+ voxel_data,
1068
+ meshsize,
1069
+ df_filtered, lon, lat, tz,
1070
+ **kwargs
1071
+ )
1072
+
1073
+ return solar_map
1074
+
1075
+ import numpy as np
1076
+ import trimesh
1077
+ import time
1078
+ from numba import njit, prange
1079
+
1080
+ ##############################################################################
1081
+ # 1) New Numba helper: per-face solar irradiance computation
1082
+ ##############################################################################
1083
+ @njit(parallel=True)
1084
+ def compute_solar_irradiance_for_all_faces(
1085
+ face_centers,
1086
+ face_normals,
1087
+ face_svf,
1088
+ sun_direction,
1089
+ direct_normal_irradiance,
1090
+ diffuse_irradiance,
1091
+ voxel_data,
1092
+ meshsize,
1093
+ tree_k,
1094
+ tree_lad,
1095
+ hit_values,
1096
+ inclusion_mode,
1097
+ grid_bounds_real,
1098
+ boundary_epsilon
1099
+ ):
1100
+ """
1101
+ Numba-compiled function to compute direct, diffuse, and global solar irradiance
1102
+ for each face in a 3D building mesh.
1103
+
1104
+ This optimized function processes all mesh faces in parallel to calculate solar
1105
+ irradiance components. It handles both direct radiation (dependent on sun position
1106
+ and surface orientation) and diffuse radiation (dependent on sky visibility).
1107
+ The function is compiled with Numba for high-performance computation on large meshes.
1108
+
1109
+ Surface Irradiance Physics:
1110
+ - Direct component: DNI × cos(incidence_angle) × transmittance
1111
+ - Diffuse component: DHI × sky_view_factor
1112
+ - Incidence angle: Angle between sun direction and surface normal
1113
+ - Transmittance: Attenuation factor from obstacles and vegetation
1114
+
1115
+ Boundary Condition Handling:
1116
+ - Vertical boundary faces are excluded (mesh edges touching domain boundaries)
1117
+ - Invalid faces (NaN SVF) are skipped to maintain data consistency
1118
+ - Surface orientation affects direct radiation calculation
1119
+
1120
+ Performance Optimizations:
1121
+ - Numba JIT compilation for near C-speed execution
1122
+ - Parallel processing of face calculations
1123
+ - Efficient geometric computations using vectorized operations
1124
+ - Memory-optimized array operations
1125
+
1126
+ Args:
1127
+ face_centers (float64[:, :]): (N x 3) array of face center coordinates in real-world units
1128
+ face_normals (float64[:, :]): (N x 3) array of outward-pointing unit normal vectors
1129
+ face_svf (float64[:]): (N,) array of Sky View Factor values for each face (0.0-1.0)
1130
+ sun_direction (float64[:]): (3,) array for normalized sun direction vector (dx, dy, dz)
1131
+ direct_normal_irradiance (float): Direct normal irradiance (DNI) in W/m²
1132
+ diffuse_irradiance (float): Diffuse horizontal irradiance (DHI) in W/m²
1133
+ voxel_data (ndarray): 3D array of voxel values for obstacle detection
1134
+ meshsize (float): Size of each voxel in meters (spatial resolution)
1135
+ tree_k (float): Tree extinction coefficient for Beer-Lambert law
1136
+ tree_lad (float): Leaf area density in m^-1
1137
+ hit_values (tuple): Values considered 'sky' for ray tracing (e.g. (0,))
1138
+ inclusion_mode (bool): Whether hit_values are included (True) or excluded (False)
1139
+ grid_bounds_real (float64[2,3]): Domain boundaries [[x_min,y_min,z_min],[x_max,y_max,z_max]]
1140
+ boundary_epsilon (float): Distance threshold for boundary face detection
1141
+
1142
+ Returns:
1143
+ tuple: Three float64[N] arrays containing:
1144
+ - direct_irr: Direct solar irradiance for each face (W/m²)
1145
+ - diffuse_irr: Diffuse solar irradiance for each face (W/m²)
1146
+ - global_irr: Global solar irradiance for each face (W/m²)
1147
+
1148
+ Note:
1149
+ This function is optimized with Numba and should not be called directly.
1150
+ Use the higher-level wrapper functions for normal operation.
1151
+ """
1152
+ n_faces = face_centers.shape[0]
1153
+
1154
+ # Initialize output arrays for each irradiance component
1155
+ face_direct = np.zeros(n_faces, dtype=np.float64)
1156
+ face_diffuse = np.zeros(n_faces, dtype=np.float64)
1157
+ face_global = np.zeros(n_faces, dtype=np.float64)
1158
+
1159
+ # Extract domain boundaries for boundary face detection
1160
+ x_min, y_min, z_min = grid_bounds_real[0, 0], grid_bounds_real[0, 1], grid_bounds_real[0, 2]
1161
+ x_max, y_max, z_max = grid_bounds_real[1, 0], grid_bounds_real[1, 1], grid_bounds_real[1, 2]
1162
+
1163
+ # Process each face individually (Numba optimizes this loop)
1164
+ for fidx in prange(n_faces):
1165
+ center = face_centers[fidx]
1166
+ normal = face_normals[fidx]
1167
+ svf = face_svf[fidx]
1168
+
1169
+ # Check for vertical boundary faces that should be excluded
1170
+ # These are mesh edges at domain boundaries, not actual building surfaces
1171
+ is_vertical = (abs(normal[2]) < 0.01) # Nearly vertical normal
1172
+
1173
+ # Check if face center is at domain boundary
1174
+ on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
1175
+ on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
1176
+ on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
1177
+ on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
1178
+
1179
+ is_boundary_vertical = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
1180
+
1181
+ # Skip boundary faces to avoid artifacts
1182
+ if is_boundary_vertical:
1183
+ face_direct[fidx] = np.nan
1184
+ face_diffuse[fidx] = np.nan
1185
+ face_global[fidx] = np.nan
1186
+ continue
1187
+
1188
+ # Skip faces with invalid SVF data
1189
+ if svf != svf: # NaN check in Numba-compatible way
1190
+ face_direct[fidx] = np.nan
1191
+ face_diffuse[fidx] = np.nan
1192
+ face_global[fidx] = np.nan
1193
+ continue
1194
+
1195
+ # Calculate direct irradiance component
1196
+ # Only surfaces oriented towards the sun receive direct radiation
1197
+ cos_incidence = normal[0]*sun_direction[0] + \
1198
+ normal[1]*sun_direction[1] + \
1199
+ normal[2]*sun_direction[2]
1200
+
1201
+ direct_val = 0.0
1202
+ if cos_incidence > 0.0: # Surface faces towards sun
1203
+ # Offset ray origin slightly along normal to avoid self-intersection
1204
+ offset_vox = 0.1 # Small offset in voxel units
1205
+ ray_origin_x = center[0]/meshsize + normal[0]*offset_vox
1206
+ ray_origin_y = center[1]/meshsize + normal[1]*offset_vox
1207
+ ray_origin_z = center[2]/meshsize + normal[2]*offset_vox
1208
+
1209
+ # Cast ray toward the sun to check for obstructions
1210
+ hit_detected, transmittance = trace_ray_generic(
1211
+ voxel_data,
1212
+ np.array([ray_origin_x, ray_origin_y, ray_origin_z], dtype=np.float64),
1213
+ sun_direction,
1214
+ hit_values,
1215
+ meshsize,
1216
+ tree_k,
1217
+ tree_lad,
1218
+ inclusion_mode
1219
+ )
1220
+
1221
+ # Calculate direct irradiance if path to sun is clear/partially clear
1222
+ if not hit_detected:
1223
+ direct_val = direct_normal_irradiance * cos_incidence * transmittance
1224
+
1225
+ # Calculate diffuse irradiance component using Sky View Factor
1226
+ # All surfaces receive diffuse radiation proportional to their sky visibility
1227
+ diffuse_val = svf * diffuse_irradiance
1228
+
1229
+ # Ensure diffuse irradiance doesn't exceed theoretical maximum
1230
+ if diffuse_val > diffuse_irradiance:
1231
+ diffuse_val = diffuse_irradiance
1232
+
1233
+ # Store results for this face
1234
+ face_direct[fidx] = direct_val
1235
+ face_diffuse[fidx] = diffuse_val
1236
+ face_global[fidx] = direct_val + diffuse_val
1237
+
1238
+ return face_direct, face_diffuse, face_global
1239
+
1240
+
1241
+ ##############################################################################
1242
+ # 2) Modified get_building_solar_irradiance: main Python wrapper
1243
+ ##############################################################################
1244
+ def get_building_solar_irradiance(
1245
+ voxel_data,
1246
+ meshsize,
1247
+ building_svf_mesh,
1248
+ azimuth_degrees,
1249
+ elevation_degrees,
1250
+ direct_normal_irradiance,
1251
+ diffuse_irradiance,
1252
+ **kwargs
1253
+ ):
1254
+ """
1255
+ Calculate solar irradiance on building surfaces using Sky View Factor (SVF) analysis,
1256
+ with high-performance computation accelerated by Numba JIT compilation.
1257
+
1258
+ This function performs detailed solar irradiance analysis on 3D building surfaces
1259
+ represented as triangulated meshes. It calculates both direct and diffuse components
1260
+ of solar radiation for each mesh face, accounting for surface orientation, shadows,
1261
+ and sky visibility. The computation is optimized for large urban models using
1262
+ efficient algorithms and parallel processing.
1263
+
1264
+ Mesh-Based Analysis Advantages:
1265
+ - Surface-specific calculations for facades, roofs, and complex geometries
1266
+ - Accurate accounting of surface orientation and local shading effects
1267
+ - Integration with 3D visualization and CAD workflows
1268
+ - Detailed irradiance data for building energy modeling
1269
+
1270
+ Performance Features:
1271
+ - Numba JIT compilation for near C-speed execution
1272
+ - Parallel processing of mesh faces
1273
+ - Efficient ray tracing with tree transmittance
1274
+ - Memory-optimized operations for large datasets
1275
+
1276
+ Physical Modeling:
1277
+ - Direct irradiance: Based on sun position and surface orientation
1278
+ - Diffuse irradiance: Based on Sky View Factor from each surface
1279
+ - Tree effects: Partial transmittance using Beer-Lambert law
1280
+ - Boundary handling: Automatic exclusion of domain boundary artifacts
1281
+
1282
+ Args:
1283
+ voxel_data (ndarray): 3D array of voxel values representing the urban environment
1284
+ meshsize (float): Size of each voxel in meters (spatial resolution)
1285
+ building_svf_mesh (trimesh.Trimesh): Building mesh with pre-calculated SVF values in metadata
1286
+ Must have 'svf' array in mesh.metadata
1287
+ azimuth_degrees (float): Sun azimuth angle in degrees (0=North, 90=East)
1288
+ elevation_degrees (float): Sun elevation angle in degrees above horizon (0-90°)
1289
+ direct_normal_irradiance (float): Direct normal irradiance (DNI) in W/m² from weather data
1290
+ diffuse_irradiance (float): Diffuse horizontal irradiance (DHI) in W/m² from weather data
1291
+ **kwargs: Additional parameters including:
1292
+ - tree_k (float): Tree extinction coefficient (default: 0.6)
1293
+ Higher values mean trees block more light
1294
+ - tree_lad (float): Leaf area density in m^-1 (default: 1.0)
1295
+ Affects light attenuation through tree canopies
1296
+ - progress_report (bool): Whether to print timing information (default: False)
1297
+ - obj_export (bool): Whether to export results as OBJ file
1298
+ - output_directory (str): Directory for file exports
1299
+ - output_file_name (str): Base filename for exports
1300
+
1301
+ Returns:
1302
+ trimesh.Trimesh: A copy of the input mesh with irradiance data stored in metadata:
1303
+ - 'svf': Sky View Factor for each face (preserved from input)
1304
+ - 'direct': Direct solar irradiance for each face (W/m²)
1305
+ - 'diffuse': Diffuse solar irradiance for each face (W/m²)
1306
+ - 'global': Global solar irradiance for each face (W/m²)
1307
+
1308
+ Note:
1309
+ The input mesh must have SVF values pre-calculated and stored in metadata.
1310
+ Use get_surface_view_factor() to compute SVF before calling this function.
1311
+ """
1312
+ import time
1313
+
1314
+ # Extract tree transmittance parameters with defaults
1315
+ tree_k = kwargs.get("tree_k", 0.6)
1316
+ tree_lad = kwargs.get("tree_lad", 1.0)
1317
+ progress_report = kwargs.get("progress_report", False)
1318
+
1319
+ # Define sky detection parameters for ray tracing
1320
+ hit_values = (0,) # '0' = sky voxel value
1321
+ inclusion_mode = False # Treat non-sky values as obstacles
1322
+
1323
+ # Convert solar angles to 3D direction vector using spherical coordinates
1324
+ az_rad = np.deg2rad(180 - azimuth_degrees) # Adjust for coordinate system
1325
+ el_rad = np.deg2rad(elevation_degrees)
1326
+ sun_dx = np.cos(el_rad) * np.cos(az_rad)
1327
+ sun_dy = np.cos(el_rad) * np.sin(az_rad)
1328
+ sun_dz = np.sin(el_rad)
1329
+ sun_direction = np.array([sun_dx, sun_dy, sun_dz], dtype=np.float64)
1330
+
1331
+ # Extract mesh geometry data for processing
1332
+ face_centers = building_svf_mesh.triangles_center # Center point of each face
1333
+ face_normals = building_svf_mesh.face_normals # Normal vector for each face
1334
+
1335
+ # Extract Sky View Factor data from mesh metadata
1336
+ if hasattr(building_svf_mesh, 'metadata') and ('svf' in building_svf_mesh.metadata):
1337
+ face_svf = building_svf_mesh.metadata['svf']
1338
+ else:
1339
+ # Initialize with zeros if SVF not available (should be pre-calculated)
1340
+ face_svf = np.zeros(len(building_svf_mesh.faces), dtype=np.float64)
1341
+
1342
+ # Set up domain boundaries for boundary face detection
1343
+ grid_shape = voxel_data.shape
1344
+ grid_bounds_voxel = np.array([[0,0,0],[grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=np.float64)
1345
+ grid_bounds_real = grid_bounds_voxel * meshsize
1346
+ boundary_epsilon = meshsize * 0.05 # Small tolerance for boundary detection
1347
+
1348
+ # Optional fast path using masked DDA kernel
1349
+ fast_path = kwargs.get("fast_path", True)
1350
+ t0 = time.time()
1351
+ if fast_path:
1352
+ # Prepare masks once
1353
+ vox_is_tree = (voxel_data == -2)
1354
+ vox_is_opaque = (voxel_data != 0) & (~vox_is_tree)
1355
+ att = float(np.exp(-tree_k * tree_lad * meshsize))
1356
+
1357
+ face_direct, face_diffuse, face_global = compute_solar_irradiance_for_all_faces_masked(
1358
+ face_centers.astype(np.float64),
1359
+ face_normals.astype(np.float64),
1360
+ face_svf.astype(np.float64),
1361
+ sun_direction.astype(np.float64),
1362
+ float(direct_normal_irradiance),
1363
+ float(diffuse_irradiance),
1364
+ vox_is_tree,
1365
+ vox_is_opaque,
1366
+ float(meshsize),
1367
+ att,
1368
+ float(grid_bounds_real[0,0]), float(grid_bounds_real[0,1]), float(grid_bounds_real[0,2]),
1369
+ float(grid_bounds_real[1,0]), float(grid_bounds_real[1,1]), float(grid_bounds_real[1,2]),
1370
+ float(boundary_epsilon)
1371
+ )
1372
+ else:
1373
+ face_direct, face_diffuse, face_global = compute_solar_irradiance_for_all_faces(
1374
+ face_centers,
1375
+ face_normals,
1376
+ face_svf,
1377
+ sun_direction,
1378
+ direct_normal_irradiance,
1379
+ diffuse_irradiance,
1380
+ voxel_data,
1381
+ meshsize,
1382
+ tree_k,
1383
+ tree_lad,
1384
+ hit_values,
1385
+ inclusion_mode,
1386
+ grid_bounds_real,
1387
+ boundary_epsilon
1388
+ )
1389
+
1390
+ # Report performance timing if requested
1391
+ if progress_report:
1392
+ elapsed = time.time() - t0
1393
+ print(f"Numba-based solar irradiance calculation took {elapsed:.2f} seconds")
1394
+
1395
+ # Create a copy of the input mesh to store results
1396
+ irradiance_mesh = building_svf_mesh.copy()
1397
+ if not hasattr(irradiance_mesh, 'metadata'):
1398
+ irradiance_mesh.metadata = {}
1399
+
1400
+ # Store results
1401
+ irradiance_mesh.metadata['svf'] = face_svf
1402
+ irradiance_mesh.metadata['direct'] = face_direct
1403
+ irradiance_mesh.metadata['diffuse'] = face_diffuse
1404
+ irradiance_mesh.metadata['global'] = face_global
1405
+
1406
+ irradiance_mesh.name = "Solar Irradiance (W/m²)"
1407
+
1408
+ # # Optional OBJ export
1409
+ # obj_export = kwargs.get("obj_export", False)
1410
+ # if obj_export:
1411
+ # # Get export parameters
1412
+ # output_dir = kwargs.get("output_directory", "output")
1413
+ # output_file_name = kwargs.get("output_file_name", "solar_irradiance")
1414
+
1415
+ # # Export the mesh directly
1416
+ # irradiance_mesh.export(f"{output_dir}/{output_file_name}.obj")
1417
+
1418
+ return irradiance_mesh
1419
+
1420
+ ##############################################################################
1421
+ # 2.5) Specialized masked DDA for per-face irradiance (parallel)
1422
+ ##############################################################################
1423
+ @njit(cache=True, fastmath=True, nogil=True)
1424
+ def _trace_direct_masked(vox_is_tree, vox_is_opaque, origin, direction, att, att_cutoff=0.01):
1425
+ nx, ny, nz = vox_is_opaque.shape
1426
+ x0 = origin[0]; y0 = origin[1]; z0 = origin[2]
1427
+ dx = direction[0]; dy = direction[1]; dz = direction[2]
1428
+
1429
+ # Normalize
1430
+ L = (dx*dx + dy*dy + dz*dz) ** 0.5
1431
+ if L == 0.0:
1432
+ return False, 1.0
1433
+ invL = 1.0 / L
1434
+ dx *= invL; dy *= invL; dz *= invL
1435
+
1436
+ # Start at voxel centers
1437
+ x = x0 + 0.5; y = y0 + 0.5; z = z0 + 0.5
1438
+ i = int(x0); j = int(y0); k = int(z0)
1439
+
1440
+ step_x = 1 if dx >= 0.0 else -1
1441
+ step_y = 1 if dy >= 0.0 else -1
1442
+ step_z = 1 if dz >= 0.0 else -1
1443
+
1444
+ BIG = 1e30
1445
+ if dx != 0.0:
1446
+ t_max_x = (((i + (1 if step_x > 0 else 0)) - x) / dx)
1447
+ t_delta_x = abs(1.0 / dx)
1448
+ else:
1449
+ t_max_x = BIG; t_delta_x = BIG
1450
+ if dy != 0.0:
1451
+ t_max_y = (((j + (1 if step_y > 0 else 0)) - y) / dy)
1452
+ t_delta_y = abs(1.0 / dy)
1453
+ else:
1454
+ t_max_y = BIG; t_delta_y = BIG
1455
+ if dz != 0.0:
1456
+ t_max_z = (((k + (1 if step_z > 0 else 0)) - z) / dz)
1457
+ t_delta_z = abs(1.0 / dz)
1458
+ else:
1459
+ t_max_z = BIG; t_delta_z = BIG
1460
+
1461
+ T = 1.0
1462
+ while True:
1463
+ if (i < 0) or (i >= nx) or (j < 0) or (j >= ny) or (k < 0) or (k >= nz):
1464
+ # Exited grid: not blocked
1465
+ return False, T
1466
+
1467
+ if vox_is_opaque[i, j, k]:
1468
+ # Hit opaque (non-sky, non-tree)
1469
+ return True, T
1470
+
1471
+ if vox_is_tree[i, j, k]:
1472
+ T *= att
1473
+ if T < att_cutoff:
1474
+ # Consider fully attenuated
1475
+ return True, T
1476
+
1477
+ # Step DDA
1478
+ if t_max_x < t_max_y:
1479
+ if t_max_x < t_max_z:
1480
+ t_max_x += t_delta_x; i += step_x
1481
+ else:
1482
+ t_max_z += t_delta_z; k += step_z
1483
+ else:
1484
+ if t_max_y < t_max_z:
1485
+ t_max_y += t_delta_y; j += step_y
1486
+ else:
1487
+ t_max_z += t_delta_z; k += step_z
1488
+
1489
+ @njit(parallel=True, cache=True, fastmath=True, nogil=True)
1490
+ def compute_solar_irradiance_for_all_faces_masked(
1491
+ face_centers,
1492
+ face_normals,
1493
+ face_svf,
1494
+ sun_direction,
1495
+ direct_normal_irradiance,
1496
+ diffuse_irradiance,
1497
+ vox_is_tree,
1498
+ vox_is_opaque,
1499
+ meshsize,
1500
+ att,
1501
+ x_min, y_min, z_min,
1502
+ x_max, y_max, z_max,
1503
+ boundary_epsilon
1504
+ ):
1505
+ n_faces = face_centers.shape[0]
1506
+ face_direct = np.zeros(n_faces, dtype=np.float64)
1507
+ face_diffuse = np.zeros(n_faces, dtype=np.float64)
1508
+ face_global = np.zeros(n_faces, dtype=np.float64)
1509
+
1510
+ for fidx in prange(n_faces):
1511
+ center = face_centers[fidx]
1512
+ normal = face_normals[fidx]
1513
+ svf = face_svf[fidx]
1514
+
1515
+ # Boundary vertical exclusion
1516
+ is_vertical = (abs(normal[2]) < 0.01)
1517
+ on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
1518
+ on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
1519
+ on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
1520
+ on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
1521
+ if is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max):
1522
+ face_direct[fidx] = np.nan
1523
+ face_diffuse[fidx] = np.nan
1524
+ face_global[fidx] = np.nan
1525
+ continue
1526
+
1527
+ if svf != svf:
1528
+ face_direct[fidx] = np.nan
1529
+ face_diffuse[fidx] = np.nan
1530
+ face_global[fidx] = np.nan
1531
+ continue
1532
+
1533
+ # Direct component
1534
+ cos_incidence = normal[0]*sun_direction[0] + normal[1]*sun_direction[1] + normal[2]*sun_direction[2]
1535
+ direct_val = 0.0
1536
+ if cos_incidence > 0.0 and direct_normal_irradiance > 0.0:
1537
+ offset_vox = 0.1
1538
+ ox = center[0]/meshsize + normal[0]*offset_vox
1539
+ oy = center[1]/meshsize + normal[1]*offset_vox
1540
+ oz = center[2]/meshsize + normal[2]*offset_vox
1541
+ blocked, T = _trace_direct_masked(
1542
+ vox_is_tree,
1543
+ vox_is_opaque,
1544
+ np.array((ox, oy, oz), dtype=np.float64),
1545
+ sun_direction,
1546
+ att
1547
+ )
1548
+ if not blocked:
1549
+ direct_val = direct_normal_irradiance * cos_incidence * T
1550
+
1551
+ # Diffuse component
1552
+ diffuse_val = svf * diffuse_irradiance
1553
+ if diffuse_val > diffuse_irradiance:
1554
+ diffuse_val = diffuse_irradiance
1555
+
1556
+ face_direct[fidx] = direct_val
1557
+ face_diffuse[fidx] = diffuse_val
1558
+ face_global[fidx] = direct_val + diffuse_val
1559
+
1560
+ return face_direct, face_diffuse, face_global
1561
+
1562
+ @njit(parallel=True, cache=True, fastmath=True, nogil=True)
1563
+ def compute_cumulative_solar_irradiance_faces_masked_timeseries(
1564
+ face_centers,
1565
+ face_normals,
1566
+ face_svf,
1567
+ sun_dirs_arr, # shape (T, 3)
1568
+ DNI_arr, # shape (T,)
1569
+ DHI_arr, # shape (T,)
1570
+ vox_is_tree,
1571
+ vox_is_opaque,
1572
+ meshsize,
1573
+ att,
1574
+ x_min, y_min, z_min,
1575
+ x_max, y_max, z_max,
1576
+ boundary_epsilon,
1577
+ t_start, t_end, # [start, end) indices
1578
+ time_step_hours
1579
+ ):
1580
+ n_faces = face_centers.shape[0]
1581
+ out_dir = np.zeros(n_faces, dtype=np.float64)
1582
+ out_diff = np.zeros(n_faces, dtype=np.float64)
1583
+ out_glob = np.zeros(n_faces, dtype=np.float64)
1584
+
1585
+ for fidx in prange(n_faces):
1586
+ center = face_centers[fidx]
1587
+ normal = face_normals[fidx]
1588
+ svf = face_svf[fidx]
1589
+
1590
+ # Boundary vertical exclusion
1591
+ is_vertical = (abs(normal[2]) < 0.01)
1592
+ on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
1593
+ on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
1594
+ on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
1595
+ on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
1596
+ if is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max):
1597
+ out_dir[fidx] = np.nan
1598
+ out_diff[fidx] = np.nan
1599
+ out_glob[fidx] = np.nan
1600
+ continue
1601
+
1602
+ if svf != svf:
1603
+ out_dir[fidx] = np.nan
1604
+ out_diff[fidx] = np.nan
1605
+ out_glob[fidx] = np.nan
1606
+ continue
1607
+
1608
+ accum_dir = 0.0
1609
+ accum_diff = 0.0
1610
+ accum_glob = 0.0
1611
+
1612
+ # Precompute ray origin (voxel coords) once per face
1613
+ offset_vox = 0.1
1614
+ ox = center[0]/meshsize + normal[0]*offset_vox
1615
+ oy = center[1]/meshsize + normal[1]*offset_vox
1616
+ oz = center[2]/meshsize + normal[2]*offset_vox
1617
+ origin = np.array((ox, oy, oz), dtype=np.float64)
1618
+
1619
+ for t in range(t_start, t_end):
1620
+ dni = DNI_arr[t]
1621
+ dhi = DHI_arr[t]
1622
+ sd0 = sun_dirs_arr[t, 0]
1623
+ sd1 = sun_dirs_arr[t, 1]
1624
+ sd2 = sun_dirs_arr[t, 2]
1625
+ # Skip below horizon quickly: dz <= 0 implies elevation<=0
1626
+ if sd2 <= 0.0:
1627
+ # diffuse only
1628
+ diff_val = svf * dhi
1629
+ if diff_val > dhi:
1630
+ diff_val = dhi
1631
+ accum_diff += diff_val * time_step_hours
1632
+ accum_glob += diff_val * time_step_hours
1633
+ continue
1634
+
1635
+ # Direct
1636
+ cos_inc = normal[0]*sd0 + normal[1]*sd1 + normal[2]*sd2
1637
+ direct_val = 0.0
1638
+ if (dni > 0.0) and (cos_inc > 0.0):
1639
+ blocked, T = _trace_direct_masked(
1640
+ vox_is_tree,
1641
+ vox_is_opaque,
1642
+ origin,
1643
+ np.array((sd0, sd1, sd2), dtype=np.float64),
1644
+ att
1645
+ )
1646
+ if not blocked:
1647
+ direct_val = dni * cos_inc * T
1648
+
1649
+ diff_val = svf * dhi
1650
+ if diff_val > dhi:
1651
+ diff_val = dhi
1652
+
1653
+ accum_dir += direct_val * time_step_hours
1654
+ accum_diff += diff_val * time_step_hours
1655
+ accum_glob += (direct_val + diff_val) * time_step_hours
1656
+
1657
+ out_dir[fidx] = accum_dir
1658
+ out_diff[fidx] = accum_diff
1659
+ out_glob[fidx] = accum_glob
1660
+
1661
+ return out_dir, out_diff, out_glob
1662
+
1663
+ ##############################################################################
1664
+ # 4) Modified get_cumulative_building_solar_irradiance
1665
+ ##############################################################################
1666
+ def get_cumulative_building_solar_irradiance(
1667
+ voxel_data,
1668
+ meshsize,
1669
+ building_svf_mesh,
1670
+ weather_df,
1671
+ lon, lat, tz,
1672
+ **kwargs
1673
+ ):
1674
+ """
1675
+ Calculate cumulative solar irradiance on building surfaces over a time period.
1676
+ Uses the Numba-accelerated get_building_solar_irradiance for each time step.
1677
+
1678
+ Args:
1679
+ voxel_data (ndarray): 3D array of voxel values.
1680
+ meshsize (float): Size of each voxel in meters.
1681
+ building_svf_mesh (trimesh.Trimesh): Mesh with pre-calculated SVF in metadata.
1682
+ weather_df (DataFrame): Weather data with DNI (W/m²) and DHI (W/m²).
1683
+ lon (float): Longitude in degrees.
1684
+ lat (float): Latitude in degrees.
1685
+ tz (float): Timezone offset in hours.
1686
+ **kwargs: Additional parameters for time range, scaling, OBJ export, etc.
1687
+
1688
+ Returns:
1689
+ trimesh.Trimesh: A mesh with cumulative (Wh/m²) irradiance in metadata.
1690
+ """
1691
+ import pytz
1692
+ from datetime import datetime
1693
+ import numpy as np
1694
+
1695
+ period_start = kwargs.get("period_start", "01-01 00:00:00")
1696
+ period_end = kwargs.get("period_end", "12-31 23:59:59")
1697
+ time_step_hours = kwargs.get("time_step_hours", 1.0)
1698
+ direct_normal_irradiance_scaling = kwargs.get("direct_normal_irradiance_scaling", 1.0)
1699
+ diffuse_irradiance_scaling = kwargs.get("diffuse_irradiance_scaling", 1.0)
1700
+ progress_report = kwargs.get("progress_report", False)
1701
+ fast_path = kwargs.get("fast_path", True)
1702
+
1703
+ # Parse times, create local tz
1704
+ try:
1705
+ start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
1706
+ end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
1707
+ except ValueError as ve:
1708
+ raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
1709
+
1710
+ offset_minutes = int(tz * 60)
1711
+ local_tz = pytz.FixedOffset(offset_minutes)
1712
+
1713
+ # Filter weather_df
1714
+ df_period = weather_df[
1715
+ ((weather_df.index.month > start_dt.month) |
1716
+ ((weather_df.index.month == start_dt.month) &
1717
+ (weather_df.index.day >= start_dt.day) &
1718
+ (weather_df.index.hour >= start_dt.hour))) &
1719
+ ((weather_df.index.month < end_dt.month) |
1720
+ ((weather_df.index.month == end_dt.month) &
1721
+ (weather_df.index.day <= end_dt.day) &
1722
+ (weather_df.index.hour <= end_dt.hour)))
1723
+ ]
1724
+ if df_period.empty:
1725
+ raise ValueError("No weather data in specified period.")
1726
+
1727
+ # Convert to local time, then to UTC
1728
+ df_period_local = df_period.copy()
1729
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
1730
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
1731
+
1732
+ # Get solar positions (allow precomputed to avoid recomputation)
1733
+ precomputed_solar_positions = kwargs.get("precomputed_solar_positions", None)
1734
+ if precomputed_solar_positions is not None and len(precomputed_solar_positions) == len(df_period_utc.index):
1735
+ solar_positions = precomputed_solar_positions
1736
+ else:
1737
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1738
+
1739
+ # Precompute arrays to avoid per-iteration pandas operations
1740
+ times_len = len(df_period_utc.index)
1741
+ azimuth_deg_arr = solar_positions['azimuth'].to_numpy()
1742
+ elev_deg_arr = solar_positions['elevation'].to_numpy()
1743
+ az_rad_arr = np.deg2rad(180.0 - azimuth_deg_arr)
1744
+ el_rad_arr = np.deg2rad(elev_deg_arr)
1745
+ sun_dx_arr = np.cos(el_rad_arr) * np.cos(az_rad_arr)
1746
+ sun_dy_arr = np.cos(el_rad_arr) * np.sin(az_rad_arr)
1747
+ sun_dz_arr = np.sin(el_rad_arr)
1748
+ sun_dirs_arr = np.stack([sun_dx_arr, sun_dy_arr, sun_dz_arr], axis=1).astype(np.float64)
1749
+ DNI_arr = (df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling).astype(np.float64)
1750
+ DHI_arr = (df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling).astype(np.float64)
1751
+ sun_above_mask = elev_deg_arr > 0.0
1752
+
1753
+ # Prepare arrays for accumulation
1754
+ n_faces = len(building_svf_mesh.faces)
1755
+ face_cum_direct = np.zeros(n_faces, dtype=np.float64)
1756
+ face_cum_diffuse = np.zeros(n_faces, dtype=np.float64)
1757
+ face_cum_global = np.zeros(n_faces, dtype=np.float64)
1758
+
1759
+ # Pre-extract mesh face arrays and domain bounds for fast path
1760
+ face_centers = building_svf_mesh.triangles_center
1761
+ face_normals = building_svf_mesh.face_normals
1762
+ face_svf = building_svf_mesh.metadata['svf'] if ('svf' in building_svf_mesh.metadata) else np.zeros(n_faces, dtype=np.float64)
1763
+ grid_shape = voxel_data.shape
1764
+ grid_bounds_voxel = np.array([[0, 0, 0], [grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=np.float64)
1765
+ grid_bounds_real = grid_bounds_voxel * meshsize
1766
+ boundary_epsilon = meshsize * 0.05
1767
+
1768
+ # Params used in Numba kernel
1769
+ hit_values = (0,) # sky
1770
+ inclusion_mode = False # any non-sky is obstacle but trees transmit
1771
+ tree_k = kwargs.get("tree_k", 0.6)
1772
+ tree_lad = kwargs.get("tree_lad", 1.0)
1773
+
1774
+ boundary_mask = None
1775
+ instant_kwargs = kwargs.copy()
1776
+ instant_kwargs['obj_export'] = False
1777
+
1778
+ total_steps = times_len
1779
+ progress_every = max(1, total_steps // 20) # ~5% steps
1780
+
1781
+ # Pre-cast stable arrays to avoid repeated allocations
1782
+ face_centers64 = building_svf_mesh.triangles_center.astype(np.float64)
1783
+ face_normals64 = building_svf_mesh.face_normals.astype(np.float64)
1784
+ face_svf64 = face_svf.astype(np.float64)
1785
+ x_min, y_min, z_min = grid_bounds_real[0, 0], grid_bounds_real[0, 1], grid_bounds_real[0, 2]
1786
+ x_max, y_max, z_max = grid_bounds_real[1, 0], grid_bounds_real[1, 1], grid_bounds_real[1, 2]
1787
+
1788
+ if fast_path:
1789
+ # Use masked cumulative kernel with chunking to minimize Python overhead
1790
+ vox_is_tree = (voxel_data == -2)
1791
+ vox_is_opaque = (voxel_data != 0) & (~vox_is_tree)
1792
+ att = float(np.exp(-tree_k * tree_lad * meshsize))
1793
+
1794
+ # Auto-tune chunk size if user didn't pass one
1795
+ time_batch_size = _auto_time_batch_size(n_faces, total_steps, kwargs.get("time_batch_size", None))
1796
+
1797
+ for start in range(0, total_steps, time_batch_size):
1798
+ end = min(start + time_batch_size, total_steps)
1799
+ # Accumulate Wh/m² over this chunk inside the kernel
1800
+ ch_dir, ch_diff, ch_glob = compute_cumulative_solar_irradiance_faces_masked_timeseries(
1801
+ face_centers64,
1802
+ face_normals64,
1803
+ face_svf64,
1804
+ sun_dirs_arr,
1805
+ DNI_arr,
1806
+ DHI_arr,
1807
+ vox_is_tree,
1808
+ vox_is_opaque,
1809
+ float(meshsize),
1810
+ float(att),
1811
+ float(x_min), float(y_min), float(z_min),
1812
+ float(x_max), float(y_max), float(z_max),
1813
+ float(boundary_epsilon),
1814
+ int(start), int(end),
1815
+ float(time_step_hours)
1816
+ )
1817
+ face_cum_direct += ch_dir
1818
+ face_cum_diffuse += ch_diff
1819
+ face_cum_global += ch_glob
1820
+
1821
+ if progress_report:
1822
+ pct = (end * 100.0) / total_steps
1823
+ print(f"Cumulative irradiance: {end}/{total_steps} ({pct:.1f}%)")
1824
+ else:
1825
+ # Iterate per timestep (fallback)
1826
+ for idx in range(total_steps):
1827
+ DNI = float(DNI_arr[idx])
1828
+ DHI = float(DHI_arr[idx])
1829
+
1830
+ # Skip if sun below horizon
1831
+ if not sun_above_mask[idx]:
1832
+ # Only diffuse term contributes (still based on SVF)
1833
+ if boundary_mask is None:
1834
+ boundary_mask = np.isnan(face_svf)
1835
+ # Accumulate diffuse only
1836
+ face_cum_diffuse += np.nan_to_num(face_svf * DHI) * time_step_hours
1837
+ face_cum_global += np.nan_to_num(face_svf * DHI) * time_step_hours
1838
+ # progress
1839
+ if progress_report:
1840
+ if ((idx + 1) % progress_every == 0) or (idx == total_steps - 1):
1841
+ pct = (idx + 1) * 100.0 / total_steps
1842
+ print(f"Cumulative irradiance: {idx+1}/{total_steps} ({pct:.1f}%)")
1843
+ continue
1844
+
1845
+ # Fallback to wrapper per-timestep
1846
+ irr_mesh = get_building_solar_irradiance(
1847
+ voxel_data,
1848
+ meshsize,
1849
+ building_svf_mesh,
1850
+ float(azimuth_deg_arr[idx]),
1851
+ float(elev_deg_arr[idx]),
1852
+ DNI,
1853
+ DHI,
1854
+ show_plot=False,
1855
+ **instant_kwargs
1856
+ )
1857
+ face_direct = irr_mesh.metadata['direct']
1858
+ face_diffuse = irr_mesh.metadata['diffuse']
1859
+ face_global = irr_mesh.metadata['global']
1860
+
1861
+ # If first time, note boundary mask from NaNs
1862
+ if boundary_mask is None:
1863
+ boundary_mask = np.isnan(face_global)
1864
+
1865
+ # Convert from W/m² to Wh/m² by multiplying time_step_hours
1866
+ face_cum_direct += np.nan_to_num(face_direct) * time_step_hours
1867
+ face_cum_diffuse += np.nan_to_num(face_diffuse) * time_step_hours
1868
+ face_cum_global += np.nan_to_num(face_global) * time_step_hours
1869
+
1870
+ if progress_report and (((idx + 1) % progress_every == 0) or (idx == total_steps - 1)):
1871
+ pct = (idx + 1) * 100.0 / total_steps
1872
+ print(f"Cumulative irradiance: {idx+1}/{total_steps} ({pct:.1f}%)")
1873
+
1874
+ # Reapply NaN for boundary
1875
+ if boundary_mask is not None:
1876
+ face_cum_direct[boundary_mask] = np.nan
1877
+ face_cum_diffuse[boundary_mask] = np.nan
1878
+ face_cum_global[boundary_mask] = np.nan
1879
+
1880
+ # Create a new mesh with cumulative results
1881
+ cumulative_mesh = building_svf_mesh.copy()
1882
+ if not hasattr(cumulative_mesh, 'metadata'):
1883
+ cumulative_mesh.metadata = {}
1884
+
1885
+ # If original mesh had SVF
1886
+ if 'svf' in building_svf_mesh.metadata:
1887
+ cumulative_mesh.metadata['svf'] = building_svf_mesh.metadata['svf']
1888
+
1889
+ cumulative_mesh.metadata['direct'] = face_cum_direct
1890
+ cumulative_mesh.metadata['diffuse'] = face_cum_diffuse
1891
+ cumulative_mesh.metadata['global'] = face_cum_global
1892
+
1893
+ cumulative_mesh.name = "Cumulative Solar Irradiance (Wh/m²)"
1894
+
1895
+ # # Optional OBJ export
1896
+ # obj_export = kwargs.get("obj_export", False)
1897
+ # if obj_export:
1898
+ # # Get export parameters
1899
+ # output_dir = kwargs.get("output_directory", "output")
1900
+ # output_file_name = kwargs.get("output_file_name", "solar_irradiance")
1901
+
1902
+ # # Export the mesh directly
1903
+ # irradiance_mesh.export(f"{output_dir}/{output_file_name}.obj")
1904
+
1905
+ return cumulative_mesh
1906
+
1907
+ def get_building_global_solar_irradiance_using_epw(
1908
+ voxel_data,
1909
+ meshsize,
1910
+ calc_type='instantaneous',
1911
+ direct_normal_irradiance_scaling=1.0,
1912
+ diffuse_irradiance_scaling=1.0,
1913
+ **kwargs
1914
+ ):
1915
+ """
1916
+ Compute global solar irradiance on building surfaces using EPW weather data, either for a single time or cumulatively.
1917
+
1918
+ The function:
1919
+ 1. Optionally downloads and reads EPW weather data
1920
+ 2. Handles timezone conversions and solar position calculations
1921
+ 3. Computes either instantaneous or cumulative irradiance on building surfaces
1922
+ 4. Supports visualization and export options
1923
+
1924
+ Args:
1925
+ voxel_data (ndarray): 3D array of voxel values.
1926
+ meshsize (float): Size of each voxel in meters.
1927
+ building_svf_mesh (trimesh.Trimesh): Building mesh with pre-calculated SVF values in metadata.
1928
+ calc_type (str): 'instantaneous' or 'cumulative'.
1929
+ direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
1930
+ diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
1931
+ **kwargs: Additional arguments including:
1932
+ - download_nearest_epw (bool): Whether to download nearest EPW file
1933
+ - epw_file_path (str): Path to EPW file
1934
+ - rectangle_vertices (list): List of (lon,lat) coordinates for EPW download
1935
+ - output_dir (str): Directory for EPW download
1936
+ - calc_time (str): Time for instantaneous calculation ('MM-DD HH:MM:SS')
1937
+ - period_start (str): Start time for cumulative calculation ('MM-DD HH:MM:SS')
1938
+ - period_end (str): End time for cumulative calculation ('MM-DD HH:MM:SS')
1939
+ - time_step_hours (float): Time step for cumulative calculation
1940
+ - tree_k (float): Tree extinction coefficient
1941
+ - tree_lad (float): Leaf area density in m^-1
1942
+ - show_each_timestep (bool): Whether to show plots for each timestep
1943
+ - nan_color (str): Color for NaN values in visualization
1944
+ - colormap (str): Matplotlib colormap name
1945
+ - vmin (float): Minimum value for colormap
1946
+ - vmax (float): Maximum value for colormap
1947
+ - obj_export (bool): Whether to export as OBJ file
1948
+ - output_directory (str): Directory for OBJ export
1949
+ - output_file_name (str): Filename for OBJ export
1950
+ - save_mesh (bool): Whether to save the mesh data using pickle
1951
+ - mesh_output_path (str): Path to save the mesh data (if save_mesh is True)
1952
+
1953
+ Returns:
1954
+ trimesh.Trimesh: Building mesh with irradiance values stored in metadata.
1955
+ """
1956
+ import numpy as np
1957
+ import pytz
1958
+ from datetime import datetime
1959
+
1960
+ # Get EPW file
1961
+ download_nearest_epw = kwargs.get("download_nearest_epw", False)
1962
+ rectangle_vertices = kwargs.get("rectangle_vertices", None)
1963
+ epw_file_path = kwargs.get("epw_file_path", None)
1964
+ building_id_grid = kwargs.get("building_id_grid", None)
1965
+ building_svf_mesh = kwargs.get("building_svf_mesh", None)
1966
+ progress_report = kwargs.get("progress_report", False)
1967
+ fast_path = kwargs.get("fast_path", True)
1968
+
1969
+ # Threading tuning (auto): choose sensible defaults based on hardware
1970
+ desired_threads = kwargs.get("numba_num_threads", None)
1971
+ _configure_num_threads(desired_threads, progress=kwargs.get("progress_report", False))
1972
+
1973
+ if download_nearest_epw:
1974
+ if rectangle_vertices is None:
1975
+ print("rectangle_vertices is required to download nearest EPW file")
1976
+ return None
1977
+ else:
1978
+ # Calculate center point of rectangle
1979
+ lons = [coord[0] for coord in rectangle_vertices]
1980
+ lats = [coord[1] for coord in rectangle_vertices]
1981
+ center_lon = (min(lons) + max(lons)) / 2
1982
+ center_lat = (min(lats) + max(lats)) / 2
1983
+
1984
+ # Optional: specify maximum distance in kilometers
1985
+ max_distance = kwargs.get("max_distance", 100) # None for no limit
1986
+ output_dir = kwargs.get("output_dir", "output")
1987
+
1988
+ epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
1989
+ longitude=center_lon,
1990
+ latitude=center_lat,
1991
+ output_dir=output_dir,
1992
+ max_distance=max_distance,
1993
+ extract_zip=True,
1994
+ load_data=True
1995
+ )
1996
+
1997
+ # Read EPW data
1998
+ df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
1999
+ if df.empty:
2000
+ raise ValueError("No data in EPW file.")
2001
+
2002
+ # Step 1: Calculate Sky View Factor for building surfaces (unless provided)
2003
+ if building_svf_mesh is None:
2004
+ if progress_report:
2005
+ print("Processing Sky View Factor for building surfaces...")
2006
+ # Allow passing through specific SVF parameters via kwargs
2007
+ svf_kwargs = {
2008
+ 'value_name': 'svf',
2009
+ 'target_values': (0,),
2010
+ 'inclusion_mode': False,
2011
+ 'building_id_grid': building_id_grid,
2012
+ 'progress_report': progress_report,
2013
+ 'fast_path': fast_path,
2014
+ }
2015
+ # Permit overrides
2016
+ for k in ("N_azimuth","N_elevation","tree_k","tree_lad","debug"):
2017
+ if k in kwargs:
2018
+ svf_kwargs[k] = kwargs[k]
2019
+ building_svf_mesh = get_surface_view_factor(
2020
+ voxel_data,
2021
+ meshsize,
2022
+ **svf_kwargs
2023
+ )
2024
+
2025
+ if progress_report:
2026
+ print(f"Processing Solar Irradiance for building surfaces...")
2027
+ result_mesh = None
2028
+
2029
+ if calc_type == 'instantaneous':
2030
+ calc_time = kwargs.get("calc_time", "01-01 12:00:00")
2031
+
2032
+ # Parse calculation time without year
2033
+ try:
2034
+ calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
2035
+ except ValueError as ve:
2036
+ raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
2037
+
2038
+ df_period = df[
2039
+ (df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
2040
+ ]
2041
+
2042
+ if df_period.empty:
2043
+ raise ValueError("No EPW data at the specified time.")
2044
+
2045
+ # Prepare timezone conversion
2046
+ offset_minutes = int(tz * 60)
2047
+ local_tz = pytz.FixedOffset(offset_minutes)
2048
+ df_period_local = df_period.copy()
2049
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
2050
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
2051
+
2052
+ # Compute solar positions
2053
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
2054
+
2055
+ # Scale irradiance values
2056
+ direct_normal_irradiance = df_period_utc.iloc[0]['DNI'] * direct_normal_irradiance_scaling
2057
+ diffuse_irradiance = df_period_utc.iloc[0]['DHI'] * diffuse_irradiance_scaling
2058
+
2059
+ # Get solar position
2060
+ azimuth_degrees = solar_positions.iloc[0]['azimuth']
2061
+ elevation_degrees = solar_positions.iloc[0]['elevation']
2062
+
2063
+ if progress_report:
2064
+ print(f"Time: {df_period_local.index[0].strftime('%Y-%m-%d %H:%M:%S')}")
2065
+ print(f"Sun position: Azimuth {azimuth_degrees:.1f}°, Elevation {elevation_degrees:.1f}°")
2066
+ print(f"DNI: {direct_normal_irradiance:.1f} W/m², DHI: {diffuse_irradiance:.1f} W/m²")
2067
+
2068
+ # Skip if sun is below horizon
2069
+ if elevation_degrees <= 0:
2070
+ if progress_report:
2071
+ print("Sun is below horizon, skipping calculation.")
2072
+ result_mesh = building_svf_mesh.copy()
2073
+ else:
2074
+ # Compute irradiance
2075
+ _call_kwargs = kwargs.copy()
2076
+ if 'progress_report' in _call_kwargs:
2077
+ _call_kwargs.pop('progress_report')
2078
+ result_mesh = get_building_solar_irradiance(
2079
+ voxel_data,
2080
+ meshsize,
2081
+ building_svf_mesh,
2082
+ azimuth_degrees,
2083
+ elevation_degrees,
2084
+ direct_normal_irradiance,
2085
+ diffuse_irradiance,
2086
+ progress_report=progress_report,
2087
+ **_call_kwargs
2088
+ )
2089
+
2090
+ elif calc_type == 'cumulative':
2091
+ # Set default parameters
2092
+ period_start = kwargs.get("period_start", "01-01 00:00:00")
2093
+ period_end = kwargs.get("period_end", "12-31 23:59:59")
2094
+ time_step_hours = kwargs.get("time_step_hours", 1.0)
2095
+
2096
+ # Parse start and end times without year
2097
+ try:
2098
+ start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
2099
+ end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
2100
+ except ValueError as ve:
2101
+ raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
2102
+
2103
+ # Create local timezone
2104
+ offset_minutes = int(tz * 60)
2105
+ local_tz = pytz.FixedOffset(offset_minutes)
2106
+
2107
+ # Filter weather data by month, day, hour
2108
+ df_period = df[
2109
+ ((df.index.month > start_dt.month) |
2110
+ ((df.index.month == start_dt.month) & (df.index.day >= start_dt.day) &
2111
+ (df.index.hour >= start_dt.hour))) &
2112
+ ((df.index.month < end_dt.month) |
2113
+ ((df.index.month == end_dt.month) & (df.index.day <= end_dt.day) &
2114
+ (df.index.hour <= end_dt.hour)))
2115
+ ]
2116
+
2117
+ if df_period.empty:
2118
+ raise ValueError("No weather data available for the specified period.")
2119
+
2120
+ # Convert to local timezone and then to UTC for solar position calculation
2121
+ df_period_local = df_period.copy()
2122
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
2123
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
2124
+
2125
+ # Get solar positions for all times
2126
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
2127
+
2128
+ # Create a copy of kwargs without time_step_hours to avoid duplicate argument
2129
+ kwargs_copy = kwargs.copy()
2130
+ if 'time_step_hours' in kwargs_copy:
2131
+ del kwargs_copy['time_step_hours']
2132
+
2133
+ # Get cumulative irradiance - adapt to match expected function signature
2134
+ if progress_report:
2135
+ print(f"Calculating cumulative irradiance from {period_start} to {period_end}...")
2136
+ result_mesh = get_cumulative_building_solar_irradiance(
2137
+ voxel_data,
2138
+ meshsize,
2139
+ building_svf_mesh,
2140
+ df, lon, lat, tz, # Pass only the required 7 positional arguments
2141
+ period_start=period_start,
2142
+ period_end=period_end,
2143
+ time_step_hours=time_step_hours,
2144
+ direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
2145
+ diffuse_irradiance_scaling=diffuse_irradiance_scaling,
2146
+ progress_report=progress_report,
2147
+ fast_path=fast_path,
2148
+ precomputed_solar_positions=solar_positions,
2149
+ colormap=kwargs.get('colormap', 'jet'),
2150
+ show_each_timestep=kwargs.get('show_each_timestep', False),
2151
+ obj_export=kwargs.get('obj_export', False),
2152
+ output_directory=kwargs.get('output_directory', 'output'),
2153
+ output_file_name=kwargs.get('output_file_name', 'cumulative_solar')
2154
+ )
2155
+
2156
+ else:
2157
+ raise ValueError("calc_type must be either 'instantaneous' or 'cumulative'")
2158
+
2159
+ # Save mesh data if requested
2160
+ save_mesh = kwargs.get("save_mesh", False)
2161
+ if save_mesh:
2162
+ mesh_output_path = kwargs.get("mesh_output_path", None)
2163
+ if mesh_output_path is None:
2164
+ # Generate default path if none provided
2165
+ output_directory = kwargs.get("output_directory", "output")
2166
+ output_file_name = kwargs.get("output_file_name", f"{calc_type}_solar_irradiance")
2167
+ mesh_output_path = f"{output_directory}/{output_file_name}.pkl"
2168
+
2169
+ save_irradiance_mesh(result_mesh, mesh_output_path)
2170
+ print(f"Saved irradiance mesh data to: {mesh_output_path}")
2171
+
2172
+ return result_mesh
2173
+
2174
+ def save_irradiance_mesh(irradiance_mesh, output_file_path):
2175
+ """
2176
+ Save the irradiance mesh data to a file using pickle serialization.
2177
+
2178
+ This function provides persistent storage for computed irradiance results,
2179
+ enabling reuse of expensive calculations and sharing of results between
2180
+ analysis sessions. The mesh data includes all geometry, irradiance values,
2181
+ and metadata required for visualization and further analysis.
2182
+
2183
+ Serialization Benefits:
2184
+ - Preserves complete mesh structure with all computed data
2185
+ - Enables offline analysis and visualization workflows
2186
+ - Supports sharing results between different tools and users
2187
+ - Avoids recomputation of expensive irradiance calculations
2188
+
2189
+ Data Preservation:
2190
+ - All mesh geometry (vertices, faces, normals)
2191
+ - Computed irradiance values (direct, diffuse, global)
2192
+ - Sky View Factor data and other metadata
2193
+ - Material properties and visualization settings
2194
+
2195
+ Args:
2196
+ irradiance_mesh (trimesh.Trimesh): Mesh with irradiance data in metadata
2197
+ Should contain computed irradiance results
2198
+ output_file_path (str): Path to save the mesh data file
2199
+ Recommended extension: .pkl for clarity
2200
+
2201
+ Note:
2202
+ The function automatically creates the output directory if it doesn't exist.
2203
+ Use pickle format for maximum compatibility with Python data structures.
2204
+ """
2205
+ import pickle
2206
+ import os
2207
+
2208
+ # Create output directory structure if it doesn't exist
2209
+ os.makedirs(os.path.dirname(output_file_path), exist_ok=True)
2210
+
2211
+ # Serialize mesh data using pickle for complete data preservation
2212
+ with open(output_file_path, 'wb') as f:
2213
+ pickle.dump(irradiance_mesh, f)
2214
+
2215
+ def load_irradiance_mesh(input_file_path):
2216
+ """
2217
+ Load previously saved irradiance mesh data from a file.
2218
+
2219
+ This function restores complete mesh data including geometry, computed
2220
+ irradiance values, and all associated metadata. It enables continuation
2221
+ of analysis workflows and reuse of expensive computation results.
2222
+
2223
+ Restoration Capabilities:
2224
+ - Complete mesh geometry with all topological information
2225
+ - All computed irradiance data (direct, diffuse, global components)
2226
+ - Sky View Factor values and analysis metadata
2227
+ - Visualization settings and material properties
2228
+
2229
+ Workflow Integration:
2230
+ - Load results from previous analysis sessions
2231
+ - Share computed data between team members
2232
+ - Perform post-processing and visualization
2233
+ - Compare results from different scenarios
2234
+
2235
+ Args:
2236
+ input_file_path (str): Path to the saved mesh data file
2237
+ Should be a file created by save_irradiance_mesh()
2238
+
2239
+ Returns:
2240
+ trimesh.Trimesh: Complete mesh with all irradiance data in metadata
2241
+ Ready for visualization, analysis, or further processing
2242
+
2243
+ Note:
2244
+ The loaded mesh maintains all original data structure and can be used
2245
+ immediately for visualization or additional analysis operations.
2246
+ """
2247
+ import pickle
2248
+
2249
+ # Deserialize mesh data preserving all original structure
2250
+ with open(input_file_path, 'rb') as f:
2251
+ irradiance_mesh = pickle.load(f)
2252
+
1821
2253
  return irradiance_mesh