voxcity 0.7.0__py3-none-any.whl → 1.0.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. voxcity/__init__.py +14 -14
  2. voxcity/downloader/ocean.py +559 -0
  3. voxcity/exporter/__init__.py +12 -12
  4. voxcity/exporter/cityles.py +633 -633
  5. voxcity/exporter/envimet.py +733 -728
  6. voxcity/exporter/magicavoxel.py +333 -333
  7. voxcity/exporter/netcdf.py +238 -238
  8. voxcity/exporter/obj.py +1480 -1480
  9. voxcity/generator/__init__.py +47 -44
  10. voxcity/generator/api.py +727 -675
  11. voxcity/generator/grids.py +394 -379
  12. voxcity/generator/io.py +94 -94
  13. voxcity/generator/pipeline.py +582 -282
  14. voxcity/generator/update.py +429 -0
  15. voxcity/generator/voxelizer.py +18 -6
  16. voxcity/geoprocessor/__init__.py +75 -75
  17. voxcity/geoprocessor/draw.py +1494 -1219
  18. voxcity/geoprocessor/merge_utils.py +91 -91
  19. voxcity/geoprocessor/mesh.py +806 -806
  20. voxcity/geoprocessor/network.py +708 -708
  21. voxcity/geoprocessor/raster/__init__.py +2 -0
  22. voxcity/geoprocessor/raster/buildings.py +435 -428
  23. voxcity/geoprocessor/raster/core.py +31 -0
  24. voxcity/geoprocessor/raster/export.py +93 -93
  25. voxcity/geoprocessor/raster/landcover.py +178 -51
  26. voxcity/geoprocessor/raster/raster.py +1 -1
  27. voxcity/geoprocessor/utils.py +824 -824
  28. voxcity/models.py +115 -113
  29. voxcity/simulator/solar/__init__.py +66 -43
  30. voxcity/simulator/solar/integration.py +336 -336
  31. voxcity/simulator/solar/sky.py +668 -0
  32. voxcity/simulator/solar/temporal.py +792 -434
  33. voxcity/simulator_gpu/__init__.py +115 -0
  34. voxcity/simulator_gpu/common/__init__.py +9 -0
  35. voxcity/simulator_gpu/common/geometry.py +11 -0
  36. voxcity/simulator_gpu/core.py +322 -0
  37. voxcity/simulator_gpu/domain.py +262 -0
  38. voxcity/simulator_gpu/environment.yml +11 -0
  39. voxcity/simulator_gpu/init_taichi.py +154 -0
  40. voxcity/simulator_gpu/integration.py +15 -0
  41. voxcity/simulator_gpu/kernels.py +56 -0
  42. voxcity/simulator_gpu/radiation.py +28 -0
  43. voxcity/simulator_gpu/raytracing.py +623 -0
  44. voxcity/simulator_gpu/sky.py +9 -0
  45. voxcity/simulator_gpu/solar/__init__.py +178 -0
  46. voxcity/simulator_gpu/solar/core.py +66 -0
  47. voxcity/simulator_gpu/solar/csf.py +1249 -0
  48. voxcity/simulator_gpu/solar/domain.py +561 -0
  49. voxcity/simulator_gpu/solar/epw.py +421 -0
  50. voxcity/simulator_gpu/solar/integration.py +2953 -0
  51. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  52. voxcity/simulator_gpu/solar/raytracing.py +686 -0
  53. voxcity/simulator_gpu/solar/reflection.py +533 -0
  54. voxcity/simulator_gpu/solar/sky.py +907 -0
  55. voxcity/simulator_gpu/solar/solar.py +337 -0
  56. voxcity/simulator_gpu/solar/svf.py +446 -0
  57. voxcity/simulator_gpu/solar/volumetric.py +1151 -0
  58. voxcity/simulator_gpu/solar/voxcity.py +2953 -0
  59. voxcity/simulator_gpu/temporal.py +13 -0
  60. voxcity/simulator_gpu/utils.py +25 -0
  61. voxcity/simulator_gpu/view.py +32 -0
  62. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  63. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  64. voxcity/simulator_gpu/visibility/integration.py +808 -0
  65. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  66. voxcity/simulator_gpu/visibility/view.py +944 -0
  67. voxcity/utils/__init__.py +11 -0
  68. voxcity/utils/classes.py +194 -0
  69. voxcity/utils/lc.py +80 -39
  70. voxcity/utils/shape.py +230 -0
  71. voxcity/visualizer/__init__.py +24 -24
  72. voxcity/visualizer/builder.py +43 -43
  73. voxcity/visualizer/grids.py +141 -141
  74. voxcity/visualizer/maps.py +187 -187
  75. voxcity/visualizer/renderer.py +1146 -928
  76. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/METADATA +56 -52
  77. voxcity-1.0.13.dist-info/RECORD +116 -0
  78. voxcity-0.7.0.dist-info/RECORD +0 -77
  79. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
  80. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
