voxcity 0.6.26__py3-none-any.whl → 0.7.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.
Files changed (75) hide show
  1. voxcity/__init__.py +14 -8
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/gba.py +210 -0
  4. voxcity/downloader/gee.py +5 -1
  5. voxcity/downloader/mbfp.py +1 -1
  6. voxcity/downloader/oemj.py +80 -8
  7. voxcity/downloader/utils.py +73 -73
  8. voxcity/errors.py +30 -0
  9. voxcity/exporter/__init__.py +13 -5
  10. voxcity/exporter/cityles.py +633 -538
  11. voxcity/exporter/envimet.py +728 -708
  12. voxcity/exporter/magicavoxel.py +334 -297
  13. voxcity/exporter/netcdf.py +238 -211
  14. voxcity/exporter/obj.py +1481 -1406
  15. voxcity/generator/__init__.py +44 -0
  16. voxcity/generator/api.py +675 -0
  17. voxcity/generator/grids.py +379 -0
  18. voxcity/generator/io.py +94 -0
  19. voxcity/generator/pipeline.py +282 -0
  20. voxcity/generator/voxelizer.py +380 -0
  21. voxcity/geoprocessor/__init__.py +75 -6
  22. voxcity/geoprocessor/conversion.py +153 -0
  23. voxcity/geoprocessor/draw.py +62 -12
  24. voxcity/geoprocessor/heights.py +199 -0
  25. voxcity/geoprocessor/io.py +101 -0
  26. voxcity/geoprocessor/merge_utils.py +91 -0
  27. voxcity/geoprocessor/mesh.py +806 -790
  28. voxcity/geoprocessor/network.py +708 -679
  29. voxcity/geoprocessor/overlap.py +84 -0
  30. voxcity/geoprocessor/raster/__init__.py +82 -0
  31. voxcity/geoprocessor/raster/buildings.py +428 -0
  32. voxcity/geoprocessor/raster/canopy.py +258 -0
  33. voxcity/geoprocessor/raster/core.py +150 -0
  34. voxcity/geoprocessor/raster/export.py +93 -0
  35. voxcity/geoprocessor/raster/landcover.py +156 -0
  36. voxcity/geoprocessor/raster/raster.py +110 -0
  37. voxcity/geoprocessor/selection.py +85 -0
  38. voxcity/geoprocessor/utils.py +18 -14
  39. voxcity/models.py +113 -0
  40. voxcity/simulator/common/__init__.py +22 -0
  41. voxcity/simulator/common/geometry.py +98 -0
  42. voxcity/simulator/common/raytracing.py +450 -0
  43. voxcity/simulator/solar/__init__.py +43 -0
  44. voxcity/simulator/solar/integration.py +336 -0
  45. voxcity/simulator/solar/kernels.py +62 -0
  46. voxcity/simulator/solar/radiation.py +648 -0
  47. voxcity/simulator/solar/temporal.py +434 -0
  48. voxcity/simulator/view.py +36 -2286
  49. voxcity/simulator/visibility/__init__.py +29 -0
  50. voxcity/simulator/visibility/landmark.py +392 -0
  51. voxcity/simulator/visibility/view.py +508 -0
  52. voxcity/utils/logging.py +61 -0
  53. voxcity/utils/orientation.py +51 -0
  54. voxcity/utils/weather/__init__.py +26 -0
  55. voxcity/utils/weather/epw.py +146 -0
  56. voxcity/utils/weather/files.py +36 -0
  57. voxcity/utils/weather/onebuilding.py +486 -0
  58. voxcity/visualizer/__init__.py +24 -0
  59. voxcity/visualizer/builder.py +43 -0
  60. voxcity/visualizer/grids.py +141 -0
  61. voxcity/visualizer/maps.py +187 -0
  62. voxcity/visualizer/palette.py +228 -0
  63. voxcity/visualizer/renderer.py +928 -0
  64. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/METADATA +107 -34
  65. voxcity-0.7.0.dist-info/RECORD +77 -0
  66. voxcity/generator.py +0 -1302
  67. voxcity/geoprocessor/grid.py +0 -1739
  68. voxcity/geoprocessor/polygon.py +0 -1344
  69. voxcity/simulator/solar.py +0 -2339
  70. voxcity/utils/visualization.py +0 -2849
  71. voxcity/utils/weather.py +0 -1038
  72. voxcity-0.6.26.dist-info/RECORD +0 -38
  73. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/WHEEL +0 -0
  74. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/licenses/AUTHORS.rst +0 -0
  75. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,2339 +0,0 @@
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 (0° = 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 (0° = 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: 0° = 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
- allow_insecure_ssl=kwargs.get("allow_insecure_ssl", False),
1012
- allow_http_fallback=kwargs.get("allow_http_fallback", False),
1013
- ssl_verify=kwargs.get("ssl_verify", True)
1014
- )
1015
-
1016
- # Read EPW data
1017
- if epw_file_path is None:
1018
- raise RuntimeError("EPW file path is None. Set 'epw_file_path' or enable 'download_nearest_epw' and ensure network succeeds.")
1019
- df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
1020
- if df.empty:
1021
- raise ValueError("No data in EPW file.")
1022
-
1023
- if calc_type == 'instantaneous':
1024
-
1025
- calc_time = kwargs.get("calc_time", "01-01 12:00:00")
1026
-
1027
- # Parse start and end times without year
1028
- try:
1029
- calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
1030
- except ValueError as ve:
1031
- raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
1032
-
1033
- df_period = df[
1034
- (df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
1035
- ]
1036
-
1037
- if df_period.empty:
1038
- raise ValueError("No EPW data at the specified time.")
1039
-
1040
- # Prepare timezone conversion
1041
- offset_minutes = int(tz * 60)
1042
- local_tz = pytz.FixedOffset(offset_minutes)
1043
- df_period_local = df_period.copy()
1044
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
1045
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
1046
-
1047
- # Compute solar positions
1048
- solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1049
- direct_normal_irradiance = df_period_utc.iloc[0]['DNI']
1050
- diffuse_irradiance = df_period_utc.iloc[0]['DHI']
1051
- azimuth_degrees = solar_positions.iloc[0]['azimuth']
1052
- elevation_degrees = solar_positions.iloc[0]['elevation']
1053
- solar_map = get_global_solar_irradiance_map(
1054
- voxel_data, # 3D voxel grid representing the urban environment
1055
- meshsize, # Size of each grid cell in meters
1056
- azimuth_degrees, # Sun's azimuth angle
1057
- elevation_degrees, # Sun's elevation angle
1058
- direct_normal_irradiance, # Direct Normal Irradiance value
1059
- diffuse_irradiance, # Diffuse irradiance value
1060
- show_plot=True, # Display visualization of results
1061
- **kwargs
1062
- )
1063
- if calc_type == 'cumulative':
1064
- # Get time window parameters
1065
- start_hour = kwargs.get("start_hour", 0) # Default to midnight
1066
- end_hour = kwargs.get("end_hour", 23) # Default to 11 PM
1067
-
1068
- # Filter dataframe for specified hours
1069
- df_filtered = df[(df.index.hour >= start_hour) & (df.index.hour <= end_hour)]
1070
-
1071
- solar_map = get_cumulative_global_solar_irradiance(
1072
- voxel_data,
1073
- meshsize,
1074
- df_filtered, lon, lat, tz,
1075
- **kwargs
1076
- )
1077
-
1078
- return solar_map
1079
-
1080
- import numpy as np
1081
- import trimesh
1082
- import time
1083
- from numba import njit, prange
1084
-
1085
- ##############################################################################
1086
- # 1) New Numba helper: per-face solar irradiance computation
1087
- ##############################################################################
1088
- @njit(parallel=True)
1089
- def compute_solar_irradiance_for_all_faces(
1090
- face_centers,
1091
- face_normals,
1092
- face_svf,
1093
- sun_direction,
1094
- direct_normal_irradiance,
1095
- diffuse_irradiance,
1096
- voxel_data,
1097
- meshsize,
1098
- tree_k,
1099
- tree_lad,
1100
- hit_values,
1101
- inclusion_mode,
1102
- grid_bounds_real,
1103
- boundary_epsilon
1104
- ):
1105
- """
1106
- Numba-compiled function to compute direct, diffuse, and global solar irradiance
1107
- for each face in a 3D building mesh.
1108
-
1109
- This optimized function processes all mesh faces in parallel to calculate solar
1110
- irradiance components. It handles both direct radiation (dependent on sun position
1111
- and surface orientation) and diffuse radiation (dependent on sky visibility).
1112
- The function is compiled with Numba for high-performance computation on large meshes.
1113
-
1114
- Surface Irradiance Physics:
1115
- - Direct component: DNI × cos(incidence_angle) × transmittance
1116
- - Diffuse component: DHI × sky_view_factor
1117
- - Incidence angle: Angle between sun direction and surface normal
1118
- - Transmittance: Attenuation factor from obstacles and vegetation
1119
-
1120
- Boundary Condition Handling:
1121
- - Vertical boundary faces are excluded (mesh edges touching domain boundaries)
1122
- - Invalid faces (NaN SVF) are skipped to maintain data consistency
1123
- - Surface orientation affects direct radiation calculation
1124
-
1125
- Performance Optimizations:
1126
- - Numba JIT compilation for near C-speed execution
1127
- - Parallel processing of face calculations
1128
- - Efficient geometric computations using vectorized operations
1129
- - Memory-optimized array operations
1130
-
1131
- Args:
1132
- face_centers (float64[:, :]): (N x 3) array of face center coordinates in real-world units
1133
- face_normals (float64[:, :]): (N x 3) array of outward-pointing unit normal vectors
1134
- face_svf (float64[:]): (N,) array of Sky View Factor values for each face (0.0-1.0)
1135
- sun_direction (float64[:]): (3,) array for normalized sun direction vector (dx, dy, dz)
1136
- direct_normal_irradiance (float): Direct normal irradiance (DNI) in W/m²
1137
- diffuse_irradiance (float): Diffuse horizontal irradiance (DHI) in W/m²
1138
- voxel_data (ndarray): 3D array of voxel values for obstacle detection
1139
- meshsize (float): Size of each voxel in meters (spatial resolution)
1140
- tree_k (float): Tree extinction coefficient for Beer-Lambert law
1141
- tree_lad (float): Leaf area density in m^-1
1142
- hit_values (tuple): Values considered 'sky' for ray tracing (e.g. (0,))
1143
- inclusion_mode (bool): Whether hit_values are included (True) or excluded (False)
1144
- grid_bounds_real (float64[2,3]): Domain boundaries [[x_min,y_min,z_min],[x_max,y_max,z_max]]
1145
- boundary_epsilon (float): Distance threshold for boundary face detection
1146
-
1147
- Returns:
1148
- tuple: Three float64[N] arrays containing:
1149
- - direct_irr: Direct solar irradiance for each face (W/m²)
1150
- - diffuse_irr: Diffuse solar irradiance for each face (W/m²)
1151
- - global_irr: Global solar irradiance for each face (W/m²)
1152
-
1153
- Note:
1154
- This function is optimized with Numba and should not be called directly.
1155
- Use the higher-level wrapper functions for normal operation.
1156
- """
1157
- n_faces = face_centers.shape[0]
1158
-
1159
- # Initialize output arrays for each irradiance component
1160
- face_direct = np.zeros(n_faces, dtype=np.float64)
1161
- face_diffuse = np.zeros(n_faces, dtype=np.float64)
1162
- face_global = np.zeros(n_faces, dtype=np.float64)
1163
-
1164
- # Extract domain boundaries for boundary face detection
1165
- x_min, y_min, z_min = grid_bounds_real[0, 0], grid_bounds_real[0, 1], grid_bounds_real[0, 2]
1166
- x_max, y_max, z_max = grid_bounds_real[1, 0], grid_bounds_real[1, 1], grid_bounds_real[1, 2]
1167
-
1168
- # Process each face individually (Numba optimizes this loop)
1169
- for fidx in prange(n_faces):
1170
- center = face_centers[fidx]
1171
- normal = face_normals[fidx]
1172
- svf = face_svf[fidx]
1173
-
1174
- # Check for vertical boundary faces that should be excluded
1175
- # These are mesh edges at domain boundaries, not actual building surfaces
1176
- is_vertical = (abs(normal[2]) < 0.01) # Nearly vertical normal
1177
-
1178
- # Check if face center is at domain boundary
1179
- on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
1180
- on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
1181
- on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
1182
- on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
1183
-
1184
- is_boundary_vertical = is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max)
1185
-
1186
- # Skip boundary faces to avoid artifacts
1187
- if is_boundary_vertical:
1188
- face_direct[fidx] = np.nan
1189
- face_diffuse[fidx] = np.nan
1190
- face_global[fidx] = np.nan
1191
- continue
1192
-
1193
- # Skip faces with invalid SVF data
1194
- if svf != svf: # NaN check in Numba-compatible way
1195
- face_direct[fidx] = np.nan
1196
- face_diffuse[fidx] = np.nan
1197
- face_global[fidx] = np.nan
1198
- continue
1199
-
1200
- # Calculate direct irradiance component
1201
- # Only surfaces oriented towards the sun receive direct radiation
1202
- cos_incidence = normal[0]*sun_direction[0] + \
1203
- normal[1]*sun_direction[1] + \
1204
- normal[2]*sun_direction[2]
1205
-
1206
- direct_val = 0.0
1207
- if cos_incidence > 0.0: # Surface faces towards sun
1208
- # Offset ray origin slightly along normal to avoid self-intersection
1209
- offset_vox = 0.1 # Small offset in voxel units
1210
- ray_origin_x = center[0]/meshsize + normal[0]*offset_vox
1211
- ray_origin_y = center[1]/meshsize + normal[1]*offset_vox
1212
- ray_origin_z = center[2]/meshsize + normal[2]*offset_vox
1213
-
1214
- # Cast ray toward the sun to check for obstructions
1215
- hit_detected, transmittance = trace_ray_generic(
1216
- voxel_data,
1217
- np.array([ray_origin_x, ray_origin_y, ray_origin_z], dtype=np.float64),
1218
- sun_direction,
1219
- hit_values,
1220
- meshsize,
1221
- tree_k,
1222
- tree_lad,
1223
- inclusion_mode
1224
- )
1225
-
1226
- # Calculate direct irradiance if path to sun is clear/partially clear
1227
- if not hit_detected:
1228
- direct_val = direct_normal_irradiance * cos_incidence * transmittance
1229
-
1230
- # Calculate diffuse irradiance component using Sky View Factor
1231
- # All surfaces receive diffuse radiation proportional to their sky visibility
1232
- diffuse_val = svf * diffuse_irradiance
1233
-
1234
- # Ensure diffuse irradiance doesn't exceed theoretical maximum
1235
- if diffuse_val > diffuse_irradiance:
1236
- diffuse_val = diffuse_irradiance
1237
-
1238
- # Store results for this face
1239
- face_direct[fidx] = direct_val
1240
- face_diffuse[fidx] = diffuse_val
1241
- face_global[fidx] = direct_val + diffuse_val
1242
-
1243
- return face_direct, face_diffuse, face_global
1244
-
1245
-
1246
- ##############################################################################
1247
- # 2) Modified get_building_solar_irradiance: main Python wrapper
1248
- ##############################################################################
1249
- def get_building_solar_irradiance(
1250
- voxel_data,
1251
- meshsize,
1252
- building_svf_mesh,
1253
- azimuth_degrees,
1254
- elevation_degrees,
1255
- direct_normal_irradiance,
1256
- diffuse_irradiance,
1257
- **kwargs
1258
- ):
1259
- """
1260
- Calculate solar irradiance on building surfaces using Sky View Factor (SVF) analysis,
1261
- with high-performance computation accelerated by Numba JIT compilation.
1262
-
1263
- This function performs detailed solar irradiance analysis on 3D building surfaces
1264
- represented as triangulated meshes. It calculates both direct and diffuse components
1265
- of solar radiation for each mesh face, accounting for surface orientation, shadows,
1266
- and sky visibility. The computation is optimized for large urban models using
1267
- efficient algorithms and parallel processing.
1268
-
1269
- Mesh-Based Analysis Advantages:
1270
- - Surface-specific calculations for facades, roofs, and complex geometries
1271
- - Accurate accounting of surface orientation and local shading effects
1272
- - Integration with 3D visualization and CAD workflows
1273
- - Detailed irradiance data for building energy modeling
1274
-
1275
- Performance Features:
1276
- - Numba JIT compilation for near C-speed execution
1277
- - Parallel processing of mesh faces
1278
- - Efficient ray tracing with tree transmittance
1279
- - Memory-optimized operations for large datasets
1280
-
1281
- Physical Modeling:
1282
- - Direct irradiance: Based on sun position and surface orientation
1283
- - Diffuse irradiance: Based on Sky View Factor from each surface
1284
- - Tree effects: Partial transmittance using Beer-Lambert law
1285
- - Boundary handling: Automatic exclusion of domain boundary artifacts
1286
-
1287
- Args:
1288
- voxel_data (ndarray): 3D array of voxel values representing the urban environment
1289
- meshsize (float): Size of each voxel in meters (spatial resolution)
1290
- building_svf_mesh (trimesh.Trimesh): Building mesh with pre-calculated SVF values in metadata
1291
- Must have 'svf' array in mesh.metadata
1292
- azimuth_degrees (float): Sun azimuth angle in degrees (0=North, 90=East)
1293
- elevation_degrees (float): Sun elevation angle in degrees above horizon (0-90°)
1294
- direct_normal_irradiance (float): Direct normal irradiance (DNI) in W/m² from weather data
1295
- diffuse_irradiance (float): Diffuse horizontal irradiance (DHI) in W/m² from weather data
1296
- **kwargs: Additional parameters including:
1297
- - tree_k (float): Tree extinction coefficient (default: 0.6)
1298
- Higher values mean trees block more light
1299
- - tree_lad (float): Leaf area density in m^-1 (default: 1.0)
1300
- Affects light attenuation through tree canopies
1301
- - progress_report (bool): Whether to print timing information (default: False)
1302
- - obj_export (bool): Whether to export results as OBJ file
1303
- - output_directory (str): Directory for file exports
1304
- - output_file_name (str): Base filename for exports
1305
-
1306
- Returns:
1307
- trimesh.Trimesh: A copy of the input mesh with irradiance data stored in metadata:
1308
- - 'svf': Sky View Factor for each face (preserved from input)
1309
- - 'direct': Direct solar irradiance for each face (W/m²)
1310
- - 'diffuse': Diffuse solar irradiance for each face (W/m²)
1311
- - 'global': Global solar irradiance for each face (W/m²)
1312
-
1313
- Note:
1314
- The input mesh must have SVF values pre-calculated and stored in metadata.
1315
- Use get_surface_view_factor() to compute SVF before calling this function.
1316
- """
1317
- import time
1318
-
1319
- # Extract tree transmittance parameters with defaults
1320
- tree_k = kwargs.get("tree_k", 0.6)
1321
- tree_lad = kwargs.get("tree_lad", 1.0)
1322
- progress_report = kwargs.get("progress_report", False)
1323
-
1324
- # Define sky detection parameters for ray tracing
1325
- hit_values = (0,) # '0' = sky voxel value
1326
- inclusion_mode = False # Treat non-sky values as obstacles
1327
-
1328
- # Convert solar angles to 3D direction vector using spherical coordinates
1329
- az_rad = np.deg2rad(180 - azimuth_degrees) # Adjust for coordinate system
1330
- el_rad = np.deg2rad(elevation_degrees)
1331
- sun_dx = np.cos(el_rad) * np.cos(az_rad)
1332
- sun_dy = np.cos(el_rad) * np.sin(az_rad)
1333
- sun_dz = np.sin(el_rad)
1334
- sun_direction = np.array([sun_dx, sun_dy, sun_dz], dtype=np.float64)
1335
-
1336
- # Extract mesh geometry data for processing (optionally from cache)
1337
- precomputed_geometry = kwargs.get("precomputed_geometry", None)
1338
- if precomputed_geometry is not None:
1339
- face_centers = precomputed_geometry.get("face_centers", building_svf_mesh.triangles_center)
1340
- face_normals = precomputed_geometry.get("face_normals", building_svf_mesh.face_normals)
1341
- face_svf_opt = precomputed_geometry.get("face_svf", None)
1342
- grid_bounds_real = precomputed_geometry.get("grid_bounds_real", None)
1343
- boundary_epsilon = precomputed_geometry.get("boundary_epsilon", None)
1344
- else:
1345
- face_centers = building_svf_mesh.triangles_center # Center point of each face
1346
- face_normals = building_svf_mesh.face_normals # Normal vector for each face
1347
-
1348
- # Extract Sky View Factor data from mesh metadata
1349
- if hasattr(building_svf_mesh, 'metadata') and ('svf' in building_svf_mesh.metadata):
1350
- face_svf = building_svf_mesh.metadata['svf']
1351
- else:
1352
- # Initialize with zeros if SVF not available (should be pre-calculated)
1353
- face_svf = np.zeros(len(building_svf_mesh.faces), dtype=np.float64)
1354
-
1355
- # Set up domain boundaries for boundary face detection (use cache if available)
1356
- if grid_bounds_real is None or boundary_epsilon is None:
1357
- grid_shape = voxel_data.shape
1358
- grid_bounds_voxel = np.array([[0,0,0],[grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=np.float64)
1359
- grid_bounds_real = grid_bounds_voxel * meshsize
1360
- boundary_epsilon = meshsize * 0.05 # Small tolerance for boundary detection
1361
-
1362
- # Optional fast path using masked DDA kernel
1363
- fast_path = kwargs.get("fast_path", True)
1364
- precomputed_masks = kwargs.get("precomputed_masks", None)
1365
- t0 = time.time()
1366
- if fast_path:
1367
- # Prepare masks (reuse cache when possible)
1368
- if precomputed_masks is not None:
1369
- vox_is_tree = precomputed_masks.get("vox_is_tree", (voxel_data == -2))
1370
- vox_is_opaque = precomputed_masks.get("vox_is_opaque", (voxel_data != 0) & (voxel_data != -2))
1371
- att = float(precomputed_masks.get("att", np.exp(-tree_k * tree_lad * meshsize)))
1372
- else:
1373
- vox_is_tree = (voxel_data == -2)
1374
- vox_is_opaque = (voxel_data != 0) & (~vox_is_tree)
1375
- att = float(np.exp(-tree_k * tree_lad * meshsize))
1376
-
1377
- face_direct, face_diffuse, face_global = compute_solar_irradiance_for_all_faces_masked(
1378
- face_centers.astype(np.float64),
1379
- face_normals.astype(np.float64),
1380
- face_svf.astype(np.float64),
1381
- sun_direction.astype(np.float64),
1382
- float(direct_normal_irradiance),
1383
- float(diffuse_irradiance),
1384
- vox_is_tree,
1385
- vox_is_opaque,
1386
- float(meshsize),
1387
- att,
1388
- float(grid_bounds_real[0,0]), float(grid_bounds_real[0,1]), float(grid_bounds_real[0,2]),
1389
- float(grid_bounds_real[1,0]), float(grid_bounds_real[1,1]), float(grid_bounds_real[1,2]),
1390
- float(boundary_epsilon)
1391
- )
1392
- else:
1393
- face_direct, face_diffuse, face_global = compute_solar_irradiance_for_all_faces(
1394
- face_centers,
1395
- face_normals,
1396
- face_svf_opt if (precomputed_geometry is not None and face_svf_opt is not None) else face_svf,
1397
- sun_direction,
1398
- direct_normal_irradiance,
1399
- diffuse_irradiance,
1400
- voxel_data,
1401
- meshsize,
1402
- tree_k,
1403
- tree_lad,
1404
- hit_values,
1405
- inclusion_mode,
1406
- grid_bounds_real,
1407
- boundary_epsilon
1408
- )
1409
-
1410
- # Report performance timing if requested
1411
- if progress_report:
1412
- elapsed = time.time() - t0
1413
- print(f"Numba-based solar irradiance calculation took {elapsed:.2f} seconds")
1414
-
1415
- # Create a copy of the input mesh to store results
1416
- irradiance_mesh = building_svf_mesh.copy()
1417
- if not hasattr(irradiance_mesh, 'metadata'):
1418
- irradiance_mesh.metadata = {}
1419
-
1420
- # Store results
1421
- irradiance_mesh.metadata['svf'] = face_svf
1422
- irradiance_mesh.metadata['direct'] = face_direct
1423
- irradiance_mesh.metadata['diffuse'] = face_diffuse
1424
- irradiance_mesh.metadata['global'] = face_global
1425
-
1426
- irradiance_mesh.name = "Solar Irradiance (W/m²)"
1427
-
1428
- # # Optional OBJ export
1429
- # obj_export = kwargs.get("obj_export", False)
1430
- # if obj_export:
1431
- # # Get export parameters
1432
- # output_dir = kwargs.get("output_directory", "output")
1433
- # output_file_name = kwargs.get("output_file_name", "solar_irradiance")
1434
-
1435
- # # Export the mesh directly
1436
- # irradiance_mesh.export(f"{output_dir}/{output_file_name}.obj")
1437
-
1438
- return irradiance_mesh
1439
-
1440
- ##############################################################################
1441
- # 2.5) Specialized masked DDA for per-face irradiance (parallel)
1442
- ##############################################################################
1443
- @njit(cache=True, fastmath=True, nogil=True)
1444
- def _trace_direct_masked(vox_is_tree, vox_is_opaque, origin, direction, att, att_cutoff=0.01):
1445
- nx, ny, nz = vox_is_opaque.shape
1446
- x0 = origin[0]; y0 = origin[1]; z0 = origin[2]
1447
- dx = direction[0]; dy = direction[1]; dz = direction[2]
1448
-
1449
- # Normalize
1450
- L = (dx*dx + dy*dy + dz*dz) ** 0.5
1451
- if L == 0.0:
1452
- return False, 1.0
1453
- invL = 1.0 / L
1454
- dx *= invL; dy *= invL; dz *= invL
1455
-
1456
- # Start at voxel centers
1457
- x = x0 + 0.5; y = y0 + 0.5; z = z0 + 0.5
1458
- i = int(x0); j = int(y0); k = int(z0)
1459
-
1460
- step_x = 1 if dx >= 0.0 else -1
1461
- step_y = 1 if dy >= 0.0 else -1
1462
- step_z = 1 if dz >= 0.0 else -1
1463
-
1464
- BIG = 1e30
1465
- if dx != 0.0:
1466
- t_max_x = (((i + (1 if step_x > 0 else 0)) - x) / dx)
1467
- t_delta_x = abs(1.0 / dx)
1468
- else:
1469
- t_max_x = BIG; t_delta_x = BIG
1470
- if dy != 0.0:
1471
- t_max_y = (((j + (1 if step_y > 0 else 0)) - y) / dy)
1472
- t_delta_y = abs(1.0 / dy)
1473
- else:
1474
- t_max_y = BIG; t_delta_y = BIG
1475
- if dz != 0.0:
1476
- t_max_z = (((k + (1 if step_z > 0 else 0)) - z) / dz)
1477
- t_delta_z = abs(1.0 / dz)
1478
- else:
1479
- t_max_z = BIG; t_delta_z = BIG
1480
-
1481
- T = 1.0
1482
- while True:
1483
- if (i < 0) or (i >= nx) or (j < 0) or (j >= ny) or (k < 0) or (k >= nz):
1484
- # Exited grid: not blocked
1485
- return False, T
1486
-
1487
- if vox_is_opaque[i, j, k]:
1488
- # Hit opaque (non-sky, non-tree)
1489
- return True, T
1490
-
1491
- if vox_is_tree[i, j, k]:
1492
- T *= att
1493
- if T < att_cutoff:
1494
- # Consider fully attenuated
1495
- return True, T
1496
-
1497
- # Step DDA
1498
- if t_max_x < t_max_y:
1499
- if t_max_x < t_max_z:
1500
- t_max_x += t_delta_x; i += step_x
1501
- else:
1502
- t_max_z += t_delta_z; k += step_z
1503
- else:
1504
- if t_max_y < t_max_z:
1505
- t_max_y += t_delta_y; j += step_y
1506
- else:
1507
- t_max_z += t_delta_z; k += step_z
1508
-
1509
- @njit(parallel=True, cache=True, fastmath=True, nogil=True)
1510
- def compute_solar_irradiance_for_all_faces_masked(
1511
- face_centers,
1512
- face_normals,
1513
- face_svf,
1514
- sun_direction,
1515
- direct_normal_irradiance,
1516
- diffuse_irradiance,
1517
- vox_is_tree,
1518
- vox_is_opaque,
1519
- meshsize,
1520
- att,
1521
- x_min, y_min, z_min,
1522
- x_max, y_max, z_max,
1523
- boundary_epsilon
1524
- ):
1525
- n_faces = face_centers.shape[0]
1526
- face_direct = np.zeros(n_faces, dtype=np.float64)
1527
- face_diffuse = np.zeros(n_faces, dtype=np.float64)
1528
- face_global = np.zeros(n_faces, dtype=np.float64)
1529
-
1530
- for fidx in prange(n_faces):
1531
- center = face_centers[fidx]
1532
- normal = face_normals[fidx]
1533
- svf = face_svf[fidx]
1534
-
1535
- # Boundary vertical exclusion
1536
- is_vertical = (abs(normal[2]) < 0.01)
1537
- on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
1538
- on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
1539
- on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
1540
- on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
1541
- if is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max):
1542
- face_direct[fidx] = np.nan
1543
- face_diffuse[fidx] = np.nan
1544
- face_global[fidx] = np.nan
1545
- continue
1546
-
1547
- if svf != svf:
1548
- face_direct[fidx] = np.nan
1549
- face_diffuse[fidx] = np.nan
1550
- face_global[fidx] = np.nan
1551
- continue
1552
-
1553
- # Direct component
1554
- cos_incidence = normal[0]*sun_direction[0] + normal[1]*sun_direction[1] + normal[2]*sun_direction[2]
1555
- direct_val = 0.0
1556
- if cos_incidence > 0.0 and direct_normal_irradiance > 0.0:
1557
- offset_vox = 0.1
1558
- ox = center[0]/meshsize + normal[0]*offset_vox
1559
- oy = center[1]/meshsize + normal[1]*offset_vox
1560
- oz = center[2]/meshsize + normal[2]*offset_vox
1561
- blocked, T = _trace_direct_masked(
1562
- vox_is_tree,
1563
- vox_is_opaque,
1564
- np.array((ox, oy, oz), dtype=np.float64),
1565
- sun_direction,
1566
- att
1567
- )
1568
- if not blocked:
1569
- direct_val = direct_normal_irradiance * cos_incidence * T
1570
-
1571
- # Diffuse component
1572
- diffuse_val = svf * diffuse_irradiance
1573
- if diffuse_val > diffuse_irradiance:
1574
- diffuse_val = diffuse_irradiance
1575
-
1576
- face_direct[fidx] = direct_val
1577
- face_diffuse[fidx] = diffuse_val
1578
- face_global[fidx] = direct_val + diffuse_val
1579
-
1580
- return face_direct, face_diffuse, face_global
1581
-
1582
- @njit(parallel=True, cache=True, fastmath=True, nogil=True)
1583
- def compute_cumulative_solar_irradiance_faces_masked_timeseries(
1584
- face_centers,
1585
- face_normals,
1586
- face_svf,
1587
- sun_dirs_arr, # shape (T, 3)
1588
- DNI_arr, # shape (T,)
1589
- DHI_arr, # shape (T,)
1590
- vox_is_tree,
1591
- vox_is_opaque,
1592
- meshsize,
1593
- att,
1594
- x_min, y_min, z_min,
1595
- x_max, y_max, z_max,
1596
- boundary_epsilon,
1597
- t_start, t_end, # [start, end) indices
1598
- time_step_hours
1599
- ):
1600
- n_faces = face_centers.shape[0]
1601
- out_dir = np.zeros(n_faces, dtype=np.float64)
1602
- out_diff = np.zeros(n_faces, dtype=np.float64)
1603
- out_glob = np.zeros(n_faces, dtype=np.float64)
1604
-
1605
- for fidx in prange(n_faces):
1606
- center = face_centers[fidx]
1607
- normal = face_normals[fidx]
1608
- svf = face_svf[fidx]
1609
-
1610
- # Boundary vertical exclusion
1611
- is_vertical = (abs(normal[2]) < 0.01)
1612
- on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
1613
- on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
1614
- on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
1615
- on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
1616
- if is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max):
1617
- out_dir[fidx] = np.nan
1618
- out_diff[fidx] = np.nan
1619
- out_glob[fidx] = np.nan
1620
- continue
1621
-
1622
- if svf != svf:
1623
- out_dir[fidx] = np.nan
1624
- out_diff[fidx] = np.nan
1625
- out_glob[fidx] = np.nan
1626
- continue
1627
-
1628
- accum_dir = 0.0
1629
- accum_diff = 0.0
1630
- accum_glob = 0.0
1631
-
1632
- # Precompute ray origin (voxel coords) once per face
1633
- offset_vox = 0.1
1634
- ox = center[0]/meshsize + normal[0]*offset_vox
1635
- oy = center[1]/meshsize + normal[1]*offset_vox
1636
- oz = center[2]/meshsize + normal[2]*offset_vox
1637
- origin = np.array((ox, oy, oz), dtype=np.float64)
1638
-
1639
- for t in range(t_start, t_end):
1640
- dni = DNI_arr[t]
1641
- dhi = DHI_arr[t]
1642
- sd0 = sun_dirs_arr[t, 0]
1643
- sd1 = sun_dirs_arr[t, 1]
1644
- sd2 = sun_dirs_arr[t, 2]
1645
- # Skip below horizon quickly: dz <= 0 implies elevation<=0
1646
- if sd2 <= 0.0:
1647
- # diffuse only
1648
- diff_val = svf * dhi
1649
- if diff_val > dhi:
1650
- diff_val = dhi
1651
- accum_diff += diff_val * time_step_hours
1652
- accum_glob += diff_val * time_step_hours
1653
- continue
1654
-
1655
- # Direct
1656
- cos_inc = normal[0]*sd0 + normal[1]*sd1 + normal[2]*sd2
1657
- direct_val = 0.0
1658
- if (dni > 0.0) and (cos_inc > 0.0):
1659
- blocked, T = _trace_direct_masked(
1660
- vox_is_tree,
1661
- vox_is_opaque,
1662
- origin,
1663
- np.array((sd0, sd1, sd2), dtype=np.float64),
1664
- att
1665
- )
1666
- if not blocked:
1667
- direct_val = dni * cos_inc * T
1668
-
1669
- diff_val = svf * dhi
1670
- if diff_val > dhi:
1671
- diff_val = dhi
1672
-
1673
- accum_dir += direct_val * time_step_hours
1674
- accum_diff += diff_val * time_step_hours
1675
- accum_glob += (direct_val + diff_val) * time_step_hours
1676
-
1677
- out_dir[fidx] = accum_dir
1678
- out_diff[fidx] = accum_diff
1679
- out_glob[fidx] = accum_glob
1680
-
1681
- return out_dir, out_diff, out_glob
1682
-
1683
- ##############################################################################
1684
- # 4) Modified get_cumulative_building_solar_irradiance
1685
- ##############################################################################
1686
- def get_cumulative_building_solar_irradiance(
1687
- voxel_data,
1688
- meshsize,
1689
- building_svf_mesh,
1690
- weather_df,
1691
- lon, lat, tz,
1692
- **kwargs
1693
- ):
1694
- """
1695
- Calculate cumulative solar irradiance on building surfaces over a time period.
1696
- Uses the Numba-accelerated get_building_solar_irradiance for each time step.
1697
-
1698
- Args:
1699
- voxel_data (ndarray): 3D array of voxel values.
1700
- meshsize (float): Size of each voxel in meters.
1701
- building_svf_mesh (trimesh.Trimesh): Mesh with pre-calculated SVF in metadata.
1702
- weather_df (DataFrame): Weather data with DNI (W/m²) and DHI (W/m²).
1703
- lon (float): Longitude in degrees.
1704
- lat (float): Latitude in degrees.
1705
- tz (float): Timezone offset in hours.
1706
- **kwargs: Additional parameters for time range, scaling, OBJ export, etc.
1707
-
1708
- Returns:
1709
- trimesh.Trimesh: A mesh with cumulative (Wh/m²) irradiance in metadata.
1710
- """
1711
- import pytz
1712
- from datetime import datetime
1713
- import numpy as np
1714
-
1715
- period_start = kwargs.get("period_start", "01-01 00:00:00")
1716
- period_end = kwargs.get("period_end", "12-31 23:59:59")
1717
- time_step_hours = kwargs.get("time_step_hours", 1.0)
1718
- direct_normal_irradiance_scaling = kwargs.get("direct_normal_irradiance_scaling", 1.0)
1719
- diffuse_irradiance_scaling = kwargs.get("diffuse_irradiance_scaling", 1.0)
1720
- progress_report = kwargs.get("progress_report", False)
1721
- fast_path = kwargs.get("fast_path", True)
1722
-
1723
- # Parse times, create local tz
1724
- try:
1725
- start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
1726
- end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
1727
- except ValueError as ve:
1728
- raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
1729
-
1730
- offset_minutes = int(tz * 60)
1731
- local_tz = pytz.FixedOffset(offset_minutes)
1732
-
1733
- # Filter weather_df
1734
- df_period = weather_df[
1735
- ((weather_df.index.month > start_dt.month) |
1736
- ((weather_df.index.month == start_dt.month) &
1737
- (weather_df.index.day >= start_dt.day) &
1738
- (weather_df.index.hour >= start_dt.hour))) &
1739
- ((weather_df.index.month < end_dt.month) |
1740
- ((weather_df.index.month == end_dt.month) &
1741
- (weather_df.index.day <= end_dt.day) &
1742
- (weather_df.index.hour <= end_dt.hour)))
1743
- ]
1744
- if df_period.empty:
1745
- raise ValueError("No weather data in specified period.")
1746
-
1747
- # Convert to local time, then to UTC
1748
- df_period_local = df_period.copy()
1749
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
1750
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
1751
-
1752
- # Get solar positions (allow precomputed to avoid recomputation)
1753
- precomputed_solar_positions = kwargs.get("precomputed_solar_positions", None)
1754
- if precomputed_solar_positions is not None and len(precomputed_solar_positions) == len(df_period_utc.index):
1755
- solar_positions = precomputed_solar_positions
1756
- else:
1757
- solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
1758
-
1759
- # Precompute arrays to avoid per-iteration pandas operations
1760
- times_len = len(df_period_utc.index)
1761
- azimuth_deg_arr = solar_positions['azimuth'].to_numpy()
1762
- elev_deg_arr = solar_positions['elevation'].to_numpy()
1763
- az_rad_arr = np.deg2rad(180.0 - azimuth_deg_arr)
1764
- el_rad_arr = np.deg2rad(elev_deg_arr)
1765
- sun_dx_arr = np.cos(el_rad_arr) * np.cos(az_rad_arr)
1766
- sun_dy_arr = np.cos(el_rad_arr) * np.sin(az_rad_arr)
1767
- sun_dz_arr = np.sin(el_rad_arr)
1768
- sun_dirs_arr = np.stack([sun_dx_arr, sun_dy_arr, sun_dz_arr], axis=1).astype(np.float64)
1769
- DNI_arr = (df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling).astype(np.float64)
1770
- DHI_arr = (df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling).astype(np.float64)
1771
- sun_above_mask = elev_deg_arr > 0.0
1772
-
1773
- # Prepare arrays for accumulation
1774
- n_faces = len(building_svf_mesh.faces)
1775
- face_cum_direct = np.zeros(n_faces, dtype=np.float64)
1776
- face_cum_diffuse = np.zeros(n_faces, dtype=np.float64)
1777
- face_cum_global = np.zeros(n_faces, dtype=np.float64)
1778
-
1779
- # Pre-extract mesh face arrays and domain bounds for fast path
1780
- # Optionally reuse precomputed geometry/bounds
1781
- precomputed_geometry = kwargs.get("precomputed_geometry", None)
1782
- if precomputed_geometry is not None:
1783
- face_centers = precomputed_geometry.get("face_centers", building_svf_mesh.triangles_center)
1784
- face_normals = precomputed_geometry.get("face_normals", building_svf_mesh.face_normals)
1785
- face_svf = precomputed_geometry.get(
1786
- "face_svf",
1787
- building_svf_mesh.metadata['svf'] if ('svf' in building_svf_mesh.metadata) else np.zeros(n_faces, dtype=np.float64)
1788
- )
1789
- grid_bounds_real = precomputed_geometry.get("grid_bounds_real", None)
1790
- boundary_epsilon = precomputed_geometry.get("boundary_epsilon", None)
1791
- else:
1792
- face_centers = building_svf_mesh.triangles_center
1793
- face_normals = building_svf_mesh.face_normals
1794
- face_svf = building_svf_mesh.metadata['svf'] if ('svf' in building_svf_mesh.metadata) else np.zeros(n_faces, dtype=np.float64)
1795
- grid_bounds_real = None
1796
- boundary_epsilon = None
1797
-
1798
- if grid_bounds_real is None or boundary_epsilon is None:
1799
- grid_shape = voxel_data.shape
1800
- grid_bounds_voxel = np.array([[0, 0, 0], [grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=np.float64)
1801
- grid_bounds_real = grid_bounds_voxel * meshsize
1802
- boundary_epsilon = meshsize * 0.05
1803
-
1804
- # Params used in Numba kernel
1805
- hit_values = (0,) # sky
1806
- inclusion_mode = False # any non-sky is obstacle but trees transmit
1807
- tree_k = kwargs.get("tree_k", 0.6)
1808
- tree_lad = kwargs.get("tree_lad", 1.0)
1809
-
1810
- boundary_mask = None
1811
- instant_kwargs = kwargs.copy()
1812
- instant_kwargs['obj_export'] = False
1813
-
1814
- total_steps = times_len
1815
- progress_every = max(1, total_steps // 20) # ~5% steps
1816
-
1817
- # Pre-cast stable arrays to avoid repeated allocations
1818
- face_centers64 = (face_centers if isinstance(face_centers, np.ndarray) else building_svf_mesh.triangles_center).astype(np.float64)
1819
- face_normals64 = (face_normals if isinstance(face_normals, np.ndarray) else building_svf_mesh.face_normals).astype(np.float64)
1820
- face_svf64 = face_svf.astype(np.float64)
1821
- x_min, y_min, z_min = grid_bounds_real[0, 0], grid_bounds_real[0, 1], grid_bounds_real[0, 2]
1822
- x_max, y_max, z_max = grid_bounds_real[1, 0], grid_bounds_real[1, 1], grid_bounds_real[1, 2]
1823
-
1824
- if fast_path:
1825
- # Use masked cumulative kernel with chunking to minimize Python overhead
1826
- precomputed_masks = kwargs.get("precomputed_masks", None)
1827
- if precomputed_masks is not None:
1828
- vox_is_tree = precomputed_masks.get("vox_is_tree", (voxel_data == -2))
1829
- vox_is_opaque = precomputed_masks.get("vox_is_opaque", (voxel_data != 0) & (voxel_data != -2))
1830
- att = float(precomputed_masks.get("att", np.exp(-tree_k * tree_lad * meshsize)))
1831
- else:
1832
- vox_is_tree = (voxel_data == -2)
1833
- vox_is_opaque = (voxel_data != 0) & (~vox_is_tree)
1834
- att = float(np.exp(-tree_k * tree_lad * meshsize))
1835
-
1836
- # Auto-tune chunk size if user didn't pass one
1837
- time_batch_size = _auto_time_batch_size(n_faces, total_steps, kwargs.get("time_batch_size", None))
1838
- if progress_report:
1839
- print(f"Faces: {n_faces:,}, Timesteps: {total_steps:,}, Batch size: {time_batch_size}")
1840
-
1841
- for start in range(0, total_steps, time_batch_size):
1842
- end = min(start + time_batch_size, total_steps)
1843
- # Accumulate Wh/m² over this chunk inside the kernel
1844
- ch_dir, ch_diff, ch_glob = compute_cumulative_solar_irradiance_faces_masked_timeseries(
1845
- face_centers64,
1846
- face_normals64,
1847
- face_svf64,
1848
- sun_dirs_arr,
1849
- DNI_arr,
1850
- DHI_arr,
1851
- vox_is_tree,
1852
- vox_is_opaque,
1853
- float(meshsize),
1854
- float(att),
1855
- float(x_min), float(y_min), float(z_min),
1856
- float(x_max), float(y_max), float(z_max),
1857
- float(boundary_epsilon),
1858
- int(start), int(end),
1859
- float(time_step_hours)
1860
- )
1861
- face_cum_direct += ch_dir
1862
- face_cum_diffuse += ch_diff
1863
- face_cum_global += ch_glob
1864
-
1865
- if progress_report:
1866
- pct = (end * 100.0) / total_steps
1867
- print(f"Cumulative irradiance: {end}/{total_steps} ({pct:.1f}%)")
1868
- else:
1869
- # Iterate per timestep (fallback)
1870
- for idx in range(total_steps):
1871
- DNI = float(DNI_arr[idx])
1872
- DHI = float(DHI_arr[idx])
1873
-
1874
- # Skip if sun below horizon
1875
- if not sun_above_mask[idx]:
1876
- # Only diffuse term contributes (still based on SVF)
1877
- if boundary_mask is None:
1878
- boundary_mask = np.isnan(face_svf)
1879
- # Accumulate diffuse only
1880
- face_cum_diffuse += np.nan_to_num(face_svf * DHI) * time_step_hours
1881
- face_cum_global += np.nan_to_num(face_svf * DHI) * time_step_hours
1882
- # progress
1883
- if progress_report:
1884
- if ((idx + 1) % progress_every == 0) or (idx == total_steps - 1):
1885
- pct = (idx + 1) * 100.0 / total_steps
1886
- print(f"Cumulative irradiance: {idx+1}/{total_steps} ({pct:.1f}%)")
1887
- continue
1888
-
1889
- # Fallback to wrapper per-timestep
1890
- irr_mesh = get_building_solar_irradiance(
1891
- voxel_data,
1892
- meshsize,
1893
- building_svf_mesh,
1894
- float(azimuth_deg_arr[idx]),
1895
- float(elev_deg_arr[idx]),
1896
- DNI,
1897
- DHI,
1898
- show_plot=False,
1899
- **instant_kwargs
1900
- )
1901
- face_direct = irr_mesh.metadata['direct']
1902
- face_diffuse = irr_mesh.metadata['diffuse']
1903
- face_global = irr_mesh.metadata['global']
1904
-
1905
- # If first time, note boundary mask from NaNs
1906
- if boundary_mask is None:
1907
- boundary_mask = np.isnan(face_global)
1908
-
1909
- # Convert from W/m² to Wh/m² by multiplying time_step_hours
1910
- face_cum_direct += np.nan_to_num(face_direct) * time_step_hours
1911
- face_cum_diffuse += np.nan_to_num(face_diffuse) * time_step_hours
1912
- face_cum_global += np.nan_to_num(face_global) * time_step_hours
1913
-
1914
- if progress_report and (((idx + 1) % progress_every == 0) or (idx == total_steps - 1)):
1915
- pct = (idx + 1) * 100.0 / total_steps
1916
- print(f"Cumulative irradiance: {idx+1}/{total_steps} ({pct:.1f}%)")
1917
-
1918
- # Reapply NaN for boundary
1919
- if boundary_mask is not None:
1920
- face_cum_direct[boundary_mask] = np.nan
1921
- face_cum_diffuse[boundary_mask] = np.nan
1922
- face_cum_global[boundary_mask] = np.nan
1923
-
1924
- # Create a new mesh with cumulative results
1925
- cumulative_mesh = building_svf_mesh.copy()
1926
- if not hasattr(cumulative_mesh, 'metadata'):
1927
- cumulative_mesh.metadata = {}
1928
-
1929
- # If original mesh had SVF
1930
- if 'svf' in building_svf_mesh.metadata:
1931
- cumulative_mesh.metadata['svf'] = building_svf_mesh.metadata['svf']
1932
-
1933
- cumulative_mesh.metadata['direct'] = face_cum_direct
1934
- cumulative_mesh.metadata['diffuse'] = face_cum_diffuse
1935
- cumulative_mesh.metadata['global'] = face_cum_global
1936
-
1937
- cumulative_mesh.name = "Cumulative Solar Irradiance (Wh/m²)"
1938
-
1939
- # # Optional OBJ export
1940
- # obj_export = kwargs.get("obj_export", False)
1941
- # if obj_export:
1942
- # # Get export parameters
1943
- # output_dir = kwargs.get("output_directory", "output")
1944
- # output_file_name = kwargs.get("output_file_name", "solar_irradiance")
1945
-
1946
- # # Export the mesh directly
1947
- # irradiance_mesh.export(f"{output_dir}/{output_file_name}.obj")
1948
-
1949
- return cumulative_mesh
1950
-
1951
- def get_building_global_solar_irradiance_using_epw(
1952
- voxel_data,
1953
- meshsize,
1954
- calc_type='instantaneous',
1955
- direct_normal_irradiance_scaling=1.0,
1956
- diffuse_irradiance_scaling=1.0,
1957
- **kwargs
1958
- ):
1959
- """
1960
- Compute global solar irradiance on building surfaces using EPW weather data, either for a single time or cumulatively.
1961
-
1962
- The function:
1963
- 1. Optionally downloads and reads EPW weather data
1964
- 2. Handles timezone conversions and solar position calculations
1965
- 3. Computes either instantaneous or cumulative irradiance on building surfaces
1966
- 4. Supports visualization and export options
1967
-
1968
- Args:
1969
- voxel_data (ndarray): 3D array of voxel values.
1970
- meshsize (float): Size of each voxel in meters.
1971
- building_svf_mesh (trimesh.Trimesh): Building mesh with pre-calculated SVF values in metadata.
1972
- calc_type (str): 'instantaneous' or 'cumulative'.
1973
- direct_normal_irradiance_scaling (float): Scaling factor for direct normal irradiance.
1974
- diffuse_irradiance_scaling (float): Scaling factor for diffuse horizontal irradiance.
1975
- **kwargs: Additional arguments including:
1976
- - download_nearest_epw (bool): Whether to download nearest EPW file
1977
- - epw_file_path (str): Path to EPW file
1978
- - rectangle_vertices (list): List of (lon,lat) coordinates for EPW download
1979
- - output_dir (str): Directory for EPW download
1980
- - calc_time (str): Time for instantaneous calculation ('MM-DD HH:MM:SS')
1981
- - period_start (str): Start time for cumulative calculation ('MM-DD HH:MM:SS')
1982
- - period_end (str): End time for cumulative calculation ('MM-DD HH:MM:SS')
1983
- - time_step_hours (float): Time step for cumulative calculation
1984
- - tree_k (float): Tree extinction coefficient
1985
- - tree_lad (float): Leaf area density in m^-1
1986
- - show_each_timestep (bool): Whether to show plots for each timestep
1987
- - nan_color (str): Color for NaN values in visualization
1988
- - colormap (str): Matplotlib colormap name
1989
- - vmin (float): Minimum value for colormap
1990
- - vmax (float): Maximum value for colormap
1991
- - obj_export (bool): Whether to export as OBJ file
1992
- - output_directory (str): Directory for OBJ export
1993
- - output_file_name (str): Filename for OBJ export
1994
- - save_mesh (bool): Whether to save the mesh data using pickle
1995
- - mesh_output_path (str): Path to save the mesh data (if save_mesh is True)
1996
-
1997
- Returns:
1998
- trimesh.Trimesh: Building mesh with irradiance values stored in metadata.
1999
- """
2000
- import numpy as np
2001
- import pytz
2002
- from datetime import datetime
2003
-
2004
- # Get EPW file
2005
- download_nearest_epw = kwargs.get("download_nearest_epw", False)
2006
- rectangle_vertices = kwargs.get("rectangle_vertices", None)
2007
- epw_file_path = kwargs.get("epw_file_path", None)
2008
- building_id_grid = kwargs.get("building_id_grid", None)
2009
- building_svf_mesh = kwargs.get("building_svf_mesh", None)
2010
- progress_report = kwargs.get("progress_report", False)
2011
- fast_path = kwargs.get("fast_path", True)
2012
-
2013
- # Threading tuning (auto): choose sensible defaults based on hardware
2014
- desired_threads = kwargs.get("numba_num_threads", None)
2015
- _configure_num_threads(desired_threads, progress=kwargs.get("progress_report", False))
2016
-
2017
- if download_nearest_epw:
2018
- if rectangle_vertices is None:
2019
- print("rectangle_vertices is required to download nearest EPW file")
2020
- return None
2021
- else:
2022
- # Calculate center point of rectangle
2023
- lons = [coord[0] for coord in rectangle_vertices]
2024
- lats = [coord[1] for coord in rectangle_vertices]
2025
- center_lon = (min(lons) + max(lons)) / 2
2026
- center_lat = (min(lats) + max(lats)) / 2
2027
-
2028
- # Optional: specify maximum distance in kilometers
2029
- max_distance = kwargs.get("max_distance", 100) # None for no limit
2030
- output_dir = kwargs.get("output_dir", "output")
2031
-
2032
- epw_file_path, weather_data, metadata = get_nearest_epw_from_climate_onebuilding(
2033
- longitude=center_lon,
2034
- latitude=center_lat,
2035
- output_dir=output_dir,
2036
- max_distance=max_distance,
2037
- extract_zip=True,
2038
- load_data=True,
2039
- allow_insecure_ssl=kwargs.get("allow_insecure_ssl", False),
2040
- allow_http_fallback=kwargs.get("allow_http_fallback", False),
2041
- ssl_verify=kwargs.get("ssl_verify", True)
2042
- )
2043
-
2044
- # Read EPW data
2045
- if epw_file_path is None:
2046
- raise RuntimeError("EPW file path is None. Set 'epw_file_path' or enable 'download_nearest_epw' and ensure network succeeds.")
2047
- df, lon, lat, tz, elevation_m = read_epw_for_solar_simulation(epw_file_path)
2048
- if df.empty:
2049
- raise ValueError("No data in EPW file.")
2050
-
2051
- # Step 1: Calculate Sky View Factor for building surfaces (unless provided)
2052
- if building_svf_mesh is None:
2053
- if progress_report:
2054
- print("Processing Sky View Factor for building surfaces...")
2055
- # Allow passing through specific SVF parameters via kwargs
2056
- svf_kwargs = {
2057
- 'value_name': 'svf',
2058
- 'target_values': (0,),
2059
- 'inclusion_mode': False,
2060
- 'building_id_grid': building_id_grid,
2061
- 'progress_report': progress_report,
2062
- 'fast_path': fast_path,
2063
- }
2064
- # Permit overrides
2065
- for k in ("N_azimuth","N_elevation","tree_k","tree_lad","debug"):
2066
- if k in kwargs:
2067
- svf_kwargs[k] = kwargs[k]
2068
- building_svf_mesh = get_surface_view_factor(
2069
- voxel_data,
2070
- meshsize,
2071
- **svf_kwargs
2072
- )
2073
-
2074
- if progress_report:
2075
- print(f"SVF ready. Faces: {len(building_svf_mesh.faces):,}")
2076
-
2077
- # Step 2: Build precomputed caches (geometry, masks, attenuation) for speed
2078
- precomputed_geometry = {}
2079
- try:
2080
- grid_shape = voxel_data.shape
2081
- grid_bounds_voxel = np.array([[0, 0, 0], [grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=np.float64)
2082
- grid_bounds_real = grid_bounds_voxel * meshsize
2083
- boundary_epsilon = meshsize * 0.05
2084
- precomputed_geometry = {
2085
- 'face_centers': building_svf_mesh.triangles_center,
2086
- 'face_normals': building_svf_mesh.face_normals,
2087
- 'face_svf': building_svf_mesh.metadata['svf'] if ('svf' in building_svf_mesh.metadata) else None,
2088
- 'grid_bounds_real': grid_bounds_real,
2089
- 'boundary_epsilon': boundary_epsilon,
2090
- }
2091
- except Exception:
2092
- # Fallback silently
2093
- precomputed_geometry = {}
2094
-
2095
- tree_k = kwargs.get("tree_k", 0.6)
2096
- tree_lad = kwargs.get("tree_lad", 1.0)
2097
- precomputed_masks = {
2098
- 'vox_is_tree': (voxel_data == -2),
2099
- 'vox_is_opaque': (voxel_data != 0) & (voxel_data != -2),
2100
- 'att': float(np.exp(-tree_k * tree_lad * meshsize)),
2101
- }
2102
-
2103
- if progress_report:
2104
- t_cnt = int(np.count_nonzero(precomputed_masks['vox_is_tree']))
2105
- b_cnt = int(np.count_nonzero(voxel_data == -3)) if hasattr(voxel_data, 'shape') else 0
2106
- print(f"Precomputed caches: trees={t_cnt:,}, buildings={b_cnt:,}, tree_att_per_voxel={precomputed_masks['att']:.4f}")
2107
- print(f"Processing Solar Irradiance for building surfaces...")
2108
- result_mesh = None
2109
-
2110
- if calc_type == 'instantaneous':
2111
- calc_time = kwargs.get("calc_time", "01-01 12:00:00")
2112
-
2113
- # Parse calculation time without year
2114
- try:
2115
- calc_dt = datetime.strptime(calc_time, "%m-%d %H:%M:%S")
2116
- except ValueError as ve:
2117
- raise ValueError("calc_time must be in format 'MM-DD HH:MM:SS'") from ve
2118
-
2119
- df_period = df[
2120
- (df.index.month == calc_dt.month) & (df.index.day == calc_dt.day) & (df.index.hour == calc_dt.hour)
2121
- ]
2122
-
2123
- if df_period.empty:
2124
- raise ValueError("No EPW data at the specified time.")
2125
-
2126
- # Prepare timezone conversion
2127
- offset_minutes = int(tz * 60)
2128
- local_tz = pytz.FixedOffset(offset_minutes)
2129
- df_period_local = df_period.copy()
2130
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
2131
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
2132
-
2133
- # Compute solar positions
2134
- solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
2135
-
2136
- # Scale irradiance values
2137
- direct_normal_irradiance = df_period_utc.iloc[0]['DNI'] * direct_normal_irradiance_scaling
2138
- diffuse_irradiance = df_period_utc.iloc[0]['DHI'] * diffuse_irradiance_scaling
2139
-
2140
- # Get solar position
2141
- azimuth_degrees = solar_positions.iloc[0]['azimuth']
2142
- elevation_degrees = solar_positions.iloc[0]['elevation']
2143
-
2144
- if progress_report:
2145
- print(f"Time: {df_period_local.index[0].strftime('%Y-%m-%d %H:%M:%S')}")
2146
- print(f"Sun position: Azimuth {azimuth_degrees:.1f}°, Elevation {elevation_degrees:.1f}°")
2147
- print(f"DNI: {direct_normal_irradiance:.1f} W/m², DHI: {diffuse_irradiance:.1f} W/m²")
2148
-
2149
- # Skip if sun is below horizon
2150
- if elevation_degrees <= 0:
2151
- if progress_report:
2152
- print("Sun is below horizon, skipping calculation.")
2153
- result_mesh = building_svf_mesh.copy()
2154
- else:
2155
- # Compute irradiance
2156
- _call_kwargs = kwargs.copy()
2157
- if 'progress_report' in _call_kwargs:
2158
- _call_kwargs.pop('progress_report')
2159
- result_mesh = get_building_solar_irradiance(
2160
- voxel_data,
2161
- meshsize,
2162
- building_svf_mesh,
2163
- azimuth_degrees,
2164
- elevation_degrees,
2165
- direct_normal_irradiance,
2166
- diffuse_irradiance,
2167
- progress_report=progress_report,
2168
- fast_path=fast_path,
2169
- precomputed_geometry=precomputed_geometry,
2170
- precomputed_masks=precomputed_masks,
2171
- **_call_kwargs
2172
- )
2173
-
2174
- elif calc_type == 'cumulative':
2175
- # Set default parameters
2176
- period_start = kwargs.get("period_start", "01-01 00:00:00")
2177
- period_end = kwargs.get("period_end", "12-31 23:59:59")
2178
- time_step_hours = kwargs.get("time_step_hours", 1.0)
2179
-
2180
- # Parse start and end times without year
2181
- try:
2182
- start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
2183
- end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
2184
- except ValueError as ve:
2185
- raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
2186
-
2187
- # Create local timezone
2188
- offset_minutes = int(tz * 60)
2189
- local_tz = pytz.FixedOffset(offset_minutes)
2190
-
2191
- # Filter weather data by month, day, hour
2192
- df_period = df[
2193
- ((df.index.month > start_dt.month) |
2194
- ((df.index.month == start_dt.month) & (df.index.day >= start_dt.day) &
2195
- (df.index.hour >= start_dt.hour))) &
2196
- ((df.index.month < end_dt.month) |
2197
- ((df.index.month == end_dt.month) & (df.index.day <= end_dt.day) &
2198
- (df.index.hour <= end_dt.hour)))
2199
- ]
2200
-
2201
- if df_period.empty:
2202
- raise ValueError("No weather data available for the specified period.")
2203
-
2204
- # Convert to local timezone and then to UTC for solar position calculation
2205
- df_period_local = df_period.copy()
2206
- df_period_local.index = df_period_local.index.tz_localize(local_tz)
2207
- df_period_utc = df_period_local.tz_convert(pytz.UTC)
2208
-
2209
- # Get solar positions for all times
2210
- solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
2211
-
2212
- # Create a copy of kwargs without time_step_hours to avoid duplicate argument
2213
- kwargs_copy = kwargs.copy()
2214
- if 'time_step_hours' in kwargs_copy:
2215
- del kwargs_copy['time_step_hours']
2216
-
2217
- # Get cumulative irradiance - adapt to match expected function signature
2218
- if progress_report:
2219
- print(f"Calculating cumulative irradiance from {period_start} to {period_end}...")
2220
- result_mesh = get_cumulative_building_solar_irradiance(
2221
- voxel_data,
2222
- meshsize,
2223
- building_svf_mesh,
2224
- df, lon, lat, tz, # Pass only the required 7 positional arguments
2225
- period_start=period_start,
2226
- period_end=period_end,
2227
- time_step_hours=time_step_hours,
2228
- direct_normal_irradiance_scaling=direct_normal_irradiance_scaling,
2229
- diffuse_irradiance_scaling=diffuse_irradiance_scaling,
2230
- progress_report=progress_report,
2231
- fast_path=fast_path,
2232
- precomputed_solar_positions=solar_positions,
2233
- precomputed_geometry=precomputed_geometry,
2234
- precomputed_masks=precomputed_masks,
2235
- colormap=kwargs.get('colormap', 'jet'),
2236
- show_each_timestep=kwargs.get('show_each_timestep', False),
2237
- obj_export=kwargs.get('obj_export', False),
2238
- output_directory=kwargs.get('output_directory', 'output'),
2239
- output_file_name=kwargs.get('output_file_name', 'cumulative_solar')
2240
- )
2241
-
2242
- else:
2243
- raise ValueError("calc_type must be either 'instantaneous' or 'cumulative'")
2244
-
2245
- # Save mesh data if requested
2246
- save_mesh = kwargs.get("save_mesh", False)
2247
- if save_mesh:
2248
- mesh_output_path = kwargs.get("mesh_output_path", None)
2249
- if mesh_output_path is None:
2250
- # Generate default path if none provided
2251
- output_directory = kwargs.get("output_directory", "output")
2252
- output_file_name = kwargs.get("output_file_name", f"{calc_type}_solar_irradiance")
2253
- mesh_output_path = f"{output_directory}/{output_file_name}.pkl"
2254
-
2255
- save_irradiance_mesh(result_mesh, mesh_output_path)
2256
- print(f"Saved irradiance mesh data to: {mesh_output_path}")
2257
-
2258
- return result_mesh
2259
-
2260
- def save_irradiance_mesh(irradiance_mesh, output_file_path):
2261
- """
2262
- Save the irradiance mesh data to a file using pickle serialization.
2263
-
2264
- This function provides persistent storage for computed irradiance results,
2265
- enabling reuse of expensive calculations and sharing of results between
2266
- analysis sessions. The mesh data includes all geometry, irradiance values,
2267
- and metadata required for visualization and further analysis.
2268
-
2269
- Serialization Benefits:
2270
- - Preserves complete mesh structure with all computed data
2271
- - Enables offline analysis and visualization workflows
2272
- - Supports sharing results between different tools and users
2273
- - Avoids recomputation of expensive irradiance calculations
2274
-
2275
- Data Preservation:
2276
- - All mesh geometry (vertices, faces, normals)
2277
- - Computed irradiance values (direct, diffuse, global)
2278
- - Sky View Factor data and other metadata
2279
- - Material properties and visualization settings
2280
-
2281
- Args:
2282
- irradiance_mesh (trimesh.Trimesh): Mesh with irradiance data in metadata
2283
- Should contain computed irradiance results
2284
- output_file_path (str): Path to save the mesh data file
2285
- Recommended extension: .pkl for clarity
2286
-
2287
- Note:
2288
- The function automatically creates the output directory if it doesn't exist.
2289
- Use pickle format for maximum compatibility with Python data structures.
2290
- """
2291
- import pickle
2292
- import os
2293
-
2294
- # Create output directory structure if it doesn't exist
2295
- os.makedirs(os.path.dirname(output_file_path), exist_ok=True)
2296
-
2297
- # Serialize mesh data using pickle for complete data preservation
2298
- with open(output_file_path, 'wb') as f:
2299
- pickle.dump(irradiance_mesh, f)
2300
-
2301
- def load_irradiance_mesh(input_file_path):
2302
- """
2303
- Load previously saved irradiance mesh data from a file.
2304
-
2305
- This function restores complete mesh data including geometry, computed
2306
- irradiance values, and all associated metadata. It enables continuation
2307
- of analysis workflows and reuse of expensive computation results.
2308
-
2309
- Restoration Capabilities:
2310
- - Complete mesh geometry with all topological information
2311
- - All computed irradiance data (direct, diffuse, global components)
2312
- - Sky View Factor values and analysis metadata
2313
- - Visualization settings and material properties
2314
-
2315
- Workflow Integration:
2316
- - Load results from previous analysis sessions
2317
- - Share computed data between team members
2318
- - Perform post-processing and visualization
2319
- - Compare results from different scenarios
2320
-
2321
- Args:
2322
- input_file_path (str): Path to the saved mesh data file
2323
- Should be a file created by save_irradiance_mesh()
2324
-
2325
- Returns:
2326
- trimesh.Trimesh: Complete mesh with all irradiance data in metadata
2327
- Ready for visualization, analysis, or further processing
2328
-
2329
- Note:
2330
- The loaded mesh maintains all original data structure and can be used
2331
- immediately for visualization or additional analysis operations.
2332
- """
2333
- import pickle
2334
-
2335
- # Deserialize mesh data preserving all original structure
2336
- with open(input_file_path, 'rb') as f:
2337
- irradiance_mesh = pickle.load(f)
2338
-
2339
- return irradiance_mesh