voxcity 0.6.15__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 (78) hide show
  1. voxcity/__init__.py +14 -8
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/citygml.py +32 -18
  4. voxcity/downloader/gba.py +210 -0
  5. voxcity/downloader/gee.py +5 -1
  6. voxcity/downloader/mbfp.py +1 -1
  7. voxcity/downloader/oemj.py +80 -8
  8. voxcity/downloader/osm.py +23 -7
  9. voxcity/downloader/overture.py +26 -1
  10. voxcity/downloader/utils.py +73 -73
  11. voxcity/errors.py +30 -0
  12. voxcity/exporter/__init__.py +13 -4
  13. voxcity/exporter/cityles.py +633 -535
  14. voxcity/exporter/envimet.py +728 -708
  15. voxcity/exporter/magicavoxel.py +334 -297
  16. voxcity/exporter/netcdf.py +238 -0
  17. voxcity/exporter/obj.py +1481 -655
  18. voxcity/generator/__init__.py +44 -0
  19. voxcity/generator/api.py +675 -0
  20. voxcity/generator/grids.py +379 -0
  21. voxcity/generator/io.py +94 -0
  22. voxcity/generator/pipeline.py +282 -0
  23. voxcity/generator/voxelizer.py +380 -0
  24. voxcity/geoprocessor/__init__.py +75 -6
  25. voxcity/geoprocessor/conversion.py +153 -0
  26. voxcity/geoprocessor/draw.py +62 -12
  27. voxcity/geoprocessor/heights.py +199 -0
  28. voxcity/geoprocessor/io.py +101 -0
  29. voxcity/geoprocessor/merge_utils.py +91 -0
  30. voxcity/geoprocessor/mesh.py +806 -790
  31. voxcity/geoprocessor/network.py +708 -679
  32. voxcity/geoprocessor/overlap.py +84 -0
  33. voxcity/geoprocessor/raster/__init__.py +82 -0
  34. voxcity/geoprocessor/raster/buildings.py +428 -0
  35. voxcity/geoprocessor/raster/canopy.py +258 -0
  36. voxcity/geoprocessor/raster/core.py +150 -0
  37. voxcity/geoprocessor/raster/export.py +93 -0
  38. voxcity/geoprocessor/raster/landcover.py +156 -0
  39. voxcity/geoprocessor/raster/raster.py +110 -0
  40. voxcity/geoprocessor/selection.py +85 -0
  41. voxcity/geoprocessor/utils.py +18 -14
  42. voxcity/models.py +113 -0
  43. voxcity/simulator/common/__init__.py +22 -0
  44. voxcity/simulator/common/geometry.py +98 -0
  45. voxcity/simulator/common/raytracing.py +450 -0
  46. voxcity/simulator/solar/__init__.py +43 -0
  47. voxcity/simulator/solar/integration.py +336 -0
  48. voxcity/simulator/solar/kernels.py +62 -0
  49. voxcity/simulator/solar/radiation.py +648 -0
  50. voxcity/simulator/solar/temporal.py +434 -0
  51. voxcity/simulator/view.py +36 -2286
  52. voxcity/simulator/visibility/__init__.py +29 -0
  53. voxcity/simulator/visibility/landmark.py +392 -0
  54. voxcity/simulator/visibility/view.py +508 -0
  55. voxcity/utils/logging.py +61 -0
  56. voxcity/utils/orientation.py +51 -0
  57. voxcity/utils/weather/__init__.py +26 -0
  58. voxcity/utils/weather/epw.py +146 -0
  59. voxcity/utils/weather/files.py +36 -0
  60. voxcity/utils/weather/onebuilding.py +486 -0
  61. voxcity/visualizer/__init__.py +24 -0
  62. voxcity/visualizer/builder.py +43 -0
  63. voxcity/visualizer/grids.py +141 -0
  64. voxcity/visualizer/maps.py +187 -0
  65. voxcity/visualizer/palette.py +228 -0
  66. voxcity/visualizer/renderer.py +928 -0
  67. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/METADATA +113 -36
  68. voxcity-0.7.0.dist-info/RECORD +77 -0
  69. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/WHEEL +1 -1
  70. voxcity/generator.py +0 -1137
  71. voxcity/geoprocessor/grid.py +0 -1568
  72. voxcity/geoprocessor/polygon.py +0 -1344
  73. voxcity/simulator/solar.py +0 -2329
  74. voxcity/utils/visualization.py +0 -2660
  75. voxcity/utils/weather.py +0 -817
  76. voxcity-0.6.15.dist-info/RECORD +0 -37
  77. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/AUTHORS.rst +0 -0
  78. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,434 @@
