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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. voxcity/__init__.py +14 -8
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/gba.py +210 -0
  4. voxcity/downloader/gee.py +5 -1
  5. voxcity/downloader/mbfp.py +1 -1
  6. voxcity/downloader/oemj.py +80 -8
  7. voxcity/downloader/utils.py +73 -73
  8. voxcity/errors.py +30 -0
  9. voxcity/exporter/__init__.py +13 -5
  10. voxcity/exporter/cityles.py +633 -538
  11. voxcity/exporter/envimet.py +728 -708
  12. voxcity/exporter/magicavoxel.py +334 -297
  13. voxcity/exporter/netcdf.py +238 -211
  14. voxcity/exporter/obj.py +1481 -1406
  15. voxcity/generator/__init__.py +44 -0
  16. voxcity/generator/api.py +675 -0
  17. voxcity/generator/grids.py +379 -0
  18. voxcity/generator/io.py +94 -0
  19. voxcity/generator/pipeline.py +282 -0
  20. voxcity/generator/voxelizer.py +380 -0
  21. voxcity/geoprocessor/__init__.py +75 -6
  22. voxcity/geoprocessor/conversion.py +153 -0
  23. voxcity/geoprocessor/draw.py +62 -12
  24. voxcity/geoprocessor/heights.py +199 -0
  25. voxcity/geoprocessor/io.py +101 -0
  26. voxcity/geoprocessor/merge_utils.py +91 -0
  27. voxcity/geoprocessor/mesh.py +806 -790
  28. voxcity/geoprocessor/network.py +708 -679
  29. voxcity/geoprocessor/overlap.py +84 -0
  30. voxcity/geoprocessor/raster/__init__.py +82 -0
  31. voxcity/geoprocessor/raster/buildings.py +428 -0
  32. voxcity/geoprocessor/raster/canopy.py +258 -0
  33. voxcity/geoprocessor/raster/core.py +150 -0
  34. voxcity/geoprocessor/raster/export.py +93 -0
  35. voxcity/geoprocessor/raster/landcover.py +156 -0
  36. voxcity/geoprocessor/raster/raster.py +110 -0
  37. voxcity/geoprocessor/selection.py +85 -0
  38. voxcity/geoprocessor/utils.py +18 -14
  39. voxcity/models.py +113 -0
  40. voxcity/simulator/common/__init__.py +22 -0
  41. voxcity/simulator/common/geometry.py +98 -0
  42. voxcity/simulator/common/raytracing.py +450 -0
  43. voxcity/simulator/solar/__init__.py +43 -0
  44. voxcity/simulator/solar/integration.py +336 -0
  45. voxcity/simulator/solar/kernels.py +62 -0
  46. voxcity/simulator/solar/radiation.py +648 -0
  47. voxcity/simulator/solar/temporal.py +434 -0
  48. voxcity/simulator/view.py +36 -2286
  49. voxcity/simulator/visibility/__init__.py +29 -0
  50. voxcity/simulator/visibility/landmark.py +392 -0
  51. voxcity/simulator/visibility/view.py +508 -0
  52. voxcity/utils/logging.py +61 -0
  53. voxcity/utils/orientation.py +51 -0
  54. voxcity/utils/weather/__init__.py +26 -0
  55. voxcity/utils/weather/epw.py +146 -0
  56. voxcity/utils/weather/files.py +36 -0
  57. voxcity/utils/weather/onebuilding.py +486 -0
  58. voxcity/visualizer/__init__.py +24 -0
  59. voxcity/visualizer/builder.py +43 -0
  60. voxcity/visualizer/grids.py +141 -0
  61. voxcity/visualizer/maps.py +187 -0
  62. voxcity/visualizer/palette.py +228 -0
  63. voxcity/visualizer/renderer.py +928 -0
  64. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/METADATA +107 -34
  65. voxcity-0.7.0.dist-info/RECORD +77 -0
  66. voxcity/generator.py +0 -1302
  67. voxcity/geoprocessor/grid.py +0 -1739
  68. voxcity/geoprocessor/polygon.py +0 -1344
  69. voxcity/simulator/solar.py +0 -2339
  70. voxcity/utils/visualization.py +0 -2849
  71. voxcity/utils/weather.py +0 -1038
  72. voxcity-0.6.26.dist-info/RECORD +0 -38
  73. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/WHEEL +0 -0
  74. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/licenses/AUTHORS.rst +0 -0
  75. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,648 @@
