voxcity 0.7.0__py3-none-any.whl → 1.0.2__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.
- voxcity/__init__.py +14 -14
- voxcity/exporter/__init__.py +12 -12
- voxcity/exporter/cityles.py +633 -633
- voxcity/exporter/envimet.py +733 -728
- voxcity/exporter/magicavoxel.py +333 -333
- voxcity/exporter/netcdf.py +238 -238
- voxcity/exporter/obj.py +1480 -1480
- voxcity/generator/__init__.py +47 -44
- voxcity/generator/api.py +721 -675
- voxcity/generator/grids.py +381 -379
- voxcity/generator/io.py +94 -94
- voxcity/generator/pipeline.py +282 -282
- voxcity/generator/update.py +429 -0
- voxcity/generator/voxelizer.py +18 -6
- voxcity/geoprocessor/__init__.py +75 -75
- voxcity/geoprocessor/draw.py +1488 -1219
- voxcity/geoprocessor/merge_utils.py +91 -91
- voxcity/geoprocessor/mesh.py +806 -806
- voxcity/geoprocessor/network.py +708 -708
- voxcity/geoprocessor/raster/buildings.py +435 -428
- voxcity/geoprocessor/raster/export.py +93 -93
- voxcity/geoprocessor/raster/landcover.py +5 -2
- voxcity/geoprocessor/utils.py +824 -824
- voxcity/models.py +113 -113
- voxcity/simulator/solar/__init__.py +66 -43
- voxcity/simulator/solar/integration.py +336 -336
- voxcity/simulator/solar/sky.py +668 -0
- voxcity/simulator/solar/temporal.py +792 -434
- voxcity/utils/__init__.py +11 -0
- voxcity/utils/classes.py +194 -0
- voxcity/utils/lc.py +80 -39
- voxcity/utils/shape.py +230 -0
- voxcity/visualizer/__init__.py +24 -24
- voxcity/visualizer/builder.py +43 -43
- voxcity/visualizer/grids.py +141 -141
- voxcity/visualizer/maps.py +187 -187
- voxcity/visualizer/renderer.py +1145 -928
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/METADATA +90 -49
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/RECORD +42 -38
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
+
|