voxcity 1.0.2__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 (50) hide show
  1. voxcity/downloader/ocean.py +559 -0
  2. voxcity/generator/api.py +6 -0
  3. voxcity/generator/grids.py +45 -32
  4. voxcity/generator/pipeline.py +327 -27
  5. voxcity/geoprocessor/draw.py +14 -8
  6. voxcity/geoprocessor/raster/__init__.py +2 -0
  7. voxcity/geoprocessor/raster/core.py +31 -0
  8. voxcity/geoprocessor/raster/landcover.py +173 -49
  9. voxcity/geoprocessor/raster/raster.py +1 -1
  10. voxcity/models.py +2 -0
  11. voxcity/simulator_gpu/__init__.py +115 -0
  12. voxcity/simulator_gpu/common/__init__.py +9 -0
  13. voxcity/simulator_gpu/common/geometry.py +11 -0
  14. voxcity/simulator_gpu/core.py +322 -0
  15. voxcity/simulator_gpu/domain.py +262 -0
  16. voxcity/simulator_gpu/environment.yml +11 -0
  17. voxcity/simulator_gpu/init_taichi.py +154 -0
  18. voxcity/simulator_gpu/integration.py +15 -0
  19. voxcity/simulator_gpu/kernels.py +56 -0
  20. voxcity/simulator_gpu/radiation.py +28 -0
  21. voxcity/simulator_gpu/raytracing.py +623 -0
  22. voxcity/simulator_gpu/sky.py +9 -0
  23. voxcity/simulator_gpu/solar/__init__.py +178 -0
  24. voxcity/simulator_gpu/solar/core.py +66 -0
  25. voxcity/simulator_gpu/solar/csf.py +1249 -0
  26. voxcity/simulator_gpu/solar/domain.py +561 -0
  27. voxcity/simulator_gpu/solar/epw.py +421 -0
  28. voxcity/simulator_gpu/solar/integration.py +2953 -0
  29. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  30. voxcity/simulator_gpu/solar/raytracing.py +686 -0
  31. voxcity/simulator_gpu/solar/reflection.py +533 -0
  32. voxcity/simulator_gpu/solar/sky.py +907 -0
  33. voxcity/simulator_gpu/solar/solar.py +337 -0
  34. voxcity/simulator_gpu/solar/svf.py +446 -0
  35. voxcity/simulator_gpu/solar/volumetric.py +1151 -0
  36. voxcity/simulator_gpu/solar/voxcity.py +2953 -0
  37. voxcity/simulator_gpu/temporal.py +13 -0
  38. voxcity/simulator_gpu/utils.py +25 -0
  39. voxcity/simulator_gpu/view.py +32 -0
  40. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  41. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  42. voxcity/simulator_gpu/visibility/integration.py +808 -0
  43. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  44. voxcity/simulator_gpu/visibility/view.py +944 -0
  45. voxcity/visualizer/renderer.py +2 -1
  46. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/METADATA +16 -53
  47. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/RECORD +50 -15
  48. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
  49. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
  50. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1151 @@