1
+ """
2
+ Stage 2: Physics - convert geometry to irradiance.
3
+ """
4
+
5
+ import numpy as np
6
+ import matplotlib.pyplot as plt
7
+ from numba import njit, prange
8
+
9
+ from ...models import VoxCity
10
+ from ...exporter.obj import grid_to_obj
11
+ from ..visibility import get_sky_view_factor_map
12
+ from ..common.raytracing import trace_ray_generic
13
+ from .kernels import compute_direct_solar_irradiance_map_binary
14
+
15
+
16
+ def get_direct_solar_irradiance_map(
17
+ voxcity: VoxCity,
18
+ azimuth_degrees_ori,
19
+ elevation_degrees,
20
+ direct_normal_irradiance,
21
+ show_plot=False,
22
+ **kwargs,
23
+ ):
24
+ """
25
+ Compute horizontal direct irradiance map (W/m²) with tree transmittance.
26
+ """
27
+ voxel_data = voxcity.voxels.classes
28
+ meshsize = voxcity.voxels.meta.meshsize
29
+
30
+ view_point_height = kwargs.get("view_point_height", 1.5)
31
+ colormap = kwargs.get("colormap", "magma")
32
+ vmin = kwargs.get("vmin", 0.0)
33
+ vmax = kwargs.get("vmax", direct_normal_irradiance)
34
+ tree_k = kwargs.get("tree_k", 0.6)
35
+ tree_lad = kwargs.get("tree_lad", 1.0)
36
+
37
+ azimuth_degrees = 180 - azimuth_degrees_ori
38
+ azimuth_radians = np.deg2rad(azimuth_degrees)
39
+ elevation_radians = np.deg2rad(elevation_degrees)
40
+ dx = np.cos(elevation_radians) * np.cos(azimuth_radians)
41
+ dy = np.cos(elevation_radians) * np.sin(azimuth_radians)
42
+ dz = np.sin(elevation_radians)
43
+ sun_direction = (dx, dy, dz)
44
+
45
+ hit_values = (0,)
46
+ inclusion_mode = False
47
+ transmittance_map = compute_direct_solar_irradiance_map_binary(
48
+ voxel_data,
49
+ sun_direction,
50
+ view_point_height,
51
+ hit_values,
52
+ meshsize,
53
+ tree_k,
54
+ tree_lad,
55
+ inclusion_mode,
56
+ )
57
+
58
+ sin_elev = dz
59
+ direct_map = transmittance_map * direct_normal_irradiance * sin_elev
60
+
61
+ if show_plot:
62
+ cmap = plt.cm.get_cmap(colormap).copy()
63
+ cmap.set_bad(color="lightgray")
64
+ plt.figure(figsize=(10, 8))
65
+ plt.imshow(direct_map, origin="lower", cmap=cmap, vmin=vmin, vmax=vmax)
66
+ plt.colorbar(label="Direct Solar Irradiance (W/m²)")
67
+ plt.axis("off")
68
+ plt.show()
69
+
70
+ if kwargs.get("obj_export", False):
71
+ dem_grid = kwargs.get("dem_grid", voxcity.dem.elevation if voxcity.dem else np.zeros_like(direct_map))
72
+ output_dir = kwargs.get("output_directory", "output")
73
+ output_file_name = kwargs.get("output_file_name", "direct_solar_irradiance")
74
+ num_colors = kwargs.get("num_colors", 10)
75
+ alpha = kwargs.get("alpha", 1.0)
76
+ grid_to_obj(
77
+ direct_map,
78
+ dem_grid,
79
+ output_dir,
80
+ output_file_name,
81
+ meshsize,
82
+ view_point_height,
83
+ colormap_name=colormap,
84
+ num_colors=num_colors,
85
+ alpha=alpha,
86
+ vmin=vmin,
87
+ vmax=vmax,
88
+ )
89
+
90
+ return direct_map
91
+
92
+
93
+ def get_diffuse_solar_irradiance_map(
94
+ voxcity: VoxCity,
95
+ diffuse_irradiance=1.0,
96
+ show_plot=False,
97
+ **kwargs,
98
+ ):
99
+ """
100
+ Compute diffuse horizontal irradiance map (W/m²) using SVF.
101
+ """
102
+ meshsize = voxcity.voxels.meta.meshsize
103
+ view_point_height = kwargs.get("view_point_height", 1.5)
104
+ colormap = kwargs.get("colormap", "magma")
105
+ vmin = kwargs.get("vmin", 0.0)
106
+ vmax = kwargs.get("vmax", diffuse_irradiance)
107
+
108
+ svf_kwargs = kwargs.copy()
109
+ svf_kwargs["colormap"] = "BuPu_r"
110
+ svf_kwargs["vmin"] = 0
111
+ svf_kwargs["vmax"] = 1
112
+
113
+ SVF_map = get_sky_view_factor_map(voxcity, **svf_kwargs)
114
+ diffuse_map = SVF_map * diffuse_irradiance
115
+
116
+ if show_plot:
117
+ cmap = plt.cm.get_cmap(colormap).copy()
118
+ cmap.set_bad(color="lightgray")
119
+ plt.figure(figsize=(10, 8))
120
+ plt.imshow(diffuse_map, origin="lower", cmap=cmap, vmin=vmin, vmax=vmax)
121
+ plt.colorbar(label="Diffuse Solar Irradiance (W/m²)")
122
+ plt.axis("off")
123
+ plt.show()
124
+
125
+ if kwargs.get("obj_export", False):
126
+ dem_grid = kwargs.get("dem_grid", voxcity.dem.elevation if voxcity.dem else np.zeros_like(diffuse_map))
127
+ output_dir = kwargs.get("output_directory", "output")
128
+ output_file_name = kwargs.get("output_file_name", "diffuse_solar_irradiance")
129
+ num_colors = kwargs.get("num_colors", 10)
130
+ alpha = kwargs.get("alpha", 1.0)
131
+ grid_to_obj(
132
+ diffuse_map,
133
+ dem_grid,
134
+ output_dir,
135
+ output_file_name,
136
+ meshsize,
137
+ view_point_height,
138
+ colormap_name=colormap,
139
+ num_colors=num_colors,
140
+ alpha=alpha,
141
+ vmin=vmin,
142
+ vmax=vmax,
143
+ )
144
+
145
+ return diffuse_map
146
+
147
+
148
+ def get_global_solar_irradiance_map(
149
+ voxcity: VoxCity,
150
+ azimuth_degrees_ori,
151
+ elevation_degrees,
152
+ direct_normal_irradiance,
153
+ diffuse_irradiance,
154
+ show_plot=False,
155
+ **kwargs,
156
+ ):
157
+ """
158
+ Combine direct and diffuse horizontal irradiance (W/m²).
159
+ """
160
+ direct_map = get_direct_solar_irradiance_map(
161
+ voxcity,
162
+ azimuth_degrees_ori,
163
+ elevation_degrees,
164
+ direct_normal_irradiance,
165
+ show_plot=False,
166
+ **kwargs,
167
+ )
168
+ diffuse_map = get_diffuse_solar_irradiance_map(
169
+ voxcity,
170
+ diffuse_irradiance=diffuse_irradiance,
171
+ show_plot=False,
172
+ **kwargs,
173
+ )
174
+ global_map = np.where(np.isnan(direct_map), diffuse_map, direct_map + diffuse_map)
175
+
176
+ if show_plot:
177
+ colormap = kwargs.get("colormap", "magma")
178
+ vmin = kwargs.get("vmin", 0.0)
179
+ vmax = kwargs.get("vmax", max(float(np.nanmax(global_map)), 1.0))
180
+ cmap = plt.cm.get_cmap(colormap).copy()
181
+ cmap.set_bad(color="lightgray")
182
+ plt.figure(figsize=(10, 8))
183
+ plt.imshow(global_map, origin="lower", cmap=cmap, vmin=vmin, vmax=vmax)
184
+ plt.colorbar(label="Global Solar Irradiance (W/m²)")
185
+ plt.axis("off")
186
+ plt.show()
187
+
188
+ if kwargs.get("obj_export", False):
189
+ meshsize = voxcity.voxels.meta.meshsize
190
+ view_point_height = kwargs.get("view_point_height", 1.5)
191
+ dem_grid = kwargs.get("dem_grid", voxcity.dem.elevation if voxcity.dem else np.zeros_like(global_map))
192
+ output_dir = kwargs.get("output_directory", "output")
193
+ output_file_name = kwargs.get("output_file_name", "global_solar_irradiance")
194
+ num_colors = kwargs.get("num_colors", 10)
195
+ alpha = kwargs.get("alpha", 1.0)
196
+ grid_to_obj(
197
+ global_map,
198
+ dem_grid,
199
+ output_dir,
200
+ output_file_name,
201
+ meshsize,
202
+ view_point_height,
203
+ colormap_name=colormap,
204
+ num_colors=num_colors,
205
+ alpha=alpha,
206
+ vmin=kwargs.get("vmin", 0.0),
207
+ vmax=kwargs.get("vmax", vmax if "vmax" in kwargs else None),
208
+ )
209
+
210
+ return global_map
211
+
212
+
213
+ # --------------------------
214
+ # Building-surface irradiance
215
+ # --------------------------
216
+
217
+ @njit(parallel=True)
218
+ def compute_solar_irradiance_for_all_faces(
219
+ face_centers,
220
+ face_normals,
221
+ face_svf,
222
+ sun_direction,
223
+ direct_normal_irradiance,
224
+ diffuse_irradiance,
225
+ voxel_data,
226
+ meshsize,
227
+ tree_k,
228
+ tree_lad,
229
+ hit_values,
230
+ inclusion_mode,
231
+ grid_bounds_real,
232
+ boundary_epsilon
233
+ ):
234
+ """
235
+ Numba kernel: compute per-face direct/diffuse/global (W/m²) using generic ray tracer.
236
+ """
237
+ n_faces = face_centers.shape[0]
238
+ face_direct = np.zeros(n_faces, dtype=np.float64)
239
+ face_diffuse = np.zeros(n_faces, dtype=np.float64)
240
+ face_global = np.zeros(n_faces, dtype=np.float64)
241
+
242
+ x_min, y_min, z_min = grid_bounds_real[0, 0], grid_bounds_real[0, 1], grid_bounds_real[0, 2]
243
+ x_max, y_max, z_max = grid_bounds_real[1, 0], grid_bounds_real[1, 1], grid_bounds_real[1, 2]
244
+
245
+ for fidx in prange(n_faces):
246
+ center = face_centers[fidx]
247
+ normal = face_normals[fidx]
248
+ svf = face_svf[fidx]
249
+
250
+ # Exclude vertical boundary faces
251
+ is_vertical = (abs(normal[2]) < 0.01)
252
+ on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
253
+ on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
254
+ on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
255
+ on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
256
+ if is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max):
257
+ face_direct[fidx] = np.nan
258
+ face_diffuse[fidx] = np.nan
259
+ face_global[fidx] = np.nan
260
+ continue
261
+
262
+ if svf != svf:
263
+ face_direct[fidx] = np.nan
264
+ face_diffuse[fidx] = np.nan
265
+ face_global[fidx] = np.nan
266
+ continue
267
+
268
+ # Direct term
269
+ cos_incidence = normal[0]*sun_direction[0] + normal[1]*sun_direction[1] + normal[2]*sun_direction[2]
270
+ direct_val = 0.0
271
+ if cos_incidence > 0.0 and direct_normal_irradiance > 0.0:
272
+ offset_vox = 0.1
273
+ ox = center[0]/meshsize + normal[0]*offset_vox
274
+ oy = center[1]/meshsize + normal[1]*offset_vox
275
+ oz = center[2]/meshsize + normal[2]*offset_vox
276
+ hit_detected, transmittance = trace_ray_generic(
277
+ voxel_data,
278
+ np.array([ox, oy, oz], dtype=np.float64),
279
+ sun_direction,
280
+ hit_values,
281
+ meshsize,
282
+ tree_k,
283
+ tree_lad,
284
+ inclusion_mode
285
+ )
286
+ if not hit_detected:
287
+ direct_val = direct_normal_irradiance * cos_incidence * transmittance
288
+
289
+ # Diffuse via SVF
290
+ diffuse_val = svf * diffuse_irradiance
291
+ if diffuse_val > diffuse_irradiance:
292
+ diffuse_val = diffuse_irradiance
293
+
294
+ face_direct[fidx] = direct_val
295
+ face_diffuse[fidx] = diffuse_val
296
+ face_global[fidx] = direct_val + diffuse_val
297
+
298
+ return face_direct, face_diffuse, face_global
299
+
300
+
301
+ @njit(cache=True, fastmath=True, nogil=True)
302
+ def _trace_direct_masked(vox_is_tree, vox_is_opaque, origin, direction, att, att_cutoff=0.01):
303
+ nx, ny, nz = vox_is_opaque.shape
304
+ x0 = origin[0]; y0 = origin[1]; z0 = origin[2]
305
+ dx = direction[0]; dy = direction[1]; dz = direction[2]
306
+
307
+ # Normalize
308
+ L = (dx*dx + dy*dy + dz*dz) ** 0.5
309
+ if L == 0.0:
310
+ return False, 1.0
311
+ invL = 1.0 / L
312
+ dx *= invL; dy *= invL; dz *= invL
313
+
314
+ # Start at voxel centers
315
+ x = x0 + 0.5; y = y0 + 0.5; z = z0 + 0.5
316
+ i = int(x0); j = int(y0); k = int(z0)
317
+
318
+ step_x = 1 if dx >= 0.0 else -1
319
+ step_y = 1 if dy >= 0.0 else -1
320
+ step_z = 1 if dz >= 0.0 else -1
321
+
322
+ BIG = 1e30
323
+ if dx != 0.0:
324
+ t_max_x = (((i + (1 if step_x > 0 else 0)) - x) / dx)
325
+ t_delta_x = abs(1.0 / dx)
326
+ else:
327
+ t_max_x = BIG; t_delta_x = BIG
328
+ if dy != 0.0:
329
+ t_max_y = (((j + (1 if step_y > 0 else 0)) - y) / dy)
330
+ t_delta_y = abs(1.0 / dy)
331
+ else:
332
+ t_max_y = BIG; t_delta_y = BIG
333
+ if dz != 0.0:
334
+ t_max_z = (((k + (1 if step_z > 0 else 0)) - z) / dz)
335
+ t_delta_z = abs(1.0 / dz)
336
+ else:
337
+ t_max_z = BIG; t_delta_z = BIG
338
+
339
+ T = 1.0
340
+ while True:
341
+ if (i < 0) or (i >= nx) or (j < 0) or (j >= ny) or (k < 0) or (k >= nz):
342
+ return False, T
343
+
344
+ if vox_is_opaque[i, j, k]:
345
+ return True, T
346
+
347
+ if vox_is_tree[i, j, k]:
348
+ T *= att
349
+ if T < att_cutoff:
350
+ return True, T
351
+
352
+ if t_max_x < t_max_y:
353
+ if t_max_x < t_max_z:
354
+ t_max_x += t_delta_x; i += step_x
355
+ else:
356
+ t_max_z += t_delta_z; k += step_z
357
+ else:
358
+ if t_max_y < t_max_z:
359
+ t_max_y += t_delta_y; j += step_y
360
+ else:
361
+ t_max_z += t_delta_z; k += step_z
362
+
363
+
364
+ @njit(parallel=True, cache=True, fastmath=True, nogil=True)
365
+ def compute_solar_irradiance_for_all_faces_masked(
366
+ face_centers,
367
+ face_normals,
368
+ face_svf,
369
+ sun_direction,
370
+ direct_normal_irradiance,
371
+ diffuse_irradiance,
372
+ vox_is_tree,
373
+ vox_is_opaque,
374
+ meshsize,
375
+ att,
376
+ x_min, y_min, z_min,
377
+ x_max, y_max, z_max,
378
+ boundary_epsilon
379
+ ):
380
+ n_faces = face_centers.shape[0]
381
+ face_direct = np.zeros(n_faces, dtype=np.float64)
382
+ face_diffuse = np.zeros(n_faces, dtype=np.float64)
383
+ face_global = np.zeros(n_faces, dtype=np.float64)
384
+
385
+ for fidx in prange(n_faces):
386
+ center = face_centers[fidx]
387
+ normal = face_normals[fidx]
388
+ svf = face_svf[fidx]
389
+
390
+ # Boundary vertical exclusion
391
+ is_vertical = (abs(normal[2]) < 0.01)
392
+ on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
393
+ on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
394
+ on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
395
+ on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
396
+ if is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max):
397
+ face_direct[fidx] = np.nan
398
+ face_diffuse[fidx] = np.nan
399
+ face_global[fidx] = np.nan
400
+ continue
401
+
402
+ if svf != svf:
403
+ face_direct[fidx] = np.nan
404
+ face_diffuse[fidx] = np.nan
405
+ face_global[fidx] = np.nan
406
+ continue
407
+
408
+ # Direct component
409
+ cos_incidence = normal[0]*sun_direction[0] + normal[1]*sun_direction[1] + normal[2]*sun_direction[2]
410
+ direct_val = 0.0
411
+ if cos_incidence > 0.0 and direct_normal_irradiance > 0.0:
412
+ offset_vox = 0.1
413
+ ox = center[0]/meshsize + normal[0]*offset_vox
414
+ oy = center[1]/meshsize + normal[1]*offset_vox
415
+ oz = center[2]/meshsize + normal[2]*offset_vox
416
+ blocked, T = _trace_direct_masked(
417
+ vox_is_tree,
418
+ vox_is_opaque,
419
+ np.array((ox, oy, oz), dtype=np.float64),
420
+ sun_direction,
421
+ att
422
+ )
423
+ if not blocked:
424
+ direct_val = direct_normal_irradiance * cos_incidence * T
425
+
426
+ # Diffuse component
427
+ diffuse_val = svf * diffuse_irradiance
428
+ if diffuse_val > diffuse_irradiance:
429
+ diffuse_val = diffuse_irradiance
430
+
431
+ face_direct[fidx] = direct_val
432
+ face_diffuse[fidx] = diffuse_val
433
+ face_global[fidx] = direct_val + diffuse_val
434
+
435
+ return face_direct, face_diffuse, face_global
436
+
437
+
438
+ @njit(parallel=True, cache=True, fastmath=True, nogil=True)
439
+ def compute_cumulative_solar_irradiance_faces_masked_timeseries(
440
+ face_centers,
441
+ face_normals,
442
+ face_svf,
443
+ sun_dirs_arr, # shape (T, 3)
444
+ DNI_arr, # shape (T,)
445
+ DHI_arr, # shape (T,)
446
+ vox_is_tree,
447
+ vox_is_opaque,
448
+ meshsize,
449
+ att,
450
+ x_min, y_min, z_min,
451
+ x_max, y_max, z_max,
452
+ boundary_epsilon,
453
+ t_start, t_end, # [start, end) indices
454
+ time_step_hours
455
+ ):
456
+ n_faces = face_centers.shape[0]
457
+ out_dir = np.zeros(n_faces, dtype=np.float64)
458
+ out_diff = np.zeros(n_faces, dtype=np.float64)
459
+ out_glob = np.zeros(n_faces, dtype=np.float64)
460
+
461
+ for fidx in prange(n_faces):
462
+ center = face_centers[fidx]
463
+ normal = face_normals[fidx]
464
+ svf = face_svf[fidx]
465
+
466
+ # Boundary vertical exclusion
467
+ is_vertical = (abs(normal[2]) < 0.01)
468
+ on_x_min = (abs(center[0] - x_min) < boundary_epsilon)
469
+ on_y_min = (abs(center[1] - y_min) < boundary_epsilon)
470
+ on_x_max = (abs(center[0] - x_max) < boundary_epsilon)
471
+ on_y_max = (abs(center[1] - y_max) < boundary_epsilon)
472
+ if is_vertical and (on_x_min or on_y_min or on_x_max or on_y_max):
473
+ out_dir[fidx] = np.nan
474
+ out_diff[fidx] = np.nan
475
+ out_glob[fidx] = np.nan
476
+ continue
477
+
478
+ if svf != svf:
479
+ out_dir[fidx] = np.nan
480
+ out_diff[fidx] = np.nan
481
+ out_glob[fidx] = np.nan
482
+ continue
483
+
484
+ accum_dir = 0.0
485
+ accum_diff = 0.0
486
+ accum_glob = 0.0
487
+
488
+ # Precompute ray origin (voxel coords) once per face
489
+ offset_vox = 0.1
490
+ ox = center[0]/meshsize + normal[0]*offset_vox
491
+ oy = center[1]/meshsize + normal[1]*offset_vox
492
+ oz = center[2]/meshsize + normal[2]*offset_vox
493
+ origin = np.array((ox, oy, oz), dtype=np.float64)
494
+
495
+ for t in range(t_start, t_end):
496
+ dni = DNI_arr[t]
497
+ dhi = DHI_arr[t]
498
+ sd0 = sun_dirs_arr[t, 0]
499
+ sd1 = sun_dirs_arr[t, 1]
500
+ sd2 = sun_dirs_arr[t, 2]
501
+ # Below horizon -> diffuse only
502
+ if sd2 <= 0.0:
503
+ diff_val = svf * dhi
504
+ if diff_val > dhi:
505
+ diff_val = dhi
506
+ accum_diff += diff_val * time_step_hours
507
+ accum_glob += diff_val * time_step_hours
508
+ continue
509
+
510
+ # Direct
511
+ cos_inc = normal[0]*sd0 + normal[1]*sd1 + normal[2]*sd2
512
+ direct_val = 0.0
513
+ if (dni > 0.0) and (cos_inc > 0.0):
514
+ blocked, T = _trace_direct_masked(
515
+ vox_is_tree,
516
+ vox_is_opaque,
517
+ origin,
518
+ np.array((sd0, sd1, sd2), dtype=np.float64),
519
+ att
520
+ )
521
+ if not blocked:
522
+ direct_val = dni * cos_inc * T
523
+
524
+ diff_val = svf * dhi
525
+ if diff_val > dhi:
526
+ diff_val = dhi
527
+
528
+ accum_dir += direct_val * time_step_hours
529
+ accum_diff += diff_val * time_step_hours
530
+ accum_glob += (direct_val + diff_val) * time_step_hours
531
+
532
+ out_dir[fidx] = accum_dir
533
+ out_diff[fidx] = accum_diff
534
+ out_glob[fidx] = accum_glob
535
+
536
+ return out_dir, out_diff, out_glob
537
+
538
+
539
+ def get_building_solar_irradiance(
540
+ voxcity: VoxCity,
541
+ building_svf_mesh,
542
+ azimuth_degrees,
543
+ elevation_degrees,
544
+ direct_normal_irradiance,
545
+ diffuse_irradiance,
546
+ **kwargs
547
+ ):
548
+ """
549
+ Compute per-face direct/diffuse/global (W/m²) on a building mesh with SVF.
550
+ """
551
+ tree_k = kwargs.get("tree_k", 0.6)
552
+ tree_lad = kwargs.get("tree_lad", 1.0)
553
+ progress_report = kwargs.get("progress_report", False)
554
+ fast_path = kwargs.get("fast_path", True)
555
+
556
+ voxel_data = voxcity.voxels.classes
557
+ meshsize = voxcity.voxels.meta.meshsize
558
+
559
+ # Sun vector
560
+ az_rad = np.deg2rad(180 - azimuth_degrees)
561
+ el_rad = np.deg2rad(elevation_degrees)
562
+ sun_dx = np.cos(el_rad) * np.cos(az_rad)
563
+ sun_dy = np.cos(el_rad) * np.sin(az_rad)
564
+ sun_dz = np.sin(el_rad)
565
+ sun_direction = np.array([sun_dx, sun_dy, sun_dz], dtype=np.float64)
566
+
567
+ # SVF
568
+ if hasattr(building_svf_mesh, 'metadata') and ('svf' in building_svf_mesh.metadata):
569
+ face_svf = building_svf_mesh.metadata['svf']
570
+ else:
571
+ face_svf = np.zeros(len(building_svf_mesh.faces), dtype=np.float64)
572
+
573
+ # Geometry caches
574
+ precomputed_geometry = kwargs.get("precomputed_geometry", None)
575
+ if precomputed_geometry is not None:
576
+ face_centers = precomputed_geometry.get("face_centers", building_svf_mesh.triangles_center)
577
+ face_normals = precomputed_geometry.get("face_normals", building_svf_mesh.face_normals)
578
+ grid_bounds_real = precomputed_geometry.get("grid_bounds_real", None)
579
+ boundary_epsilon = precomputed_geometry.get("boundary_epsilon", None)
580
+ else:
581
+ face_centers = building_svf_mesh.triangles_center
582
+ face_normals = building_svf_mesh.face_normals
583
+ grid_bounds_real = None
584
+ boundary_epsilon = None
585
+
586
+ if grid_bounds_real is None or boundary_epsilon is None:
587
+ grid_shape = voxel_data.shape
588
+ grid_bounds_voxel = np.array([[0, 0, 0], [grid_shape[0], grid_shape[1], grid_shape[2]]], dtype=np.float64)
589
+ grid_bounds_real = grid_bounds_voxel * meshsize
590
+ boundary_epsilon = meshsize * 0.05
591
+
592
+ if fast_path:
593
+ precomputed_masks = kwargs.get("precomputed_masks", None)
594
+ if precomputed_masks is not None:
595
+ vox_is_tree = precomputed_masks.get("vox_is_tree", (voxel_data == -2))
596
+ vox_is_opaque = precomputed_masks.get("vox_is_opaque", (voxel_data != 0) & (voxel_data != -2))
597
+ att = float(precomputed_masks.get("att", np.exp(-tree_k * tree_lad * meshsize)))
598
+ else:
599
+ vox_is_tree = (voxel_data == -2)
600
+ vox_is_opaque = (voxel_data != 0) & (~vox_is_tree)
601
+ att = float(np.exp(-tree_k * tree_lad * meshsize))
602
+
603
+ face_direct, face_diffuse, face_global = compute_solar_irradiance_for_all_faces_masked(
604
+ face_centers.astype(np.float64),
605
+ face_normals.astype(np.float64),
606
+ face_svf.astype(np.float64),
607
+ sun_direction.astype(np.float64),
608
+ float(direct_normal_irradiance),
609
+ float(diffuse_irradiance),
610
+ vox_is_tree,
611
+ vox_is_opaque,
612
+ float(meshsize),
613
+ att,
614
+ float(grid_bounds_real[0,0]), float(grid_bounds_real[0,1]), float(grid_bounds_real[0,2]),
615
+ float(grid_bounds_real[1,0]), float(grid_bounds_real[1,1]), float(grid_bounds_real[1,2]),
616
+ float(boundary_epsilon)
617
+ )
618
+ else:
619
+ hit_values = (0,)
620
+ inclusion_mode = False
621
+ face_direct, face_diffuse, face_global = compute_solar_irradiance_for_all_faces(
622
+ face_centers.astype(np.float64),
623
+ face_normals.astype(np.float64),
624
+ face_svf.astype(np.float64),
625
+ sun_direction.astype(np.float64),
626
+ float(direct_normal_irradiance),
627
+ float(diffuse_irradiance),
628
+ voxel_data,
629
+ float(meshsize),
630
+ float(tree_k),
631
+ float(tree_lad),
632
+ hit_values,
633
+ inclusion_mode,
634
+ grid_bounds_real.astype(np.float64),
635
+ float(boundary_epsilon)
636
+ )
637
+
638
+ irradiance_mesh = building_svf_mesh.copy()
639
+ if not hasattr(irradiance_mesh, 'metadata'):
640
+ irradiance_mesh.metadata = {}
641
+ irradiance_mesh.metadata['svf'] = face_svf
642
+ irradiance_mesh.metadata['direct'] = face_direct
643
+ irradiance_mesh.metadata['diffuse'] = face_diffuse
644
+ irradiance_mesh.metadata['global'] = face_global
645
+ irradiance_mesh.name = "Solar Irradiance (W/m²)"
646
+ return irradiance_mesh
647
+
648
+