1
+ """
2
+ Stage 3: Time-series integration.
3
+ """
4
+
5
+ import os
6
+ from datetime import datetime
7
+ import pytz
8
+ import numpy as np
9
+ import matplotlib.pyplot as plt
10
+ import numba
11
+
12
+ from ...models import VoxCity
13
+ from ...exporter.obj import grid_to_obj
14
+ from .radiation import (
15
+ get_direct_solar_irradiance_map,
16
+ get_diffuse_solar_irradiance_map,
17
+ compute_cumulative_solar_irradiance_faces_masked_timeseries,
18
+ get_building_solar_irradiance,
19
+ )
20
+
21
+
22
+ def get_solar_positions_astral(times, lon, lat):
23
+ """
24
+ Compute solar azimuth and elevation for given times and location using Astral.
25
+ Returns a DataFrame indexed by times with columns ['azimuth', 'elevation'] (degrees).
26
+ """
27
+ import pandas as pd
28
+ from astral import Observer
29
+ from astral.sun import elevation, azimuth
30
+
31
+ observer = Observer(latitude=lat, longitude=lon)
32
+ df_pos = pd.DataFrame(index=times, columns=['azimuth', 'elevation'], dtype=float)
33
+ for t in times:
34
+ el = elevation(observer=observer, dateandtime=t)
35
+ az = azimuth(observer=observer, dateandtime=t)
36
+ df_pos.at[t, 'elevation'] = el
37
+ df_pos.at[t, 'azimuth'] = az
38
+ return df_pos
39
+
40
+ def _configure_num_threads(desired_threads=None, progress=False):
41
+ try:
42
+ cores = os.cpu_count() or 4
43
+ except Exception:
44
+ cores = 4
45
+ used = desired_threads if desired_threads is not None else cores
46
+ try:
47
+ numba.set_num_threads(int(used))
48
+ except Exception:
49
+ pass
50
+ os.environ.setdefault('MKL_NUM_THREADS', '1')
51
+ if 'OMP_NUM_THREADS' not in os.environ:
52
+ os.environ['OMP_NUM_THREADS'] = str(int(used))
53
+ if progress:
54
+ try:
55
+ print(f"Numba threads: {numba.get_num_threads()} (requested {used})")
56
+ except Exception:
57
+ print(f"Numba threads set to {used}")
58
+ return used
59
+
60
+
61
+ def _auto_time_batch_size(n_faces, total_steps, user_value=None):
62
+ if user_value is not None:
63
+ return max(1, int(user_value))
64
+ if total_steps <= 0:
65
+ return 1
66
+ if n_faces <= 5_000:
67
+ batches = 2
68
+ elif n_faces <= 50_000:
69
+ batches = 8
70
+ elif n_faces <= 200_000:
71
+ batches = 16
72
+ else:
73
+ batches = 32
74
+ batches = min(batches, total_steps)
75
+ return max(1, total_steps // batches)
76
+
77
+
78
+ def get_cumulative_global_solar_irradiance(
79
+ voxcity: VoxCity,
80
+ df,
81
+ lon,
82
+ lat,
83
+ tz,
84
+ direct_normal_irradiance_scaling=1.0,
85
+ diffuse_irradiance_scaling=1.0,
86
+ **kwargs,
87
+ ):
88
+ """
89
+ Integrate global horizontal irradiance over a period using EPW data.
90
+ Returns W/m²·hour accumulation on the ground plane.
91
+ """
92
+ view_point_height = kwargs.get("view_point_height", 1.5)
93
+ colormap = kwargs.get("colormap", "magma")
94
+ start_time = kwargs.get("start_time", "01-01 05:00:00")
95
+ end_time = kwargs.get("end_time", "01-01 20:00:00")
96
+ desired_threads = kwargs.get("numba_num_threads", None)
97
+ progress_report = kwargs.get("progress_report", False)
98
+ _configure_num_threads(desired_threads, progress=progress_report)
99
+
100
+ if df.empty:
101
+ raise ValueError("No data in EPW dataframe.")
102
+
103
+ try:
104
+ start_dt = datetime.strptime(start_time, "%m-%d %H:%M:%S")
105
+ end_dt = datetime.strptime(end_time, "%m-%d %H:%M:%S")
106
+ except ValueError as ve:
107
+ raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
108
+
109
+ df = df.copy()
110
+ df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
111
+ start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
112
+ end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
113
+ start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
114
+ end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
115
+
116
+ if start_hour <= end_hour:
117
+ df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
118
+ else:
119
+ df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
120
+
121
+ df_period = df_period[
122
+ ((df_period.index.hour != start_dt.hour) | (df_period.index.minute >= start_dt.minute)) &
123
+ ((df_period.index.hour != end_dt.hour) | (df_period.index.minute <= end_dt.minute))
124
+ ]
125
+
126
+ if df_period.empty:
127
+ raise ValueError("No EPW data in the specified period.")
128
+
129
+ offset_minutes = int(tz * 60)
130
+ local_tz = pytz.FixedOffset(offset_minutes)
131
+ df_period_local = df_period.copy()
132
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
133
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
134
+
135
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
136
+
137
+ diffuse_kwargs = kwargs.copy()
138
+ diffuse_kwargs.update({'show_plot': False, 'obj_export': False})
139
+ base_diffuse_map = get_diffuse_solar_irradiance_map(
140
+ voxcity,
141
+ diffuse_irradiance=1.0,
142
+ **diffuse_kwargs
143
+ )
144
+
145
+ nx, ny, _ = voxcity.voxels.classes.shape
146
+ cumulative_map = np.zeros((nx, ny))
147
+ mask_map = np.ones((nx, ny), dtype=bool)
148
+
149
+ direct_kwargs = kwargs.copy()
150
+ direct_kwargs.update({'show_plot': False, 'view_point_height': view_point_height, 'obj_export': False})
151
+
152
+ for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
153
+ DNI = float(row['DNI']) * direct_normal_irradiance_scaling
154
+ DHI = float(row['DHI']) * diffuse_irradiance_scaling
155
+
156
+ solpos = solar_positions.loc[time_utc]
157
+ azimuth_degrees = float(solpos['azimuth'])
158
+ elevation_degrees = float(solpos['elevation'])
159
+
160
+ direct_map = get_direct_solar_irradiance_map(
161
+ voxcity,
162
+ azimuth_degrees,
163
+ elevation_degrees,
164
+ direct_normal_irradiance=DNI,
165
+ **direct_kwargs,
166
+ )
167
+
168
+ diffuse_map = base_diffuse_map * DHI
169
+ global_map = direct_map + diffuse_map
170
+ mask_map &= ~np.isnan(global_map)
171
+ cumulative_map += np.nan_to_num(global_map, nan=0.0)
172
+
173
+ if kwargs.get("show_each_timestep", False):
174
+ vmin = kwargs.get("vmin", 0.0)
175
+ vmax = kwargs.get("vmax", max(direct_normal_irradiance_scaling, diffuse_irradiance_scaling) * 1000)
176
+ cmap = plt.cm.get_cmap(kwargs.get("colormap", "viridis")).copy()
177
+ cmap.set_bad(color="lightgray")
178
+ plt.figure(figsize=(10, 8))
179
+ plt.imshow(global_map, origin="lower", cmap=cmap, vmin=vmin, vmax=vmax)
180
+ plt.axis("off")
181
+ plt.colorbar(label="Global Solar Irradiance (W/m²)")
182
+ plt.show()
183
+
184
+ cumulative_map[~mask_map] = np.nan
185
+
186
+ if kwargs.get("show_plot", True):
187
+ vmin = kwargs.get("vmin", float(np.nanmin(cumulative_map)))
188
+ vmax = kwargs.get("vmax", float(np.nanmax(cumulative_map)))
189
+ cmap = plt.cm.get_cmap(colormap).copy()
190
+ cmap.set_bad(color="lightgray")
191
+ plt.figure(figsize=(10, 8))
192
+ plt.imshow(cumulative_map, origin="lower", cmap=cmap, vmin=vmin, vmax=vmax)
193
+ plt.colorbar(label="Cumulative Global Solar Irradiance (W/m²·hour)")
194
+ plt.axis("off")
195
+ plt.show()
196
+
197
+ if kwargs.get("obj_export", False):
198
+ vmin = kwargs.get("vmin", float(np.nanmin(cumulative_map)))
199
+ vmax = kwargs.get("vmax", float(np.nanmax(cumulative_map)))
200
+ dem_grid = kwargs.get("dem_grid", voxcity.dem.elevation if voxcity.dem else np.zeros_like(cumulative_map))
201
+ output_dir = kwargs.get("output_directory", "output")
202
+ output_file_name = kwargs.get("output_file_name", "cumulative_global_solar_irradiance")
203
+ num_colors = kwargs.get("num_colors", 10)
204
+ alpha = kwargs.get("alpha", 1.0)
205
+ meshsize = voxcity.voxels.meta.meshsize
206
+ grid_to_obj(
207
+ cumulative_map,
208
+ dem_grid,
209
+ output_dir,
210
+ output_file_name,
211
+ meshsize,
212
+ view_point_height,
213
+ colormap_name=colormap,
214
+ num_colors=num_colors,
215
+ alpha=alpha,
216
+ vmin=vmin,
217
+ vmax=vmax,
218
+ )
219
+
220
+ return cumulative_map
221
+
222
+
223
+ def get_cumulative_building_solar_irradiance(
224
+ voxcity: VoxCity,
225
+ building_svf_mesh,
226
+ weather_df,
227
+ lon,
228
+ lat,
229
+ tz,
230
+ **kwargs
231
+ ):
232
+ """
233
+ Cumulative Wh/m² on building faces over a period from weather dataframe.
234
+ """
235
+ import numpy as _np
236
+
237
+ period_start = kwargs.get("period_start", "01-01 00:00:00")
238
+ period_end = kwargs.get("period_end", "12-31 23:59:59")
239
+ time_step_hours = float(kwargs.get("time_step_hours", 1.0))
240
+ direct_normal_irradiance_scaling = float(kwargs.get("direct_normal_irradiance_scaling", 1.0))
241
+ diffuse_irradiance_scaling = float(kwargs.get("diffuse_irradiance_scaling", 1.0))
242
+ progress_report = kwargs.get("progress_report", False)
243
+ fast_path = kwargs.get("fast_path", True)
244
+
245
+ try:
246
+ start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
247
+ end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
248
+ except ValueError as ve:
249
+ raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
250
+
251
+ offset_minutes = int(tz * 60)
252
+ local_tz = pytz.FixedOffset(offset_minutes)
253
+
254
+ df_period = weather_df[
255
+ ((weather_df.index.month > start_dt.month) |
256
+ ((weather_df.index.month == start_dt.month) &
257
+ (weather_df.index.day >= start_dt.day) &
258
+ (weather_df.index.hour >= start_dt.hour))) &
259
+ ((weather_df.index.month < end_dt.month) |
260
+ ((weather_df.index.month == end_dt.month) &
261
+ (weather_df.index.day <= end_dt.day) &
262
+ (weather_df.index.hour <= end_dt.hour)))
263
+ ]
264
+ if df_period.empty:
265
+ raise ValueError("No weather data in specified period.")
266
+
267
+ df_period_local = df_period.copy()
268
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
269
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
270
+
271
+ precomputed_solar_positions = kwargs.get("precomputed_solar_positions", None)
272
+ if precomputed_solar_positions is not None and len(precomputed_solar_positions) == len(df_period_utc.index):
273
+ solar_positions = precomputed_solar_positions
274
+ else:
275
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
276
+
277
+ times_len = len(df_period_utc.index)
278
+ azimuth_deg_arr = solar_positions['azimuth'].to_numpy()
279
+ elev_deg_arr = solar_positions['elevation'].to_numpy()
280
+ az_rad_arr = _np.deg2rad(180.0 - azimuth_deg_arr)
281
+ el_rad_arr = _np.deg2rad(elev_deg_arr)
282
+ sun_dx_arr = _np.cos(el_rad_arr) * _np.cos(az_rad_arr)
283
+ sun_dy_arr = _np.cos(el_rad_arr) * _np.sin(az_rad_arr)
284
+ sun_dz_arr = _np.sin(el_rad_arr)
285
+ sun_dirs_arr = _np.stack([sun_dx_arr, sun_dy_arr, sun_dz_arr], axis=1).astype(_np.float64)
286
+ DNI_arr = (df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling).astype(_np.float64)
287
+ DHI_arr = (df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling).astype(_np.float64)
288
+ sun_above_mask = elev_deg_arr > 0.0
289
+
290
+ n_faces = len(building_svf_mesh.faces)
291
+ face_cum_direct = _np.zeros(n_faces, dtype=_np.float64)
292
+ face_cum_diffuse = _np.zeros(n_faces, dtype=_np.float64)
293
+ face_cum_global = _np.zeros(n_faces, dtype=_np.float64)
294
+
295
+ voxel_data = voxcity.voxels.classes
296
+ meshsize = float(voxcity.voxels.meta.meshsize)
297
+
298
+ precomputed_geometry = kwargs.get("precomputed_geometry", None)
299
+ if precomputed_geometry is not None:
300
+ face_centers = precomputed_geometry.get("face_centers", building_svf_mesh.triangles_center)
301
+ face_normals = precomputed_geometry.get("face_normals", building_svf_mesh.face_normals)
302
+ face_svf = precomputed_geometry.get(
303
+ "face_svf",
304
+ building_svf_mesh.metadata['svf'] if ('svf' in building_svf_mesh.metadata) else _np.zeros(n_faces, dtype=_np.float64)
305
+ )
306
+ grid_bounds_real = precomputed_geometry.get("grid_bounds_real", None)
307
+ boundary_epsilon = precomputed_geometry.get("boundary_epsilon", None)
308
+ else:
309
+ face_centers = building_svf_mesh.triangles_center
310
+ face_normals = building_svf_mesh.face_normals
311
+ face_svf = building_svf_mesh.metadata['svf'] if ('svf' in building_svf_mesh.metadata) else _np.zeros(n_faces, dtype=_np.float64)
312
+ grid_bounds_real = None
313
+ boundary_epsilon = None
314
+
315
+ if grid_bounds_real is None or boundary_epsilon is None:
316
+ grid_shape = voxel_data.shape
317
+ grid_bounds_voxel = _np.array([[0, 0, 0], [grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=_np.float64)
318
+ grid_bounds_real = grid_bounds_voxel * meshsize
319
+ boundary_epsilon = meshsize * 0.05
320
+
321
+ hit_values = (0,)
322
+ inclusion_mode = False
323
+ tree_k = kwargs.get("tree_k", 0.6)
324
+ tree_lad = kwargs.get("tree_lad", 1.0)
325
+
326
+ boundary_mask = None
327
+ instant_kwargs = kwargs.copy()
328
+ instant_kwargs['obj_export'] = False
329
+
330
+ total_steps = times_len
331
+ progress_every = max(1, total_steps // 20)
332
+
333
+ face_centers64 = (face_centers if isinstance(face_centers, _np.ndarray) else building_svf_mesh.triangles_center).astype(_np.float64)
334
+ face_normals64 = (face_normals if isinstance(face_normals, _np.ndarray) else building_svf_mesh.face_normals).astype(_np.float64)
335
+ face_svf64 = face_svf.astype(_np.float64)
336
+ x_min, y_min, z_min = grid_bounds_real[0, 0], grid_bounds_real[0, 1], grid_bounds_real[0, 2]
337
+ x_max, y_max, z_max = grid_bounds_real[1, 0], grid_bounds_real[1, 1], grid_bounds_real[1, 2]
338
+
339
+ if fast_path:
340
+ precomputed_masks = kwargs.get("precomputed_masks", None)
341
+ if precomputed_masks is not None:
342
+ vox_is_tree = precomputed_masks.get("vox_is_tree", (voxel_data == -2))
343
+ vox_is_opaque = precomputed_masks.get("vox_is_opaque", (voxel_data != 0) & (voxel_data != -2))
344
+ att = float(precomputed_masks.get("att", _np.exp(-tree_k * tree_lad * meshsize)))
345
+ else:
346
+ vox_is_tree = (voxel_data == -2)
347
+ vox_is_opaque = (voxel_data != 0) & (~vox_is_tree)
348
+ att = float(_np.exp(-tree_k * tree_lad * meshsize))
349
+
350
+ time_batch_size = _auto_time_batch_size(n_faces, total_steps, kwargs.get("time_batch_size", None))
351
+ if progress_report:
352
+ print(f"Faces: {n_faces:,}, Timesteps: {total_steps:,}, Batch size: {time_batch_size}")
353
+
354
+ for start in range(0, total_steps, time_batch_size):
355
+ end = min(start + time_batch_size, total_steps)
356
+ ch_dir, ch_diff, ch_glob = compute_cumulative_solar_irradiance_faces_masked_timeseries(
357
+ face_centers64,
358
+ face_normals64,
359
+ face_svf64,
360
+ sun_dirs_arr.astype(_np.float64),
361
+ DNI_arr.astype(_np.float64),
362
+ DHI_arr.astype(_np.float64),
363
+ vox_is_tree,
364
+ vox_is_opaque,
365
+ float(meshsize),
366
+ float(att),
367
+ float(x_min), float(y_min), float(z_min),
368
+ float(x_max), float(y_max), float(z_max),
369
+ float(boundary_epsilon),
370
+ int(start), int(end),
371
+ float(time_step_hours)
372
+ )
373
+ face_cum_direct += ch_dir
374
+ face_cum_diffuse += ch_diff
375
+ face_cum_global += ch_glob
376
+ if progress_report:
377
+ pct = (end * 100.0) / total_steps
378
+ print(f"Cumulative irradiance: {end}/{total_steps} ({pct:.1f}%)")
379
+ else:
380
+ for idx in range(total_steps):
381
+ DNI = float(DNI_arr[idx])
382
+ DHI = float(DHI_arr[idx])
383
+ if not sun_above_mask[idx]:
384
+ if boundary_mask is None:
385
+ boundary_mask = _np.isnan(face_svf)
386
+ face_cum_diffuse += _np.nan_to_num(face_svf * DHI) * time_step_hours
387
+ face_cum_global += _np.nan_to_num(face_svf * DHI) * time_step_hours
388
+ if progress_report and (((idx + 1) % progress_every == 0) or (idx == total_steps - 1)):
389
+ pct = (idx + 1) * 100.0 / total_steps
390
+ print(f"Cumulative irradiance: {idx+1}/{total_steps} ({pct:.1f}%)")
391
+ continue
392
+
393
+ irr_mesh = get_building_solar_irradiance(
394
+ voxcity,
395
+ building_svf_mesh,
396
+ float(azimuth_deg_arr[idx]),
397
+ float(elev_deg_arr[idx]),
398
+ DNI,
399
+ DHI,
400
+ show_plot=False,
401
+ **instant_kwargs
402
+ )
403
+ face_direct = irr_mesh.metadata['direct']
404
+ face_diffuse = irr_mesh.metadata['diffuse']
405
+ face_global = irr_mesh.metadata['global']
406
+
407
+ if boundary_mask is None:
408
+ boundary_mask = _np.isnan(face_global)
409
+
410
+ face_cum_direct += _np.nan_to_num(face_direct) * time_step_hours
411
+ face_cum_diffuse += _np.nan_to_num(face_diffuse) * time_step_hours
412
+ face_cum_global += _np.nan_to_num(face_global) * time_step_hours
413
+
414
+ if progress_report and (((idx + 1) % progress_every == 0) or (idx == total_steps - 1)):
415
+ pct = (idx + 1) * 100.0 / total_steps
416
+ print(f"Cumulative irradiance: {idx+1}/{total_steps} ({pct:.1f}%)")
417
+
418
+ if boundary_mask is not None:
419
+ face_cum_direct[boundary_mask] = _np.nan
420
+ face_cum_diffuse[boundary_mask] = _np.nan
421
+ face_cum_global[boundary_mask] = _np.nan
422
+
423
+ cumulative_mesh = building_svf_mesh.copy()
424
+ if not hasattr(cumulative_mesh, 'metadata'):
425
+ cumulative_mesh.metadata = {}
426
+ if 'svf' in building_svf_mesh.metadata:
427
+ cumulative_mesh.metadata['svf'] = building_svf_mesh.metadata['svf']
428
+ cumulative_mesh.metadata['direct'] = face_cum_direct
429
+ cumulative_mesh.metadata['diffuse'] = face_cum_diffuse
430
+ cumulative_mesh.metadata['global'] = face_cum_global
431
+ cumulative_mesh.name = "Cumulative Solar Irradiance (Wh/m²)"
432
+ return cumulative_mesh
433
+
434
+