1
+ """Volumetric radiative flux calculation for palm-solar.
2
+
3
+ Computes 3D radiation fields at each grid cell, not just at surfaces.
4
+ Based on PALM's radiation_volumetric_flux feature.
5
+
6
+ Key outputs:
7
+ - skyvf_vol: Volumetric sky view factor at each (i, j, k)
8
+ - swflux_vol: Omnidirectional volumetric SW flux at each (i, j, k)
9
+ - swflux_reflected_vol: Reflected radiation from surfaces at each (i, j, k)
10
+ - shadow_top: Shadow height for each solar direction
11
+
12
+ Reflected radiation mode:
13
+ - When include_reflections=True, the volumetric flux includes radiation
14
+ reflected from buildings, ground, and tree surfaces
15
+ - Reflections are traced from each surface element to volumetric grid cells
16
+ - Uses Beer-Lambert attenuation through vegetation
17
+ """
18
+
19
+ import taichi as ti
20
+ import numpy as np
21
+ import math
22
+ from typing import Optional, Tuple, List, Union
23
+ from enum import Enum
24
+
25
+ from .core import Vector3, Point3, PI, TWO_PI, EXT_COEF
26
+
27
+
28
+ class VolumetricFluxMode(Enum):
29
+ """Mode for volumetric flux computation."""
30
+ DIRECT_DIFFUSE = "direct_diffuse" # Only direct + diffuse sky radiation
31
+ WITH_REFLECTIONS = "with_reflections" # Include reflected radiation from surfaces
32
+
33
+
34
+ @ti.data_oriented
35
+ class VolumetricFluxCalculator:
36
+ """
37
+ GPU-accelerated volumetric radiative flux calculator.
38
+
39
+ Computes 3D radiation fields throughout the domain volume,
40
+ not just at surface elements. This is useful for:
41
+ - Mean Radiant Temperature (MRT) calculations
42
+ - Photolysis rate estimation
43
+ - Plant canopy light availability
44
+ - Pedestrian thermal comfort
45
+
46
+ Modes:
47
+ - DIRECT_DIFFUSE: Only direct solar + diffuse sky radiation (faster)
48
+ - WITH_REFLECTIONS: Includes reflected radiation from buildings/ground/trees
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ domain,
54
+ n_azimuth: int = 36,
55
+ min_opaque_lad: float = 0.5
56
+ ):
57
+ """
58
+ Initialize volumetric flux calculator.
59
+
60
+ Args:
61
+ domain: Domain object with grid geometry
62
+ n_azimuth: Number of azimuthal directions for horizon tracing
63
+ min_opaque_lad: Minimum LAD value considered opaque for shadow purposes
64
+ """
65
+ self.domain = domain
66
+ self.nx = domain.nx
67
+ self.ny = domain.ny
68
+ self.nz = domain.nz
69
+ self.dx = domain.dx
70
+ self.dy = domain.dy
71
+ self.dz = domain.dz
72
+
73
+ self.n_azimuth = n_azimuth
74
+ self.min_opaque_lad = min_opaque_lad
75
+
76
+ # Default mode
77
+ self.mode = VolumetricFluxMode.DIRECT_DIFFUSE
78
+
79
+ # Maximum trace distance
80
+ self.max_dist = math.sqrt(
81
+ (self.nx * self.dx)**2 +
82
+ (self.ny * self.dy)**2 +
83
+ (self.nz * self.dz)**2
84
+ )
85
+
86
+ # Volumetric sky view factor: fraction of sky visible from each grid cell
87
+ self.skyvf_vol = ti.field(dtype=ti.f32, shape=(self.nx, self.ny, self.nz))
88
+
89
+ # Volumetric SW flux: omnidirectional flux at each grid cell (W/m²)
90
+ # Represents average irradiance onto an imaginary sphere
91
+ self.swflux_vol = ti.field(dtype=ti.f32, shape=(self.nx, self.ny, self.nz))
92
+
93
+ # Volumetric reflected SW flux: radiation reflected from surfaces (W/m²)
94
+ self.swflux_reflected_vol = ti.field(dtype=ti.f32, shape=(self.nx, self.ny, self.nz))
95
+
96
+ # Separate components for analysis
97
+ self.swflux_direct_vol = ti.field(dtype=ti.f32, shape=(self.nx, self.ny, self.nz))
98
+ self.swflux_diffuse_vol = ti.field(dtype=ti.f32, shape=(self.nx, self.ny, self.nz))
99
+
100
+ # Opaque top: highest level that blocks direct radiation
101
+ # Considers both buildings and dense vegetation
102
+ self.opaque_top = ti.field(dtype=ti.i32, shape=(self.nx, self.ny))
103
+
104
+ # Shadow top per solar direction: highest level in shadow
105
+ # For single solar direction (current sun position)
106
+ self.shadow_top = ti.field(dtype=ti.i32, shape=(self.nx, self.ny))
107
+
108
+ # Horizon angle for each (i, j, k, azimuth) - temporary storage
109
+ # Stored as tangent of elevation angle
110
+ self._horizon_tan = ti.field(dtype=ti.f32, shape=(self.nx, self.ny, self.nz))
111
+
112
+ # Pre-computed azimuth directions
113
+ self.azim_dir_x = ti.field(dtype=ti.f32, shape=(n_azimuth,))
114
+ self.azim_dir_y = ti.field(dtype=ti.f32, shape=(n_azimuth,))
115
+
116
+ self._init_azimuth_directions()
117
+
118
+ # Flag for computed state
119
+ self._skyvf_computed = False
120
+
121
+ @ti.kernel
122
+ def _init_azimuth_directions(self):
123
+ """Pre-compute azimuth direction vectors."""
124
+ for iaz in range(self.n_azimuth):
125
+ azimuth = (ti.cast(iaz, ti.f32) + 0.5) * TWO_PI / ti.cast(self.n_azimuth, ti.f32)
126
+ # x = east (sin), y = north (cos)
127
+ self.azim_dir_x[iaz] = ti.sin(azimuth)
128
+ self.azim_dir_y[iaz] = ti.cos(azimuth)
129
+
130
+ @ti.kernel
131
+ def _compute_opaque_top(
132
+ self,
133
+ is_solid: ti.template(),
134
+ lad: ti.template(),
135
+ has_lad: ti.i32
136
+ ):
137
+ """
138
+ Compute the opaque top level for each column.
139
+
140
+ Considers both solid obstacles (buildings) and dense vegetation.
141
+ """
142
+ for i, j in ti.ndrange(self.nx, self.ny):
143
+ # Start with terrain/building top
144
+ top_k = 0
145
+ for k in range(self.nz):
146
+ if is_solid[i, j, k] == 1:
147
+ top_k = k
148
+
149
+ # Check vegetation above solid top (iterate downward from top)
150
+ if has_lad == 1:
151
+ # Taichi doesn't support 3-arg range with step, so we iterate forward
152
+ # and compute the reversed index
153
+ num_levels = self.nz - 1 - top_k
154
+ for k_rev in range(num_levels):
155
+ k = self.nz - 1 - k_rev
156
+ if lad[i, j, k] >= self.min_opaque_lad:
157
+ if k > top_k:
158
+ top_k = k
159
+ break
160
+
161
+ self.opaque_top[i, j] = top_k
162
+
163
+ @ti.func
164
+ def _trace_horizon_single_azimuth(
165
+ self,
166
+ i_start: ti.i32,
167
+ j_start: ti.i32,
168
+ k_level: ti.i32,
169
+ dir_x: ti.f32,
170
+ dir_y: ti.f32,
171
+ is_solid: ti.template()
172
+ ) -> ti.f32:
173
+ """
174
+ Trace horizon in a single azimuth direction from a point.
175
+
176
+ Returns the tangent of the horizon elevation angle.
177
+ A higher value means more sky is blocked.
178
+ Only considers solid obstacles (buildings), not vegetation.
179
+ """
180
+ # Starting position (center of grid cell)
181
+ x0 = (ti.cast(i_start, ti.f32) + 0.5) * self.dx
182
+ y0 = (ti.cast(j_start, ti.f32) + 0.5) * self.dy
183
+ z0 = (ti.cast(k_level, ti.f32) + 0.5) * self.dz
184
+
185
+ max_horizon_tan = -1e10 # Start below horizon
186
+
187
+ # Step along the direction
188
+ step_dist = ti.min(self.dx, self.dy)
189
+ n_steps = ti.cast(self.max_dist / step_dist, ti.i32) + 1
190
+
191
+ for step in range(1, n_steps):
192
+ dist = ti.cast(step, ti.f32) * step_dist
193
+
194
+ x = x0 + dir_x * dist
195
+ y = y0 + dir_y * dist
196
+
197
+ # Check if out of domain
198
+ if x < 0.0 or x >= self.nx * self.dx:
199
+ break
200
+ if y < 0.0 or y >= self.ny * self.dy:
201
+ break
202
+
203
+ # Grid indices
204
+ ix = ti.cast(ti.floor(x / self.dx), ti.i32)
205
+ iy = ti.cast(ti.floor(y / self.dy), ti.i32)
206
+
207
+ ix = ti.max(0, ti.min(self.nx - 1, ix))
208
+ iy = ti.max(0, ti.min(self.ny - 1, iy))
209
+
210
+ # Find solid top at this location (not opaque_top which includes vegetation)
211
+ solid_top_k = 0
212
+ for kk in range(self.nz):
213
+ if is_solid[ix, iy, kk] == 1:
214
+ solid_top_k = kk
215
+
216
+ obstacle_z = (ti.cast(solid_top_k, ti.f32) + 1.0) * self.dz # Top of obstacle
217
+
218
+ # Compute elevation angle tangent to obstacle top
219
+ dz = obstacle_z - z0
220
+ horizon_tan = dz / dist
221
+
222
+ if horizon_tan > max_horizon_tan:
223
+ max_horizon_tan = horizon_tan
224
+
225
+ return max_horizon_tan
226
+
227
+ @ti.func
228
+ def _trace_transmissivity_zenith(
229
+ self,
230
+ i: ti.i32,
231
+ j: ti.i32,
232
+ k: ti.i32,
233
+ zenith_angle: ti.f32,
234
+ azimuth: ti.f32,
235
+ is_solid: ti.template(),
236
+ lad: ti.template(),
237
+ has_lad: ti.i32
238
+ ) -> ti.f32:
239
+ """
240
+ Trace transmissivity from a point toward sky at given zenith/azimuth.
241
+
242
+ Returns transmissivity [0, 1] accounting for:
243
+ - Solid obstacles (transmissivity = 0)
244
+ - Vegetation (Beer-Lambert attenuation)
245
+
246
+ Args:
247
+ i, j, k: Starting grid cell
248
+ zenith_angle: Angle from vertical (0 = straight up)
249
+ azimuth: Horizontal angle (0 = north, π/2 = east)
250
+ is_solid: Solid obstacle field
251
+ lad: Leaf Area Density field
252
+ has_lad: Whether LAD field exists
253
+ """
254
+ # Direction vector (pointing toward sky)
255
+ sin_zen = ti.sin(zenith_angle)
256
+ cos_zen = ti.cos(zenith_angle)
257
+ dir_x = sin_zen * ti.sin(azimuth) # East component
258
+ dir_y = sin_zen * ti.cos(azimuth) # North component
259
+ dir_z = cos_zen # Up component
260
+
261
+ # Starting position
262
+ x = (ti.cast(i, ti.f32) + 0.5) * self.dx
263
+ y = (ti.cast(j, ti.f32) + 0.5) * self.dy
264
+ z = (ti.cast(k, ti.f32) + 0.5) * self.dz
265
+
266
+ # Accumulated LAD path length
267
+ cumulative_lad_path = 0.0
268
+ transmissivity = 1.0
269
+
270
+ # Step size based on grid resolution
271
+ step_dist = ti.min(self.dx, ti.min(self.dy, self.dz)) * 0.5
272
+ max_steps = ti.cast(self.max_dist / step_dist, ti.i32) + 1
273
+
274
+ for step in range(1, max_steps):
275
+ dist = ti.cast(step, ti.f32) * step_dist
276
+
277
+ # Current position
278
+ cx = x + dir_x * dist
279
+ cy = y + dir_y * dist
280
+ cz = z + dir_z * dist
281
+
282
+ # Check bounds
283
+ if cx < 0.0 or cx >= self.nx * self.dx:
284
+ break
285
+ if cy < 0.0 or cy >= self.ny * self.dy:
286
+ break
287
+ if cz < 0.0 or cz >= self.nz * self.dz:
288
+ break # Exited domain through top - reached sky
289
+
290
+ # Grid indices
291
+ ix = ti.cast(ti.floor(cx / self.dx), ti.i32)
292
+ iy = ti.cast(ti.floor(cy / self.dy), ti.i32)
293
+ iz = ti.cast(ti.floor(cz / self.dz), ti.i32)
294
+
295
+ ix = ti.max(0, ti.min(self.nx - 1, ix))
296
+ iy = ti.max(0, ti.min(self.ny - 1, iy))
297
+ iz = ti.max(0, ti.min(self.nz - 1, iz))
298
+
299
+ # Check for solid obstacle - completely blocks
300
+ if is_solid[ix, iy, iz] == 1:
301
+ transmissivity = 0.0
302
+ break
303
+
304
+ # Accumulate LAD for Beer-Lambert
305
+ if has_lad == 1:
306
+ cell_lad = lad[ix, iy, iz]
307
+ if cell_lad > 0.0:
308
+ cumulative_lad_path += cell_lad * step_dist
309
+
310
+ # Apply Beer-Lambert if passed through vegetation
311
+ if transmissivity > 0.0 and cumulative_lad_path > 0.0:
312
+ transmissivity = ti.exp(-EXT_COEF * cumulative_lad_path)
313
+
314
+ return transmissivity
315
+
316
+ @ti.kernel
317
+ def _compute_skyvf_vol_kernel(
318
+ self,
319
+ is_solid: ti.template()
320
+ ):
321
+ """
322
+ Compute volumetric sky view factor for all grid cells.
323
+
324
+ For each cell, traces horizons in all azimuth directions
325
+ and integrates the visible sky fraction.
326
+ This version only considers solid obstacles (no vegetation).
327
+ """
328
+ n_az_f = ti.cast(self.n_azimuth, ti.f32)
329
+
330
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
331
+ # Skip cells inside solid obstacles
332
+ if is_solid[i, j, k] == 1:
333
+ self.skyvf_vol[i, j, k] = 0.0
334
+ continue
335
+
336
+ # Integrate sky view over all azimuths
337
+ total_svf = 0.0
338
+
339
+ for iaz in range(self.n_azimuth):
340
+ dir_x = self.azim_dir_x[iaz]
341
+ dir_y = self.azim_dir_y[iaz]
342
+
343
+ # Get horizon tangent in this direction (solid obstacles only)
344
+ horizon_tan = self._trace_horizon_single_azimuth(
345
+ i, j, k, dir_x, dir_y, is_solid
346
+ )
347
+
348
+ # Convert tangent to elevation angle, then to cos(zenith)
349
+ cos_zen = 0.0
350
+ if horizon_tan >= 0.0:
351
+ cos_zen = horizon_tan / ti.sqrt(1.0 + horizon_tan * horizon_tan)
352
+
353
+ # Sky view contribution: (1 - cos_zenith_of_horizon)
354
+ svf_contrib = (1.0 - cos_zen)
355
+ total_svf += svf_contrib
356
+
357
+ # Normalize: divide by number of azimuths and factor of 2 for hemisphere
358
+ self.skyvf_vol[i, j, k] = total_svf / (2.0 * n_az_f)
359
+
360
+ @ti.kernel
361
+ def _compute_skyvf_vol_with_lad_kernel(
362
+ self,
363
+ is_solid: ti.template(),
364
+ lad: ti.template(),
365
+ n_zenith: ti.i32
366
+ ):
367
+ """
368
+ Compute volumetric sky view factor with vegetation transmissivity.
369
+
370
+ Integrates over hemisphere using discrete zenith and azimuth angles,
371
+ applying Beer-Lambert attenuation through vegetation.
372
+
373
+ SVF = (1/2π) ∫∫ τ(θ,φ) cos(θ) sin(θ) dθ dφ
374
+
375
+ where τ is transmissivity through vegetation/obstacles.
376
+ """
377
+ n_az_f = ti.cast(self.n_azimuth, ti.f32)
378
+ n_zen_f = ti.cast(n_zenith, ti.f32)
379
+
380
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
381
+ # Skip cells inside solid obstacles
382
+ if is_solid[i, j, k] == 1:
383
+ self.skyvf_vol[i, j, k] = 0.0
384
+ continue
385
+
386
+ # Integrate over hemisphere
387
+ # SVF = (1/2π) ∫₀^(π/2) ∫₀^(2π) τ(θ,φ) cos(θ) sin(θ) dθ dφ
388
+ # Discretized with uniform spacing
389
+ total_weighted_trans = 0.0
390
+ total_weight = 0.0
391
+
392
+ for izen in range(n_zenith):
393
+ # Zenith angle from 0 (up) to π/2 (horizontal)
394
+ # Use midpoint of each bin
395
+ zenith = (ti.cast(izen, ti.f32) + 0.5) * (PI / 2.0) / n_zen_f
396
+
397
+ # Weight: cos(θ) * sin(θ) * dθ
398
+ # This accounts for solid angle and projection
399
+ cos_zen = ti.cos(zenith)
400
+ sin_zen = ti.sin(zenith)
401
+ weight = cos_zen * sin_zen
402
+
403
+ for iaz in range(self.n_azimuth):
404
+ azimuth = (ti.cast(iaz, ti.f32) + 0.5) * TWO_PI / n_az_f
405
+
406
+ # Trace transmissivity toward sky
407
+ trans = self._trace_transmissivity_zenith(
408
+ i, j, k, zenith, azimuth, is_solid, lad, 1
409
+ )
410
+
411
+ total_weighted_trans += trans * weight
412
+ total_weight += weight
413
+
414
+ # Normalize by total weight (integral of cos*sin over hemisphere = 0.5)
415
+ if total_weight > 0.0:
416
+ self.skyvf_vol[i, j, k] = total_weighted_trans / total_weight
417
+ else:
418
+ self.skyvf_vol[i, j, k] = 0.0
419
+
420
+ @ti.kernel
421
+ def _compute_shadow_top_kernel(
422
+ self,
423
+ sun_dir: ti.types.vector(3, ti.f32),
424
+ is_solid: ti.template()
425
+ ):
426
+ """
427
+ Compute shadow top for current solar direction.
428
+
429
+ Shadow top is the highest grid level that is in shadow
430
+ (direct solar radiation blocked).
431
+ """
432
+ # Horizontal direction magnitude
433
+ horiz_mag = ti.sqrt(sun_dir[0]**2 + sun_dir[1]**2)
434
+
435
+ # Tangent of solar elevation
436
+ solar_tan = 1e10 # Default: sun near zenith
437
+ if horiz_mag > 1e-6:
438
+ solar_tan = sun_dir[2] / horiz_mag
439
+
440
+ # Horizontal direction components (normalized)
441
+ dir_x = 0.0
442
+ dir_y = 1.0
443
+ if horiz_mag > 1e-6:
444
+ dir_x = sun_dir[0] / horiz_mag
445
+ dir_y = sun_dir[1] / horiz_mag
446
+
447
+ for i, j in ti.ndrange(self.nx, self.ny):
448
+ # Start from opaque top
449
+ shadow_k = self.opaque_top[i, j]
450
+
451
+ # Trace upward to find where horizon drops below solar elevation
452
+ for k in range(self.opaque_top[i, j] + 1, self.nz):
453
+ # Get horizon in sun direction
454
+ horizon_tan = self._trace_horizon_single_azimuth(
455
+ i, j, k, dir_x, dir_y, is_solid
456
+ )
457
+
458
+ # If horizon is below sun, this level is sunlit
459
+ if horizon_tan < solar_tan:
460
+ break
461
+
462
+ shadow_k = k
463
+
464
+ self.shadow_top[i, j] = shadow_k
465
+
466
+ @ti.kernel
467
+ def _compute_swflux_vol_kernel(
468
+ self,
469
+ sw_direct: ti.f32,
470
+ sw_diffuse: ti.f32,
471
+ cos_zenith: ti.f32,
472
+ is_solid: ti.template()
473
+ ):
474
+ """
475
+ Compute volumetric shortwave flux at each grid cell.
476
+
477
+ The flux represents average irradiance onto an imaginary sphere,
478
+ combining direct and diffuse components.
479
+ Also stores separate direct and diffuse components.
480
+ """
481
+ # Sun direct factor (convert horizontal to normal)
482
+ sun_factor = 1.0
483
+ if cos_zenith > 0.0262: # min_stable_coszen
484
+ sun_factor = 1.0 / cos_zenith
485
+
486
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
487
+ # Skip solid cells
488
+ if is_solid[i, j, k] == 1:
489
+ self.swflux_vol[i, j, k] = 0.0
490
+ self.swflux_direct_vol[i, j, k] = 0.0
491
+ self.swflux_diffuse_vol[i, j, k] = 0.0
492
+ continue
493
+
494
+ direct_flux = 0.0
495
+ diffuse_flux = 0.0
496
+
497
+ # Direct component: only above shadow level
498
+ if k > self.shadow_top[i, j] and cos_zenith > 0.0:
499
+ # For a sphere, the ratio of projected area to surface area is 1/4
500
+ # Direct flux onto sphere = sw_direct * sun_factor * 0.25
501
+ direct_flux = sw_direct * sun_factor * 0.25
502
+
503
+ # Diffuse component: weighted by volumetric sky view factor
504
+ # For a sphere receiving isotropic diffuse radiation:
505
+ # diffuse_flux = sw_diffuse * skyvf_vol
506
+ diffuse_flux = sw_diffuse * self.skyvf_vol[i, j, k]
507
+
508
+ self.swflux_direct_vol[i, j, k] = direct_flux
509
+ self.swflux_diffuse_vol[i, j, k] = diffuse_flux
510
+ self.swflux_vol[i, j, k] = direct_flux + diffuse_flux
511
+
512
+ @ti.kernel
513
+ def _compute_swflux_vol_with_lad_kernel(
514
+ self,
515
+ sw_direct: ti.f32,
516
+ sw_diffuse: ti.f32,
517
+ cos_zenith: ti.f32,
518
+ sun_dir: ti.types.vector(3, ti.f32),
519
+ is_solid: ti.template(),
520
+ lad: ti.template()
521
+ ):
522
+ """
523
+ Compute volumetric shortwave flux with LAD attenuation.
524
+
525
+ The flux is attenuated through vegetation using Beer-Lambert law.
526
+ Direct radiation is traced toward the sun with proper attenuation.
527
+ """
528
+ # Sun direct factor (convert horizontal irradiance to normal)
529
+ sun_factor = 1.0
530
+ if cos_zenith > 0.0262:
531
+ sun_factor = 1.0 / cos_zenith
532
+
533
+ # Compute solar zenith angle for transmissivity tracing
534
+ solar_zenith = ti.acos(ti.max(-1.0, ti.min(1.0, cos_zenith)))
535
+
536
+ # Compute solar azimuth from sun direction
537
+ solar_azimuth = ti.atan2(sun_dir[0], sun_dir[1]) # atan2(east, north)
538
+
539
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
540
+ # Skip solid cells
541
+ if is_solid[i, j, k] == 1:
542
+ self.swflux_vol[i, j, k] = 0.0
543
+ self.swflux_direct_vol[i, j, k] = 0.0
544
+ self.swflux_diffuse_vol[i, j, k] = 0.0
545
+ continue
546
+
547
+ direct_flux = 0.0
548
+ diffuse_flux = 0.0
549
+
550
+ # Direct component with full 3D transmissivity tracing
551
+ if cos_zenith > 0.0262: # Sun is up
552
+ # Trace transmissivity toward sun through vegetation and obstacles
553
+ trans = self._trace_transmissivity_zenith(
554
+ i, j, k, solar_zenith, solar_azimuth, is_solid, lad, 1
555
+ )
556
+
557
+ # Direct flux onto sphere = sw_direct * sun_factor * 0.25 * transmissivity
558
+ direct_flux = sw_direct * sun_factor * 0.25 * trans
559
+
560
+ # Diffuse component: SVF already accounts for vegetation attenuation
561
+ # (computed by _compute_skyvf_vol_with_lad_kernel)
562
+ diffuse_flux = sw_diffuse * self.skyvf_vol[i, j, k]
563
+
564
+ self.swflux_direct_vol[i, j, k] = direct_flux
565
+ self.swflux_diffuse_vol[i, j, k] = diffuse_flux
566
+ self.swflux_vol[i, j, k] = direct_flux + diffuse_flux
567
+
568
+ @ti.func
569
+ def _compute_canopy_transmissivity(
570
+ self,
571
+ i: ti.i32,
572
+ j: ti.i32,
573
+ k: ti.i32,
574
+ sun_dir: ti.types.vector(3, ti.f32),
575
+ is_solid: ti.template(),
576
+ lad: ti.template()
577
+ ) -> ti.f32:
578
+ """
579
+ Compute transmissivity through canopy from point (i,j,k) toward sun.
580
+
581
+ Uses simplified vertical integration (for efficiency).
582
+ Full 3D ray tracing is done in CSF calculator.
583
+ """
584
+ cumulative_lad_path = 0.0
585
+ blocked = 0
586
+
587
+ # Integrate upward through canopy
588
+ # Simplified: just sum LAD in vertical column above
589
+ # More accurate would trace along sun direction
590
+ for kk in range(k + 1, self.nz):
591
+ if blocked == 0:
592
+ if is_solid[i, j, kk] == 1:
593
+ # Hit solid - mark as fully blocked
594
+ blocked = 1
595
+ cumulative_lad_path = 1e10 # Large value for zero transmissivity
596
+ else:
597
+ cell_lad = lad[i, j, kk]
598
+ if cell_lad > 0.0:
599
+ # Path length through cell (vertical)
600
+ # For non-vertical sun, would need angle correction
601
+ path_len = self.dz / ti.max(0.1, sun_dir[2]) # Avoid division by zero
602
+ cumulative_lad_path += cell_lad * path_len
603
+
604
+ # Beer-Lambert transmissivity
605
+ return ti.exp(-EXT_COEF * cumulative_lad_path)
606
+
607
+ def compute_opaque_top(self):
608
+ """
609
+ Compute opaque top levels considering buildings and vegetation.
610
+ """
611
+ has_lad = 1 if self.domain.lad is not None else 0
612
+
613
+ if has_lad:
614
+ self._compute_opaque_top(
615
+ self.domain.is_solid,
616
+ self.domain.lad,
617
+ has_lad
618
+ )
619
+ else:
620
+ self._compute_opaque_top_no_lad(self.domain.is_solid)
621
+
622
+ @ti.kernel
623
+ def _compute_opaque_top_no_lad(self, is_solid: ti.template()):
624
+ """Compute opaque top without vegetation."""
625
+ for i, j in ti.ndrange(self.nx, self.ny):
626
+ top_k = 0
627
+ for k in range(self.nz):
628
+ if is_solid[i, j, k] == 1:
629
+ top_k = k
630
+ self.opaque_top[i, j] = top_k
631
+
632
+ def compute_skyvf_vol(self, n_zenith: int = 9):
633
+ """
634
+ Compute volumetric sky view factors for all grid cells.
635
+
636
+ This is computationally expensive - call once per domain setup
637
+ or when geometry changes.
638
+
639
+ Args:
640
+ n_zenith: Number of zenith angle divisions for hemisphere integration.
641
+ Higher values give more accurate results but slower computation.
642
+ Default 9 gives ~10° resolution.
643
+ """
644
+ print("Computing opaque top levels...")
645
+ self.compute_opaque_top()
646
+
647
+ has_lad = self.domain.lad is not None
648
+
649
+ if has_lad:
650
+ print(f"Computing volumetric sky view factors with vegetation...")
651
+ print(f" ({self.n_azimuth} azimuths × {n_zenith} zenith angles)")
652
+ self._compute_skyvf_vol_with_lad_kernel(
653
+ self.domain.is_solid,
654
+ self.domain.lad,
655
+ n_zenith
656
+ )
657
+ else:
658
+ print(f"Computing volumetric sky view factors ({self.n_azimuth} azimuths)...")
659
+ self._compute_skyvf_vol_kernel(self.domain.is_solid)
660
+
661
+ self._skyvf_computed = True
662
+ print("Volumetric SVF computation complete.")
663
+
664
+ def compute_shadow_top(self, sun_direction: Tuple[float, float, float]):
665
+ """
666
+ Compute shadow top for a given solar direction.
667
+
668
+ Args:
669
+ sun_direction: Unit vector pointing toward sun (x, y, z)
670
+ """
671
+ if not self._skyvf_computed:
672
+ self.compute_opaque_top()
673
+
674
+ sun_dir = ti.Vector([sun_direction[0], sun_direction[1], sun_direction[2]])
675
+ self._compute_shadow_top_kernel(sun_dir, self.domain.is_solid)
676
+
677
+ def compute_swflux_vol(
678
+ self,
679
+ sw_direct: float,
680
+ sw_diffuse: float,
681
+ cos_zenith: float,
682
+ sun_direction: Tuple[float, float, float],
683
+ lad: Optional[ti.template] = None
684
+ ):
685
+ """
686
+ Compute volumetric shortwave flux for all grid cells.
687
+
688
+ Args:
689
+ sw_direct: Direct normal irradiance (W/m²)
690
+ sw_diffuse: Diffuse horizontal irradiance (W/m²)
691
+ cos_zenith: Cosine of solar zenith angle
692
+ sun_direction: Unit vector toward sun (x, y, z)
693
+ lad: Optional LAD field for canopy attenuation
694
+ """
695
+ if not self._skyvf_computed:
696
+ print("Warning: Volumetric SVF not computed, computing now...")
697
+ self.compute_skyvf_vol()
698
+
699
+ # Compute shadow heights for current sun position
700
+ self.compute_shadow_top(sun_direction)
701
+
702
+ # Compute flux (with or without LAD attenuation)
703
+ if lad is not None:
704
+ sun_dir = ti.Vector([sun_direction[0], sun_direction[1], sun_direction[2]])
705
+ self._compute_swflux_vol_with_lad_kernel(
706
+ sw_direct,
707
+ sw_diffuse,
708
+ cos_zenith,
709
+ sun_dir,
710
+ self.domain.is_solid,
711
+ lad
712
+ )
713
+ else:
714
+ self._compute_swflux_vol_kernel(
715
+ sw_direct,
716
+ sw_diffuse,
717
+ cos_zenith,
718
+ self.domain.is_solid
719
+ )
720
+
721
+ def get_skyvf_vol(self) -> np.ndarray:
722
+ """Get volumetric sky view factor as numpy array."""
723
+ return self.skyvf_vol.to_numpy()
724
+
725
+ def get_swflux_vol(self) -> np.ndarray:
726
+ """Get volumetric SW flux as numpy array (W/m²)."""
727
+ return self.swflux_vol.to_numpy()
728
+
729
+ def get_shadow_top(self) -> np.ndarray:
730
+ """Get shadow top indices as numpy array."""
731
+ return self.shadow_top.to_numpy()
732
+
733
+ def get_opaque_top(self) -> np.ndarray:
734
+ """Get opaque top indices as numpy array."""
735
+ return self.opaque_top.to_numpy()
736
+
737
+ def get_shadow_mask_3d(self) -> np.ndarray:
738
+ """
739
+ Get 3D shadow mask (1=shadowed, 0=sunlit).
740
+
741
+ Returns:
742
+ 3D boolean array where True indicates shadowed cells
743
+ """
744
+ shadow_top = self.shadow_top.to_numpy()
745
+ is_solid = self.domain.is_solid.to_numpy()
746
+
747
+ mask = np.zeros((self.nx, self.ny, self.nz), dtype=bool)
748
+
749
+ for i in range(self.nx):
750
+ for j in range(self.ny):
751
+ k_shadow = shadow_top[i, j]
752
+ mask[i, j, :k_shadow+1] = True
753
+
754
+ # Also mark solid cells
755
+ mask[is_solid == 1] = True
756
+
757
+ return mask
758
+
759
+ def get_horizontal_slice(self, k: int, field: str = 'swflux') -> np.ndarray:
760
+ """
761
+ Get horizontal slice of a volumetric field.
762
+
763
+ Args:
764
+ k: Vertical level index
765
+ field: 'swflux' or 'skyvf'
766
+
767
+ Returns:
768
+ 2D array at level k
769
+ """
770
+ if field == 'swflux':
771
+ return self.swflux_vol.to_numpy()[:, :, k]
772
+ elif field == 'skyvf':
773
+ return self.skyvf_vol.to_numpy()[:, :, k]
774
+ else:
775
+ raise ValueError(f"Unknown field: {field}")
776
+
777
+ def get_vertical_slice(
778
+ self,
779
+ axis: str,
780
+ index: int,
781
+ field: str = 'swflux'
782
+ ) -> np.ndarray:
783
+ """
784
+ Get vertical slice of a volumetric field.
785
+
786
+ Args:
787
+ axis: 'x' or 'y'
788
+ index: Index along the axis
789
+ field: 'swflux' or 'skyvf'
790
+
791
+ Returns:
792
+ 2D array (horizontal_coord, z)
793
+ """
794
+ if field == 'swflux':
795
+ data = self.swflux_vol.to_numpy()
796
+ elif field == 'skyvf':
797
+ data = self.skyvf_vol.to_numpy()
798
+ else:
799
+ raise ValueError(f"Unknown field: {field}")
800
+
801
+ if axis == 'x':
802
+ return data[index, :, :]
803
+ elif axis == 'y':
804
+ return data[:, index, :]
805
+ else:
806
+ raise ValueError(f"Unknown axis: {axis}")
807
+
808
+ def set_mode(self, mode: Union[VolumetricFluxMode, str]):
809
+ """
810
+ Set the volumetric flux computation mode.
811
+
812
+ Args:
813
+ mode: Either a VolumetricFluxMode enum or string:
814
+ 'direct_diffuse' - Only direct + diffuse sky radiation
815
+ 'with_reflections' - Include reflected radiation from surfaces
816
+ """
817
+ if isinstance(mode, str):
818
+ mode = VolumetricFluxMode(mode)
819
+ self.mode = mode
820
+
821
+ @ti.func
822
+ def _trace_transmissivity_to_surface(
823
+ self,
824
+ i: ti.i32,
825
+ j: ti.i32,
826
+ k: ti.i32,
827
+ surf_x: ti.f32,
828
+ surf_y: ti.f32,
829
+ surf_z: ti.f32,
830
+ surf_nx: ti.f32,
831
+ surf_ny: ti.f32,
832
+ surf_nz: ti.f32,
833
+ is_solid: ti.template(),
834
+ lad: ti.template(),
835
+ has_lad: ti.i32
836
+ ) -> ti.f32:
837
+ """
838
+ Trace transmissivity from grid cell (i,j,k) to a surface element.
839
+
840
+ Returns transmissivity [0, 1] accounting for:
841
+ - Solid obstacles (transmissivity = 0)
842
+ - Vegetation (Beer-Lambert attenuation)
843
+ - Visibility check (normal pointing toward cell)
844
+
845
+ Args:
846
+ i, j, k: Grid cell indices
847
+ surf_x, surf_y, surf_z: Surface center position
848
+ surf_nx, surf_ny, surf_nz: Surface normal vector
849
+ is_solid: Solid obstacle field
850
+ lad: Leaf Area Density field
851
+ has_lad: Whether LAD field exists
852
+ """
853
+ # Cell center position
854
+ cell_x = (ti.cast(i, ti.f32) + 0.5) * self.dx
855
+ cell_y = (ti.cast(j, ti.f32) + 0.5) * self.dy
856
+ cell_z = (ti.cast(k, ti.f32) + 0.5) * self.dz
857
+
858
+ # Direction from surface to cell
859
+ dx = cell_x - surf_x
860
+ dy = cell_y - surf_y
861
+ dz = cell_z - surf_z
862
+ dist = ti.sqrt(dx*dx + dy*dy + dz*dz)
863
+
864
+ transmissivity = 0.0
865
+
866
+ if dist > 0.01: # Avoid self-intersection
867
+ # Normalize direction
868
+ dir_x = dx / dist
869
+ dir_y = dy / dist
870
+ dir_z = dz / dist
871
+
872
+ # Check if surface faces the cell (dot product with normal > 0)
873
+ cos_angle = dir_x * surf_nx + dir_y * surf_ny + dir_z * surf_nz
874
+
875
+ if cos_angle > 0.0:
876
+ transmissivity = 1.0
877
+ cumulative_lad_path = 0.0
878
+
879
+ # Step along the ray from surface to cell
880
+ step_dist = ti.min(self.dx, ti.min(self.dy, self.dz)) * 0.5
881
+ n_steps = ti.cast(dist / step_dist, ti.i32) + 1
882
+
883
+ for step in range(1, n_steps):
884
+ t = ti.cast(step, ti.f32) * step_dist
885
+ if t >= dist:
886
+ break
887
+
888
+ # Current position along ray
889
+ cx = surf_x + dir_x * t
890
+ cy = surf_y + dir_y * t
891
+ cz = surf_z + dir_z * t
892
+
893
+ # Check bounds
894
+ if cx < 0.0 or cx >= self.nx * self.dx:
895
+ break
896
+ if cy < 0.0 or cy >= self.ny * self.dy:
897
+ break
898
+ if cz < 0.0 or cz >= self.nz * self.dz:
899
+ break
900
+
901
+ # Grid indices
902
+ ix = ti.cast(ti.floor(cx / self.dx), ti.i32)
903
+ iy = ti.cast(ti.floor(cy / self.dy), ti.i32)
904
+ iz = ti.cast(ti.floor(cz / self.dz), ti.i32)
905
+
906
+ ix = ti.max(0, ti.min(self.nx - 1, ix))
907
+ iy = ti.max(0, ti.min(self.ny - 1, iy))
908
+ iz = ti.max(0, ti.min(self.nz - 1, iz))
909
+
910
+ # Check for solid obstacle - blocks completely
911
+ if is_solid[ix, iy, iz] == 1:
912
+ transmissivity = 0.0
913
+ break
914
+
915
+ # Accumulate LAD for Beer-Lambert
916
+ if has_lad == 1:
917
+ cell_lad = lad[ix, iy, iz]
918
+ if cell_lad > 0.0:
919
+ cumulative_lad_path += cell_lad * step_dist
920
+
921
+ # Apply Beer-Lambert attenuation
922
+ if transmissivity > 0.0 and cumulative_lad_path > 0.0:
923
+ transmissivity = ti.exp(-EXT_COEF * cumulative_lad_path)
924
+
925
+ # Apply geometric factor: cos(angle) / distance^2
926
+ # Normalized to produce flux in W/m²
927
+ transmissivity *= cos_angle
928
+
929
+ return transmissivity
930
+
931
+ @ti.kernel
932
+ def _compute_reflected_flux_kernel(
933
+ self,
934
+ n_surfaces: ti.i32,
935
+ surf_center: ti.template(),
936
+ surf_normal: ti.template(),
937
+ surf_area: ti.template(),
938
+ surf_outgoing: ti.template(),
939
+ is_solid: ti.template(),
940
+ lad: ti.template(),
941
+ has_lad: ti.i32
942
+ ):
943
+ """
944
+ Compute volumetric reflected flux from surface outgoing radiation.
945
+
946
+ For each grid cell, integrates reflected radiation from all visible
947
+ surfaces weighted by view factor and transmissivity.
948
+
949
+ Args:
950
+ n_surfaces: Number of surface elements
951
+ surf_center: Surface center positions (n_surfaces, 3)
952
+ surf_normal: Surface normal vectors (n_surfaces, 3)
953
+ surf_area: Surface areas (n_surfaces,)
954
+ surf_outgoing: Surface outgoing radiation in W/m² (n_surfaces,)
955
+ is_solid: Solid obstacle field
956
+ lad: Leaf Area Density field
957
+ has_lad: Whether LAD field exists
958
+ """
959
+ # For a sphere at each grid cell, reflected flux is:
960
+ # flux = Σ (surfout * area * transmissivity * cos_angle) / (4 * π * dist²)
961
+ # The factor 0.25 accounts for sphere geometry (projected area / surface area)
962
+
963
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
964
+ # Skip solid cells
965
+ if is_solid[i, j, k] == 1:
966
+ self.swflux_reflected_vol[i, j, k] = 0.0
967
+ continue
968
+
969
+ cell_x = (ti.cast(i, ti.f32) + 0.5) * self.dx
970
+ cell_y = (ti.cast(j, ti.f32) + 0.5) * self.dy
971
+ cell_z = (ti.cast(k, ti.f32) + 0.5) * self.dz
972
+
973
+ total_reflected = 0.0
974
+
975
+ for surf_idx in range(n_surfaces):
976
+ outgoing = surf_outgoing[surf_idx]
977
+
978
+ # Skip surfaces with negligible outgoing radiation
979
+ if outgoing > 0.1: # W/m² threshold
980
+ surf_x = surf_center[surf_idx][0]
981
+ surf_y = surf_center[surf_idx][1]
982
+ surf_z = surf_center[surf_idx][2]
983
+ surf_nx = surf_normal[surf_idx][0]
984
+ surf_ny = surf_normal[surf_idx][1]
985
+ surf_nz = surf_normal[surf_idx][2]
986
+ area = surf_area[surf_idx]
987
+
988
+ # Distance to surface
989
+ dx = cell_x - surf_x
990
+ dy = cell_y - surf_y
991
+ dz = cell_z - surf_z
992
+ dist_sq = dx*dx + dy*dy + dz*dz
993
+
994
+ if dist_sq > 0.01: # Avoid numerical issues
995
+ dist = ti.sqrt(dist_sq)
996
+
997
+ # Direction from surface to cell (normalized)
998
+ dir_x = dx / dist
999
+ dir_y = dy / dist
1000
+ dir_z = dz / dist
1001
+
1002
+ # Cosine of angle between normal and direction
1003
+ cos_angle = dir_x * surf_nx + dir_y * surf_ny + dir_z * surf_nz
1004
+
1005
+ if cos_angle > 0.0: # Surface faces the cell
1006
+ # Get transmissivity through vegetation/obstacles
1007
+ trans = self._trace_transmissivity_to_surface(
1008
+ i, j, k, surf_x, surf_y, surf_z,
1009
+ surf_nx, surf_ny, surf_nz,
1010
+ is_solid, lad, has_lad
1011
+ )
1012
+
1013
+ if trans > 0.0:
1014
+ # View factor contribution: (A * cos_θ) / (π * d²)
1015
+ # For omnidirectional sphere: multiply by 0.25
1016
+ vf = area * cos_angle / (PI * dist_sq)
1017
+ contribution = outgoing * vf * trans * 0.25
1018
+ total_reflected += contribution
1019
+
1020
+ self.swflux_reflected_vol[i, j, k] = total_reflected
1021
+
1022
+ def compute_reflected_flux_vol(
1023
+ self,
1024
+ surfaces,
1025
+ surf_outgoing: np.ndarray
1026
+ ):
1027
+ """
1028
+ Compute volumetric reflected flux from surface outgoing radiation.
1029
+
1030
+ This propagates reflected radiation from surfaces into the 3D volume.
1031
+ Should be called after surface reflection calculations are complete.
1032
+
1033
+ Args:
1034
+ surfaces: Surfaces object with geometry (center, normal, area)
1035
+ surf_outgoing: Array of surface outgoing radiation (W/m²)
1036
+ Shape: (n_surfaces,)
1037
+ """
1038
+ n_surfaces = surfaces.n_surfaces[None]
1039
+
1040
+ if n_surfaces == 0:
1041
+ print("Warning: No surfaces defined, skipping reflected flux calculation")
1042
+ return
1043
+
1044
+ # Create temporary taichi field for outgoing radiation
1045
+ surf_out_field = ti.field(dtype=ti.f32, shape=(n_surfaces,))
1046
+ surf_out_field.from_numpy(surf_outgoing[:n_surfaces].astype(np.float32))
1047
+
1048
+ has_lad = 1 if self.domain.lad is not None else 0
1049
+
1050
+ print(f"Computing volumetric reflected flux from {n_surfaces} surfaces...")
1051
+
1052
+ if has_lad:
1053
+ self._compute_reflected_flux_kernel(
1054
+ n_surfaces,
1055
+ surfaces.center,
1056
+ surfaces.normal,
1057
+ surfaces.area,
1058
+ surf_out_field,
1059
+ self.domain.is_solid,
1060
+ self.domain.lad,
1061
+ has_lad
1062
+ )
1063
+ else:
1064
+ self._compute_reflected_flux_kernel(
1065
+ n_surfaces,
1066
+ surfaces.center,
1067
+ surfaces.normal,
1068
+ surfaces.area,
1069
+ surf_out_field,
1070
+ self.domain.is_solid,
1071
+ self.domain.lad,
1072
+ 0
1073
+ )
1074
+
1075
+ print("Volumetric reflected flux computation complete.")
1076
+
1077
+ @ti.kernel
1078
+ def _add_reflected_to_total(self):
1079
+ """Add reflected flux to total volumetric flux."""
1080
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
1081
+ self.swflux_vol[i, j, k] += self.swflux_reflected_vol[i, j, k]
1082
+
1083
+ @ti.kernel
1084
+ def _clear_reflected_flux(self):
1085
+ """Clear reflected flux field."""
1086
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
1087
+ self.swflux_reflected_vol[i, j, k] = 0.0
1088
+
1089
+ def compute_swflux_vol_with_reflections(
1090
+ self,
1091
+ sw_direct: float,
1092
+ sw_diffuse: float,
1093
+ cos_zenith: float,
1094
+ sun_direction: Tuple[float, float, float],
1095
+ surfaces,
1096
+ surf_outgoing: np.ndarray,
1097
+ lad: Optional[ti.template] = None
1098
+ ):
1099
+ """
1100
+ Compute volumetric shortwave flux including reflected radiation.
1101
+
1102
+ This is a convenience method that combines direct/diffuse computation
1103
+ with reflected radiation from surfaces.
1104
+
1105
+ Args:
1106
+ sw_direct: Direct normal irradiance (W/m²)
1107
+ sw_diffuse: Diffuse horizontal irradiance (W/m²)
1108
+ cos_zenith: Cosine of solar zenith angle
1109
+ sun_direction: Unit vector toward sun (x, y, z)
1110
+ surfaces: Surfaces object with geometry
1111
+ surf_outgoing: Surface outgoing radiation array (W/m²)
1112
+ lad: Optional LAD field for canopy attenuation
1113
+ """
1114
+ # Compute direct + diffuse
1115
+ self.compute_swflux_vol(sw_direct, sw_diffuse, cos_zenith, sun_direction, lad)
1116
+
1117
+ # Compute and add reflected
1118
+ self.compute_reflected_flux_vol(surfaces, surf_outgoing)
1119
+ self._add_reflected_to_total()
1120
+
1121
+ def get_swflux_reflected_vol(self) -> np.ndarray:
1122
+ """Get volumetric reflected SW flux as numpy array (W/m²)."""
1123
+ return self.swflux_reflected_vol.to_numpy()
1124
+
1125
+ def get_swflux_direct_vol(self) -> np.ndarray:
1126
+ """Get volumetric direct SW flux as numpy array (W/m²)."""
1127
+ return self.swflux_direct_vol.to_numpy()
1128
+
1129
+ def get_swflux_diffuse_vol(self) -> np.ndarray:
1130
+ """Get volumetric diffuse SW flux as numpy array (W/m²)."""
1131
+ return self.swflux_diffuse_vol.to_numpy()
1132
+
1133
+ def get_flux_components(self) -> dict:
1134
+ """
1135
+ Get all volumetric flux components as a dictionary.
1136
+
1137
+ Returns:
1138
+ Dictionary with keys:
1139
+ - 'total': Total SW flux (direct + diffuse + reflected if enabled)
1140
+ - 'direct': Direct solar component
1141
+ - 'diffuse': Diffuse sky component
1142
+ - 'reflected': Reflected from surfaces (if computed)
1143
+ - 'skyvf': Sky view factor
1144
+ """
1145
+ return {
1146
+ 'total': self.swflux_vol.to_numpy(),
1147
+ 'direct': self.swflux_direct_vol.to_numpy(),
1148
+ 'diffuse': self.swflux_diffuse_vol.to_numpy(),
1149
+ 'reflected': self.swflux_reflected_vol.to_numpy(),
1150
+ 'skyvf': self.skyvf_vol.to_numpy()
1151
+ }