@@ -1,434 +1,792 @@
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
-
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
+ from .sky import (
21
+ generate_tregenza_patches,
22
+ generate_reinhart_patches,
23
+ generate_uniform_grid_patches,
24
+ generate_fibonacci_patches,
25
+ bin_sun_positions_to_patches,
26
+ get_tregenza_patch_index_fast,
27
+ )
28
+
29
+
30
+ def get_solar_positions_astral(times, lon, lat):
31
+ """
32
+ Compute solar azimuth and elevation for given times and location using Astral.
33
+ Returns a DataFrame indexed by times with columns ['azimuth', 'elevation'] (degrees).
34
+ """
35
+ import pandas as pd
36
+ from astral import Observer
37
+ from astral.sun import elevation, azimuth
38
+
39
+ observer = Observer(latitude=lat, longitude=lon)
40
+ df_pos = pd.DataFrame(index=times, columns=['azimuth', 'elevation'], dtype=float)
41
+ for t in times:
42
+ el = elevation(observer=observer, dateandtime=t)
43
+ az = azimuth(observer=observer, dateandtime=t)
44
+ df_pos.at[t, 'elevation'] = el
45
+ df_pos.at[t, 'azimuth'] = az
46
+ return df_pos
47
+
48
+ def _configure_num_threads(desired_threads=None, progress=False):
49
+ try:
50
+ cores = os.cpu_count() or 4
51
+ except Exception:
52
+ cores = 4
53
+ used = desired_threads if desired_threads is not None else cores
54
+ try:
55
+ numba.set_num_threads(int(used))
56
+ except Exception:
57
+ pass
58
+ os.environ.setdefault('MKL_NUM_THREADS', '1')
59
+ if 'OMP_NUM_THREADS' not in os.environ:
60
+ os.environ['OMP_NUM_THREADS'] = str(int(used))
61
+ if progress:
62
+ try:
63
+ print(f"Numba threads: {numba.get_num_threads()} (requested {used})")
64
+ except Exception:
65
+ print(f"Numba threads set to {used}")
66
+ return used
67
+
68
+
69
+ def _auto_time_batch_size(n_faces, total_steps, user_value=None):
70
+ if user_value is not None:
71
+ return max(1, int(user_value))
72
+ if total_steps <= 0:
73
+ return 1
74
+ if n_faces <= 5_000:
75
+ batches = 2
76
+ elif n_faces <= 50_000:
77
+ batches = 8
78
+ elif n_faces <= 200_000:
79
+ batches = 16
80
+ else:
81
+ batches = 32
82
+ batches = min(batches, total_steps)
83
+ return max(1, total_steps // batches)
84
+
85
+
86
+ # =============================================================================
87
+ # Sky Patch Optimization for Cumulative Solar Irradiance
88
+ # =============================================================================
89
+
90
+ def _aggregate_weather_to_sky_patches(
91
+ azimuth_arr,
92
+ elevation_arr,
93
+ dni_arr,
94
+ dhi_arr,
95
+ time_step_hours=1.0,
96
+ sky_discretization="tregenza",
97
+ **kwargs
98
+ ):
99
+ """
100
+ Aggregate weather data (DNI, DHI) into sky patches for efficient cumulative calculation.
101
+
102
+ Instead of computing for each hourly sun position (potentially 8760 per year),
103
+ this aggregates DNI into sky patches and sums DHI. Ray tracing is then performed
104
+ once per patch instead of per timestep.
105
+
106
+ Parameters
107
+ ----------
108
+ azimuth_arr : np.ndarray
109
+ Solar azimuth values in degrees.
110
+ elevation_arr : np.ndarray
111
+ Solar elevation values in degrees.
112
+ dni_arr : np.ndarray
113
+ Direct Normal Irradiance (W/m²) for each timestep.
114
+ dhi_arr : np.ndarray
115
+ Diffuse Horizontal Irradiance (W/m²) for each timestep.
116
+ time_step_hours : float
117
+ Duration of each timestep in hours.
118
+ sky_discretization : str
119
+ Method: "tregenza", "reinhart", "uniform", "fibonacci".
120
+ **kwargs : dict
121
+ Additional parameters (e.g., mf for Reinhart).
122
+
123
+ Returns
124
+ -------
125
+ dict
126
+ Contains:
127
+ - 'patch_directions': Unit vectors for each patch (N, 3)
128
+ - 'patch_cumulative_dni': Cumulative DNI×hours per patch (Wh/m²)
129
+ - 'patch_solid_angles': Solid angle of each patch (steradians)
130
+ - 'patch_hours': Number of hours sun was in each patch
131
+ - 'total_cumulative_dhi': Total DHI×hours sum (Wh/m²)
132
+ - 'n_patches': Number of patches
133
+ - 'n_original_timesteps': Original number of timesteps
134
+ """
135
+ # Generate sky patches based on method
136
+ if sky_discretization.lower() == "tregenza":
137
+ patches, directions, solid_angles = generate_tregenza_patches()
138
+ elif sky_discretization.lower() == "reinhart":
139
+ mf = kwargs.get("reinhart_mf", kwargs.get("mf", 4))
140
+ patches, directions, solid_angles = generate_reinhart_patches(mf=mf)
141
+ elif sky_discretization.lower() == "uniform":
142
+ n_az = kwargs.get("sky_n_azimuth", kwargs.get("n_azimuth", 36))
143
+ n_el = kwargs.get("sky_n_elevation", kwargs.get("n_elevation", 9))
144
+ patches, directions, solid_angles = generate_uniform_grid_patches(n_az, n_el)
145
+ elif sky_discretization.lower() == "fibonacci":
146
+ n_patches = kwargs.get("sky_n_patches", kwargs.get("n_patches", 145))
147
+ patches, directions, solid_angles = generate_fibonacci_patches(n_patches=n_patches)
148
+ else:
149
+ raise ValueError(f"Unknown sky discretization method: {sky_discretization}")
150
+
151
+ n_patches = len(patches)
152
+ cumulative_dni = np.zeros(n_patches, dtype=np.float64)
153
+ hours_count = np.zeros(n_patches, dtype=np.int32)
154
+ total_cumulative_dhi = 0.0
155
+ n_timesteps = len(azimuth_arr)
156
+
157
+ # Bin each sun position to a patch
158
+ for i in range(n_timesteps):
159
+ elev = elevation_arr[i]
160
+ dhi = dhi_arr[i]
161
+
162
+ # DHI accumulates regardless of sun position (sky-based diffuse)
163
+ if dhi > 0:
164
+ total_cumulative_dhi += dhi * time_step_hours
165
+
166
+ # DNI only when sun is above horizon
167
+ if elev <= 0:
168
+ continue
169
+
170
+ az = azimuth_arr[i]
171
+ dni = dni_arr[i]
172
+
173
+ if dni <= 0:
174
+ continue
175
+
176
+ # Find nearest patch
177
+ if sky_discretization.lower() == "tregenza":
178
+ patch_idx = int(get_tregenza_patch_index_fast(float(az), float(elev)))
179
+ else:
180
+ # For other methods, find nearest patch by direction
181
+ elev_rad = np.deg2rad(elev)
182
+ az_rad = np.deg2rad(az)
183
+ sun_dir = np.array([
184
+ np.cos(elev_rad) * np.cos(az_rad),
185
+ np.cos(elev_rad) * np.sin(az_rad),
186
+ np.sin(elev_rad)
187
+ ])
188
+ dots = np.sum(directions * sun_dir, axis=1)
189
+ patch_idx = int(np.argmax(dots))
190
+
191
+ if patch_idx >= 0 and patch_idx < n_patches:
192
+ cumulative_dni[patch_idx] += dni * time_step_hours
193
+ hours_count[patch_idx] += 1
194
+
195
+ # Filter to patches that actually have sun exposure
196
+ active_mask = cumulative_dni > 0
197
+
198
+ return {
199
+ 'patches': patches,
200
+ 'patch_directions': directions,
201
+ 'patch_cumulative_dni': cumulative_dni,
202
+ 'patch_solid_angles': solid_angles,
203
+ 'patch_hours': hours_count,
204
+ 'active_mask': active_mask,
205
+ 'n_active_patches': int(np.sum(active_mask)),
206
+ 'total_cumulative_dhi': total_cumulative_dhi,
207
+ 'n_patches': n_patches,
208
+ 'n_original_timesteps': n_timesteps,
209
+ 'method': sky_discretization,
210
+ }
211
+
212
+
213
+ def get_cumulative_global_solar_irradiance(
214
+ voxcity: VoxCity,
215
+ df,
216
+ lon,
217
+ lat,
218
+ tz,
219
+ direct_normal_irradiance_scaling=1.0,
220
+ diffuse_irradiance_scaling=1.0,
221
+ **kwargs,
222
+ ):
223
+ """
224
+ Integrate global horizontal irradiance over a period using EPW data.
225
+ Returns W/m²·hour accumulation on the ground plane.
226
+
227
+ Parameters
228
+ ----------
229
+ voxcity : VoxCity
230
+ The VoxCity model.
231
+ df : pd.DataFrame
232
+ Weather data with 'DNI' and 'DHI' columns.
233
+ lon, lat : float
234
+ Longitude and latitude for solar position calculation.
235
+ tz : float
236
+ Timezone offset in hours.
237
+ direct_normal_irradiance_scaling : float
238
+ Scaling factor for DNI.
239
+ diffuse_irradiance_scaling : float
240
+ Scaling factor for DHI.
241
+ **kwargs : dict
242
+ Additional options:
243
+ - use_sky_patches : bool (default False)
244
+ If True, use sky patch aggregation for efficiency.
245
+ DNI is aggregated into sky patches and ray tracing is done
246
+ once per patch instead of per timestep.
247
+ - sky_discretization : str (default "tregenza")
248
+ Sky discretization method: "tregenza", "reinhart", "uniform", "fibonacci"
249
+ - reinhart_mf : int (default 4)
250
+ Multiplication factor for Reinhart subdivision.
251
+ - sky_n_patches : int (default 145)
252
+ Number of patches for Fibonacci method.
253
+ - progress_report : bool
254
+ Print progress information.
255
+
256
+ Returns
257
+ -------
258
+ np.ndarray
259
+ Cumulative global solar irradiance map (Wh/m²).
260
+ """
261
+ view_point_height = kwargs.get("view_point_height", 1.5)
262
+ colormap = kwargs.get("colormap", "magma")
263
+ start_time = kwargs.get("start_time", "01-01 05:00:00")
264
+ end_time = kwargs.get("end_time", "01-01 20:00:00")
265
+ desired_threads = kwargs.get("numba_num_threads", None)
266
+ progress_report = kwargs.get("progress_report", False)
267
+ use_sky_patches = kwargs.get("use_sky_patches", False)
268
+ sky_discretization = kwargs.get("sky_discretization", "tregenza")
269
+ _configure_num_threads(desired_threads, progress=progress_report)
270
+
271
+ if df.empty:
272
+ raise ValueError("No data in EPW dataframe.")
273
+
274
+ try:
275
+ start_dt = datetime.strptime(start_time, "%m-%d %H:%M:%S")
276
+ end_dt = datetime.strptime(end_time, "%m-%d %H:%M:%S")
277
+ except ValueError as ve:
278
+ raise ValueError("start_time and end_time must be in format 'MM-DD HH:MM:SS'") from ve
279
+
280
+ df = df.copy()
281
+ df['hour_of_year'] = (df.index.dayofyear - 1) * 24 + df.index.hour + 1
282
+ start_doy = datetime(2000, start_dt.month, start_dt.day).timetuple().tm_yday
283
+ end_doy = datetime(2000, end_dt.month, end_dt.day).timetuple().tm_yday
284
+ start_hour = (start_doy - 1) * 24 + start_dt.hour + 1
285
+ end_hour = (end_doy - 1) * 24 + end_dt.hour + 1
286
+
287
+ if start_hour <= end_hour:
288
+ df_period = df[(df['hour_of_year'] >= start_hour) & (df['hour_of_year'] <= end_hour)]
289
+ else:
290
+ df_period = df[(df['hour_of_year'] >= start_hour) | (df['hour_of_year'] <= end_hour)]
291
+
292
+ df_period = df_period[
293
+ ((df_period.index.hour != start_dt.hour) | (df_period.index.minute >= start_dt.minute)) &
294
+ ((df_period.index.hour != end_dt.hour) | (df_period.index.minute <= end_dt.minute))
295
+ ]
296
+
297
+ if df_period.empty:
298
+ raise ValueError("No EPW data in the specified period.")
299
+
300
+ offset_minutes = int(tz * 60)
301
+ local_tz = pytz.FixedOffset(offset_minutes)
302
+ df_period_local = df_period.copy()
303
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
304
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
305
+
306
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
307
+
308
+ # Compute base diffuse map (SVF-based) - used in both methods
309
+ diffuse_kwargs = kwargs.copy()
310
+ diffuse_kwargs.update({'show_plot': False, 'obj_export': False})
311
+ base_diffuse_map = get_diffuse_solar_irradiance_map(
312
+ voxcity,
313
+ diffuse_irradiance=1.0,
314
+ **diffuse_kwargs
315
+ )
316
+
317
+ nx, ny, _ = voxcity.voxels.classes.shape
318
+ cumulative_map = np.zeros((nx, ny))
319
+ mask_map = np.ones((nx, ny), dtype=bool)
320
+
321
+ direct_kwargs = kwargs.copy()
322
+ direct_kwargs.update({'show_plot': False, 'view_point_height': view_point_height, 'obj_export': False})
323
+
324
+ # =========================================================================
325
+ # Sky Patch Optimization Path
326
+ # =========================================================================
327
+ if use_sky_patches:
328
+ # Extract arrays for aggregation
329
+ azimuth_arr = solar_positions['azimuth'].to_numpy()
330
+ elevation_arr = solar_positions['elevation'].to_numpy()
331
+ dni_arr = df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling
332
+ dhi_arr = df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling
333
+ time_step_hours = kwargs.get("time_step_hours", 1.0)
334
+
335
+ # Aggregate weather data into sky patches
336
+ # Filter kwargs to avoid duplicate parameters
337
+ sky_kwargs = {k: v for k, v in kwargs.items()
338
+ if k not in ('sky_discretization', 'time_step_hours')}
339
+ patch_data = _aggregate_weather_to_sky_patches(
340
+ azimuth_arr, elevation_arr, dni_arr, dhi_arr,
341
+ time_step_hours=time_step_hours,
342
+ sky_discretization=sky_discretization,
343
+ **sky_kwargs
344
+ )
345
+
346
+ if progress_report:
347
+ print(f"Sky patch optimization: {patch_data['n_original_timesteps']} timesteps "
348
+ f"{patch_data['n_active_patches']} active patches ({patch_data['method']})")
349
+ print(f" Total cumulative DHI: {patch_data['total_cumulative_dhi']:.1f} Wh/m²")
350
+
351
+ # Diffuse component: SVF × total cumulative DHI
352
+ cumulative_diffuse = base_diffuse_map * patch_data['total_cumulative_dhi']
353
+ cumulative_map += np.nan_to_num(cumulative_diffuse, nan=0.0)
354
+ mask_map &= ~np.isnan(cumulative_diffuse)
355
+
356
+ # Direct component: loop over active patches only
357
+ active_indices = np.where(patch_data['active_mask'])[0]
358
+ patches = patch_data['patches']
359
+ patch_cumulative_dni = patch_data['patch_cumulative_dni']
360
+
361
+ for i, patch_idx in enumerate(active_indices):
362
+ az_deg = patches[patch_idx, 0]
363
+ el_deg = patches[patch_idx, 1]
364
+ cumulative_dni_patch = patch_cumulative_dni[patch_idx]
365
+
366
+ # Compute direct transmittance map for this patch direction
367
+ # Using DNI=1.0 to get transmittance, then multiply by cumulative DNI
368
+ direct_map = get_direct_solar_irradiance_map(
369
+ voxcity,
370
+ az_deg,
371
+ el_deg,
372
+ direct_normal_irradiance=1.0, # Get transmittance
373
+ **direct_kwargs,
374
+ )
375
+
376
+ # Accumulate: transmittance × cumulative DNI for this patch
377
+ patch_contribution = direct_map * cumulative_dni_patch
378
+ mask_map &= ~np.isnan(patch_contribution)
379
+ cumulative_map += np.nan_to_num(patch_contribution, nan=0.0)
380
+
381
+ if progress_report and ((i + 1) % max(1, len(active_indices) // 10) == 0 or i == len(active_indices) - 1):
382
+ pct = (i + 1) * 100.0 / len(active_indices)
383
+ print(f" Patch {i+1}/{len(active_indices)} ({pct:.1f}%)")
384
+
385
+ # =========================================================================
386
+ # Original Per-Timestep Path
387
+ # =========================================================================
388
+ else:
389
+ for idx, (time_utc, row) in enumerate(df_period_utc.iterrows()):
390
+ DNI = float(row['DNI']) * direct_normal_irradiance_scaling
391
+ DHI = float(row['DHI']) * diffuse_irradiance_scaling
392
+
393
+ solpos = solar_positions.loc[time_utc]
394
+ azimuth_degrees = float(solpos['azimuth'])
395
+ elevation_degrees = float(solpos['elevation'])
396
+
397
+ direct_map = get_direct_solar_irradiance_map(
398
+ voxcity,
399
+ azimuth_degrees,
400
+ elevation_degrees,
401
+ direct_normal_irradiance=DNI,
402
+ **direct_kwargs,
403
+ )
404
+
405
+ diffuse_map = base_diffuse_map * DHI
406
+ global_map = direct_map + diffuse_map
407
+ mask_map &= ~np.isnan(global_map)
408
+ cumulative_map += np.nan_to_num(global_map, nan=0.0)
409
+
410
+ if kwargs.get("show_each_timestep", False):
411
+ vmin = kwargs.get("vmin", 0.0)
412
+ vmax = kwargs.get("vmax", max(direct_normal_irradiance_scaling, diffuse_irradiance_scaling) * 1000)
413
+ cmap = plt.cm.get_cmap(kwargs.get("colormap", "viridis")).copy()
414
+ cmap.set_bad(color="lightgray")
415
+ plt.figure(figsize=(10, 8))
416
+ plt.imshow(global_map, origin="lower", cmap=cmap, vmin=vmin, vmax=vmax)
417
+ plt.axis("off")
418
+ plt.colorbar(label="Global Solar Irradiance (W/m²)")
419
+ plt.show()
420
+
421
+ cumulative_map[~mask_map] = np.nan
422
+
423
+ if kwargs.get("show_plot", True):
424
+ vmin = kwargs.get("vmin", float(np.nanmin(cumulative_map)))
425
+ vmax = kwargs.get("vmax", float(np.nanmax(cumulative_map)))
426
+ cmap = plt.cm.get_cmap(colormap).copy()
427
+ cmap.set_bad(color="lightgray")
428
+ plt.figure(figsize=(10, 8))
429
+ plt.imshow(cumulative_map, origin="lower", cmap=cmap, vmin=vmin, vmax=vmax)
430
+ plt.colorbar(label="Cumulative Global Solar Irradiance (W/m²·hour)")
431
+ plt.axis("off")
432
+ plt.show()
433
+
434
+ if kwargs.get("obj_export", False):
435
+ vmin = kwargs.get("vmin", float(np.nanmin(cumulative_map)))
436
+ vmax = kwargs.get("vmax", float(np.nanmax(cumulative_map)))
437
+ dem_grid = kwargs.get("dem_grid", voxcity.dem.elevation if voxcity.dem else np.zeros_like(cumulative_map))
438
+ output_dir = kwargs.get("output_directory", "output")
439
+ output_file_name = kwargs.get("output_file_name", "cumulative_global_solar_irradiance")
440
+ num_colors = kwargs.get("num_colors", 10)
441
+ alpha = kwargs.get("alpha", 1.0)
442
+ meshsize = voxcity.voxels.meta.meshsize
443
+ grid_to_obj(
444
+ cumulative_map,
445
+ dem_grid,
446
+ output_dir,
447
+ output_file_name,
448
+ meshsize,
449
+ view_point_height,
450
+ colormap_name=colormap,
451
+ num_colors=num_colors,
452
+ alpha=alpha,
453
+ vmin=vmin,
454
+ vmax=vmax,
455
+ )
456
+
457
+ return cumulative_map
458
+
459
+
460
+ def get_cumulative_building_solar_irradiance(
461
+ voxcity: VoxCity,
462
+ building_svf_mesh,
463
+ weather_df,
464
+ lon,
465
+ lat,
466
+ tz,
467
+ **kwargs
468
+ ):
469
+ """
470
+ Cumulative Wh/m² on building faces over a period from weather dataframe.
471
+
472
+ Parameters
473
+ ----------
474
+ voxcity : VoxCity
475
+ The VoxCity model.
476
+ building_svf_mesh : trimesh.Trimesh
477
+ Building mesh with SVF in metadata.
478
+ weather_df : pd.DataFrame
479
+ Weather data with 'DNI' and 'DHI' columns.
480
+ lon, lat : float
481
+ Longitude and latitude.
482
+ tz : float
483
+ Timezone offset in hours.
484
+ **kwargs : dict
485
+ Additional options:
486
+ - use_sky_patches : bool (default False)
487
+ If True, use sky patch aggregation for efficiency.
488
+ Reduces computation from N timesteps to M patches (M << N).
489
+ - sky_discretization : str (default "tregenza")
490
+ Method: "tregenza", "reinhart", "uniform", "fibonacci"
491
+ - reinhart_mf : int (default 4)
492
+ Multiplication factor for Reinhart subdivision.
493
+ - fast_path : bool (default True)
494
+ Use optimized Numba kernels.
495
+ - progress_report : bool
496
+ Print progress information.
497
+
498
+ Returns
499
+ -------
500
+ trimesh.Trimesh
501
+ Mesh with cumulative irradiance in metadata (direct, diffuse, global in Wh/m²).
502
+ """
503
+ import numpy as _np
504
+
505
+ period_start = kwargs.get("period_start", "01-01 00:00:00")
506
+ period_end = kwargs.get("period_end", "12-31 23:59:59")
507
+ time_step_hours = float(kwargs.get("time_step_hours", 1.0))
508
+ direct_normal_irradiance_scaling = float(kwargs.get("direct_normal_irradiance_scaling", 1.0))
509
+ diffuse_irradiance_scaling = float(kwargs.get("diffuse_irradiance_scaling", 1.0))
510
+ progress_report = kwargs.get("progress_report", False)
511
+ fast_path = kwargs.get("fast_path", True)
512
+ use_sky_patches = kwargs.get("use_sky_patches", False)
513
+ sky_discretization = kwargs.get("sky_discretization", "tregenza")
514
+
515
+ try:
516
+ start_dt = datetime.strptime(period_start, "%m-%d %H:%M:%S")
517
+ end_dt = datetime.strptime(period_end, "%m-%d %H:%M:%S")
518
+ except ValueError as ve:
519
+ raise ValueError("Time must be in format 'MM-DD HH:MM:SS'") from ve
520
+
521
+ offset_minutes = int(tz * 60)
522
+ local_tz = pytz.FixedOffset(offset_minutes)
523
+
524
+ df_period = weather_df[
525
+ ((weather_df.index.month > start_dt.month) |
526
+ ((weather_df.index.month == start_dt.month) &
527
+ (weather_df.index.day >= start_dt.day) &
528
+ (weather_df.index.hour >= start_dt.hour))) &
529
+ ((weather_df.index.month < end_dt.month) |
530
+ ((weather_df.index.month == end_dt.month) &
531
+ (weather_df.index.day <= end_dt.day) &
532
+ (weather_df.index.hour <= end_dt.hour)))
533
+ ]
534
+ if df_period.empty:
535
+ raise ValueError("No weather data in specified period.")
536
+
537
+ df_period_local = df_period.copy()
538
+ df_period_local.index = df_period_local.index.tz_localize(local_tz)
539
+ df_period_utc = df_period_local.tz_convert(pytz.UTC)
540
+
541
+ precomputed_solar_positions = kwargs.get("precomputed_solar_positions", None)
542
+ if precomputed_solar_positions is not None and len(precomputed_solar_positions) == len(df_period_utc.index):
543
+ solar_positions = precomputed_solar_positions
544
+ else:
545
+ solar_positions = get_solar_positions_astral(df_period_utc.index, lon, lat)
546
+
547
+ times_len = len(df_period_utc.index)
548
+ azimuth_deg_arr = solar_positions['azimuth'].to_numpy()
549
+ elev_deg_arr = solar_positions['elevation'].to_numpy()
550
+ az_rad_arr = _np.deg2rad(180.0 - azimuth_deg_arr)
551
+ el_rad_arr = _np.deg2rad(elev_deg_arr)
552
+ sun_dx_arr = _np.cos(el_rad_arr) * _np.cos(az_rad_arr)
553
+ sun_dy_arr = _np.cos(el_rad_arr) * _np.sin(az_rad_arr)
554
+ sun_dz_arr = _np.sin(el_rad_arr)
555
+ sun_dirs_arr = _np.stack([sun_dx_arr, sun_dy_arr, sun_dz_arr], axis=1).astype(_np.float64)
556
+ DNI_arr = (df_period_utc['DNI'].to_numpy() * direct_normal_irradiance_scaling).astype(_np.float64)
557
+ DHI_arr = (df_period_utc['DHI'].to_numpy() * diffuse_irradiance_scaling).astype(_np.float64)
558
+ sun_above_mask = elev_deg_arr > 0.0
559
+
560
+ n_faces = len(building_svf_mesh.faces)
561
+ face_cum_direct = _np.zeros(n_faces, dtype=_np.float64)
562
+ face_cum_diffuse = _np.zeros(n_faces, dtype=_np.float64)
563
+ face_cum_global = _np.zeros(n_faces, dtype=_np.float64)
564
+
565
+ voxel_data = voxcity.voxels.classes
566
+ meshsize = float(voxcity.voxels.meta.meshsize)
567
+
568
+ precomputed_geometry = kwargs.get("precomputed_geometry", None)
569
+ if precomputed_geometry is not None:
570
+ face_centers = precomputed_geometry.get("face_centers", building_svf_mesh.triangles_center)
571
+ face_normals = precomputed_geometry.get("face_normals", building_svf_mesh.face_normals)
572
+ face_svf = precomputed_geometry.get(
573
+ "face_svf",
574
+ building_svf_mesh.metadata['svf'] if ('svf' in building_svf_mesh.metadata) else _np.zeros(n_faces, dtype=_np.float64)
575
+ )
576
+ grid_bounds_real = precomputed_geometry.get("grid_bounds_real", None)
577
+ boundary_epsilon = precomputed_geometry.get("boundary_epsilon", None)
578
+ else:
579
+ face_centers = building_svf_mesh.triangles_center
580
+ face_normals = building_svf_mesh.face_normals
581
+ face_svf = building_svf_mesh.metadata['svf'] if ('svf' in building_svf_mesh.metadata) else _np.zeros(n_faces, dtype=_np.float64)
582
+ grid_bounds_real = None
583
+ boundary_epsilon = None
584
+
585
+ if grid_bounds_real is None or boundary_epsilon is None:
586
+ grid_shape = voxel_data.shape
587
+ grid_bounds_voxel = _np.array([[0, 0, 0], [grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=_np.float64)
588
+ grid_bounds_real = grid_bounds_voxel * meshsize
589
+ boundary_epsilon = meshsize * 0.05
590
+
591
+ hit_values = (0,)
592
+ inclusion_mode = False
593
+ tree_k = kwargs.get("tree_k", 0.6)
594
+ tree_lad = kwargs.get("tree_lad", 1.0)
595
+
596
+ boundary_mask = None
597
+ instant_kwargs = kwargs.copy()
598
+ instant_kwargs['obj_export'] = False
599
+
600
+ total_steps = times_len
601
+ progress_every = max(1, total_steps // 20)
602
+
603
+ face_centers64 = (face_centers if isinstance(face_centers, _np.ndarray) else building_svf_mesh.triangles_center).astype(_np.float64)
604
+ face_normals64 = (face_normals if isinstance(face_normals, _np.ndarray) else building_svf_mesh.face_normals).astype(_np.float64)
605
+ face_svf64 = face_svf.astype(_np.float64)
606
+ x_min, y_min, z_min = grid_bounds_real[0, 0], grid_bounds_real[0, 1], grid_bounds_real[0, 2]
607
+ x_max, y_max, z_max = grid_bounds_real[1, 0], grid_bounds_real[1, 1], grid_bounds_real[1, 2]
608
+
609
+ # =========================================================================
610
+ # Sky Patch Optimization Path for Building Faces
611
+ # =========================================================================
612
+ if use_sky_patches:
613
+ # Aggregate weather data into sky patches
614
+ # Filter kwargs to avoid duplicate parameters
615
+ sky_kwargs = {k: v for k, v in kwargs.items()
616
+ if k not in ('sky_discretization', 'time_step_hours')}
617
+ patch_data = _aggregate_weather_to_sky_patches(
618
+ azimuth_deg_arr, elev_deg_arr, DNI_arr, DHI_arr,
619
+ time_step_hours=time_step_hours,
620
+ sky_discretization=sky_discretization,
621
+ **sky_kwargs
622
+ )
623
+
624
+ if progress_report:
625
+ print(f"Sky patch optimization: {patch_data['n_original_timesteps']} timesteps → "
626
+ f"{patch_data['n_active_patches']} active patches ({patch_data['method']})")
627
+ print(f" Faces: {n_faces:,}, Total cumulative DHI: {patch_data['total_cumulative_dhi']:.1f} Wh/m²")
628
+
629
+ # Diffuse component: SVF × total cumulative DHI
630
+ # (DHI is sky-hemisphere based, so sum over all timesteps)
631
+ face_cum_diffuse = face_svf64 * patch_data['total_cumulative_dhi']
632
+
633
+ # Direct component: loop over active patches
634
+ active_indices = _np.where(patch_data['active_mask'])[0]
635
+ patches = patch_data['patches']
636
+ patch_cumulative_dni = patch_data['patch_cumulative_dni']
637
+
638
+ # Prepare masks for fast path
639
+ precomputed_masks = kwargs.get("precomputed_masks", None)
640
+ if precomputed_masks is not None:
641
+ vox_is_tree = precomputed_masks.get("vox_is_tree", (voxel_data == -2))
642
+ vox_is_opaque = precomputed_masks.get("vox_is_opaque", (voxel_data != 0) & (voxel_data != -2))
643
+ att = float(precomputed_masks.get("att", _np.exp(-tree_k * tree_lad * meshsize)))
644
+ else:
645
+ vox_is_tree = (voxel_data == -2)
646
+ vox_is_opaque = (voxel_data != 0) & (~vox_is_tree)
647
+ att = float(_np.exp(-tree_k * tree_lad * meshsize))
648
+
649
+ from .radiation import compute_solar_irradiance_for_all_faces_masked
650
+
651
+ for i, patch_idx in enumerate(active_indices):
652
+ az_deg = float(patches[patch_idx, 0])
653
+ el_deg = float(patches[patch_idx, 1])
654
+ cumulative_dni_patch = float(patch_cumulative_dni[patch_idx])
655
+
656
+ # Convert patch direction to sun vector
657
+ az_rad = _np.deg2rad(180.0 - az_deg)
658
+ el_rad = _np.deg2rad(el_deg)
659
+ sun_dx = _np.cos(el_rad) * _np.cos(az_rad)
660
+ sun_dy = _np.cos(el_rad) * _np.sin(az_rad)
661
+ sun_dz = _np.sin(el_rad)
662
+ sun_direction = _np.array([sun_dx, sun_dy, sun_dz], dtype=_np.float64)
663
+
664
+ # Compute direct irradiance for this patch direction (using DNI=1 for transmittance)
665
+ patch_direct, _, _ = compute_solar_irradiance_for_all_faces_masked(
666
+ face_centers64,
667
+ face_normals64,
668
+ face_svf64,
669
+ sun_direction,
670
+ 1.0, # DNI = 1 to get cos(incidence) × transmittance
671
+ 0.0, # No diffuse here
672
+ vox_is_tree,
673
+ vox_is_opaque,
674
+ float(meshsize),
675
+ att,
676
+ float(x_min), float(y_min), float(z_min),
677
+ float(x_max), float(y_max), float(z_max),
678
+ float(boundary_epsilon)
679
+ )
680
+
681
+ # Accumulate: transmittance factor × cumulative DNI for this patch
682
+ face_cum_direct += _np.nan_to_num(patch_direct, nan=0.0) * cumulative_dni_patch
683
+
684
+ if progress_report and ((i + 1) % max(1, len(active_indices) // 10) == 0 or i == len(active_indices) - 1):
685
+ pct = (i + 1) * 100.0 / len(active_indices)
686
+ print(f" Patch {i+1}/{len(active_indices)} ({pct:.1f}%)")
687
+
688
+ # Combine direct and diffuse
689
+ face_cum_global = face_cum_direct + face_cum_diffuse
690
+
691
+ # Apply boundary mask from SVF
692
+ boundary_mask = _np.isnan(face_svf64)
693
+
694
+ # =========================================================================
695
+ # Original Fast Path (Per-Timestep with Batching)
696
+ # =========================================================================
697
+ elif fast_path:
698
+ precomputed_masks = kwargs.get("precomputed_masks", None)
699
+ if precomputed_masks is not None:
700
+ vox_is_tree = precomputed_masks.get("vox_is_tree", (voxel_data == -2))
701
+ vox_is_opaque = precomputed_masks.get("vox_is_opaque", (voxel_data != 0) & (voxel_data != -2))
702
+ att = float(precomputed_masks.get("att", _np.exp(-tree_k * tree_lad * meshsize)))
703
+ else:
704
+ vox_is_tree = (voxel_data == -2)
705
+ vox_is_opaque = (voxel_data != 0) & (~vox_is_tree)
706
+ att = float(_np.exp(-tree_k * tree_lad * meshsize))
707
+
708
+ time_batch_size = _auto_time_batch_size(n_faces, total_steps, kwargs.get("time_batch_size", None))
709
+ if progress_report:
710
+ print(f"Faces: {n_faces:,}, Timesteps: {total_steps:,}, Batch size: {time_batch_size}")
711
+
712
+ for start in range(0, total_steps, time_batch_size):
713
+ end = min(start + time_batch_size, total_steps)
714
+ ch_dir, ch_diff, ch_glob = compute_cumulative_solar_irradiance_faces_masked_timeseries(
715
+ face_centers64,
716
+ face_normals64,
717
+ face_svf64,
718
+ sun_dirs_arr.astype(_np.float64),
719
+ DNI_arr.astype(_np.float64),
720
+ DHI_arr.astype(_np.float64),
721
+ vox_is_tree,
722
+ vox_is_opaque,
723
+ float(meshsize),
724
+ float(att),
725
+ float(x_min), float(y_min), float(z_min),
726
+ float(x_max), float(y_max), float(z_max),
727
+ float(boundary_epsilon),
728
+ int(start), int(end),
729
+ float(time_step_hours)
730
+ )
731
+ face_cum_direct += ch_dir
732
+ face_cum_diffuse += ch_diff
733
+ face_cum_global += ch_glob
734
+ if progress_report:
735
+ pct = (end * 100.0) / total_steps
736
+ print(f"Cumulative irradiance: {end}/{total_steps} ({pct:.1f}%)")
737
+ else:
738
+ for idx in range(total_steps):
739
+ DNI = float(DNI_arr[idx])
740
+ DHI = float(DHI_arr[idx])
741
+ if not sun_above_mask[idx]:
742
+ if boundary_mask is None:
743
+ boundary_mask = _np.isnan(face_svf)
744
+ face_cum_diffuse += _np.nan_to_num(face_svf * DHI) * time_step_hours
745
+ face_cum_global += _np.nan_to_num(face_svf * DHI) * time_step_hours
746
+ if progress_report and (((idx + 1) % progress_every == 0) or (idx == total_steps - 1)):
747
+ pct = (idx + 1) * 100.0 / total_steps
748
+ print(f"Cumulative irradiance: {idx+1}/{total_steps} ({pct:.1f}%)")
749
+ continue
750
+
751
+ irr_mesh = get_building_solar_irradiance(
752
+ voxcity,
753
+ building_svf_mesh,
754
+ float(azimuth_deg_arr[idx]),
755
+ float(elev_deg_arr[idx]),
756
+ DNI,
757
+ DHI,
758
+ show_plot=False,
759
+ **instant_kwargs
760
+ )
761
+ face_direct = irr_mesh.metadata['direct']
762
+ face_diffuse = irr_mesh.metadata['diffuse']
763
+ face_global = irr_mesh.metadata['global']
764
+
765
+ if boundary_mask is None:
766
+ boundary_mask = _np.isnan(face_global)
767
+
768
+ face_cum_direct += _np.nan_to_num(face_direct) * time_step_hours
769
+ face_cum_diffuse += _np.nan_to_num(face_diffuse) * time_step_hours
770
+ face_cum_global += _np.nan_to_num(face_global) * time_step_hours
771
+
772
+ if progress_report and (((idx + 1) % progress_every == 0) or (idx == total_steps - 1)):
773
+ pct = (idx + 1) * 100.0 / total_steps
774
+ print(f"Cumulative irradiance: {idx+1}/{total_steps} ({pct:.1f}%)")
775
+
776
+ if boundary_mask is not None:
777
+ face_cum_direct[boundary_mask] = _np.nan
778
+ face_cum_diffuse[boundary_mask] = _np.nan
779
+ face_cum_global[boundary_mask] = _np.nan
780
+
781
+ cumulative_mesh = building_svf_mesh.copy()
782
+ if not hasattr(cumulative_mesh, 'metadata'):
783
+ cumulative_mesh.metadata = {}
784
+ if 'svf' in building_svf_mesh.metadata:
785
+ cumulative_mesh.metadata['svf'] = building_svf_mesh.metadata['svf']
786
+ cumulative_mesh.metadata['direct'] = face_cum_direct
787
+ cumulative_mesh.metadata['diffuse'] = face_cum_diffuse
788
+ cumulative_mesh.metadata['global'] = face_cum_global
789
+ cumulative_mesh.name = "Cumulative Solar Irradiance (Wh/m²)"
790
+ return cumulative_mesh
791
+
792
+