voxcity 1.0.2__py3-none-any.whl → 1.0.15__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 (41) 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/solar/__init__.py +13 -0
  12. voxcity/simulator_gpu/__init__.py +90 -0
  13. voxcity/simulator_gpu/core.py +322 -0
  14. voxcity/simulator_gpu/domain.py +36 -0
  15. voxcity/simulator_gpu/init_taichi.py +154 -0
  16. voxcity/simulator_gpu/raytracing.py +776 -0
  17. voxcity/simulator_gpu/solar/__init__.py +222 -0
  18. voxcity/simulator_gpu/solar/core.py +66 -0
  19. voxcity/simulator_gpu/solar/csf.py +1249 -0
  20. voxcity/simulator_gpu/solar/domain.py +618 -0
  21. voxcity/simulator_gpu/solar/epw.py +421 -0
  22. voxcity/simulator_gpu/solar/integration.py +4322 -0
  23. voxcity/simulator_gpu/solar/mask.py +459 -0
  24. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  25. voxcity/simulator_gpu/solar/raytracing.py +182 -0
  26. voxcity/simulator_gpu/solar/reflection.py +533 -0
  27. voxcity/simulator_gpu/solar/sky.py +907 -0
  28. voxcity/simulator_gpu/solar/solar.py +337 -0
  29. voxcity/simulator_gpu/solar/svf.py +446 -0
  30. voxcity/simulator_gpu/solar/volumetric.py +2099 -0
  31. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  32. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  33. voxcity/simulator_gpu/visibility/integration.py +808 -0
  34. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  35. voxcity/simulator_gpu/visibility/view.py +944 -0
  36. voxcity/visualizer/renderer.py +2 -1
  37. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/METADATA +16 -53
  38. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/RECORD +41 -16
  39. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/WHEEL +0 -0
  40. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/AUTHORS.rst +0 -0
  41. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,2099 @@
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
+ # ========== Cell-to-Surface View Factor (C2S-VF) Matrix Caching ==========
122
+ # Pre-compute which surfaces each voxel cell can see and their view factors.
123
+ # This makes reflected flux computation O(nnz) instead of O(N_cells * N_surfaces).
124
+ #
125
+ # Stored in sparse COO format:
126
+ # - c2s_cell_idx[i]: Linear cell index (i * ny * nz + j * nz + k)
127
+ # - c2s_surf_idx[i]: Surface index
128
+ # - c2s_vf[i]: View factor (includes geometry + transmissivity)
129
+ #
130
+ # For multi-timestep simulations with reflections, call compute_c2s_matrix()
131
+ # once to pre-compute, then use fast compute_reflected_flux_vol_cached().
132
+
133
+ self._c2s_matrix_cached = False
134
+ self._c2s_nnz = 0
135
+
136
+ # Estimate max non-zeros: assume each cell sees ~50 surfaces on average
137
+ # For a 200x200x50 domain = 2M cells * 50 = 100M entries max
138
+ # Cap at reasonable memory limit (~1.6GB for the 4 arrays)
139
+ n_cells = self.nx * self.ny * self.nz
140
+ estimated_entries = min(n_cells * 50, 100_000_000)
141
+ self._max_c2s_entries = estimated_entries
142
+
143
+ # Sparse COO arrays for C2S-VF matrix (allocated on demand)
144
+ self._c2s_cell_idx = None
145
+ self._c2s_surf_idx = None
146
+ self._c2s_vf = None
147
+ self._c2s_count = None
148
+
149
+ # Pre-allocated surface outgoing field for efficient repeated calls
150
+ self._surf_out_field = None
151
+ self._surf_out_max_size = 0
152
+
153
+ # ========== Cumulative Terrain-Following Accumulation (Optimization) ==========
154
+ # For cumulative simulations, accumulate terrain-following slices directly on GPU
155
+ # instead of transferring full 3D arrays each timestep/patch.
156
+ # This provides 10-15x speedup for cumulative volumetric calculations.
157
+
158
+ # 2D cumulative map for terrain-following irradiance accumulation
159
+ self._cumulative_map = ti.field(dtype=ti.f64, shape=(self.nx, self.ny))
160
+
161
+ # Ground k-levels for terrain-following extraction (set by init_cumulative_accumulation)
162
+ self._ground_k = ti.field(dtype=ti.i32, shape=(self.nx, self.ny))
163
+
164
+ # Height offset for extraction (cells above ground)
165
+ self._height_offset_k = 0
166
+
167
+ # Whether cumulative accumulation is initialized
168
+ self._cumulative_initialized = False
169
+
170
+ # ========== Terrain-Following Cell-to-Surface VF (T2S-VF) Matrix Caching ==========
171
+ # Pre-compute view factors from terrain-following evaluation cells to surfaces.
172
+ # This is for O(nx*ny) cells only (at volumetric_height above ground), not full 3D.
173
+ #
174
+ # Stored in sparse COO format:
175
+ # - t2s_ij_idx[i]: 2D cell index (i * ny + j)
176
+ # - t2s_surf_idx[i]: Surface index
177
+ # - t2s_vf[i]: View factor (includes geometry + transmissivity)
178
+ #
179
+ # For cumulative volumetric simulations with reflections:
180
+ # 1. Call init_cumulative_accumulation() to set ground_k and height_offset
181
+ # 2. Call compute_t2s_matrix() once to pre-compute view factors
182
+ # 3. Use compute_reflected_flux_terrain_cached() for fast O(nnz) reflections
183
+
184
+ self._t2s_matrix_cached = False
185
+ self._t2s_nnz = 0
186
+
187
+ # Estimate max non-zeros: each terrain cell might see ~200 surfaces on average
188
+ # For a 300x300 domain = 90K cells * 200 = 18M entries max
189
+ n_terrain_cells = self.nx * self.ny
190
+ estimated_t2s_entries = min(n_terrain_cells * 200, 50_000_000) # Cap at 50M
191
+ self._max_t2s_entries = estimated_t2s_entries
192
+
193
+ # Sparse COO arrays for T2S-VF matrix (allocated on demand)
194
+ self._t2s_ij_idx = None
195
+ self._t2s_surf_idx = None
196
+ self._t2s_vf = None
197
+ self._t2s_count = None
198
+
199
+ # Parameters used for cached T2S matrix (for validation)
200
+ self._t2s_height_offset_k = -1
201
+
202
+ @ti.kernel
203
+ def _init_azimuth_directions(self):
204
+ """Pre-compute azimuth direction vectors."""
205
+ for iaz in range(self.n_azimuth):
206
+ azimuth = (ti.cast(iaz, ti.f32) + 0.5) * TWO_PI / ti.cast(self.n_azimuth, ti.f32)
207
+ # x = east (sin), y = north (cos)
208
+ self.azim_dir_x[iaz] = ti.sin(azimuth)
209
+ self.azim_dir_y[iaz] = ti.cos(azimuth)
210
+
211
+ @ti.kernel
212
+ def _compute_opaque_top(
213
+ self,
214
+ is_solid: ti.template(),
215
+ lad: ti.template(),
216
+ has_lad: ti.i32
217
+ ):
218
+ """
219
+ Compute the opaque top level for each column.
220
+
221
+ Considers both solid obstacles (buildings) and dense vegetation.
222
+ """
223
+ for i, j in ti.ndrange(self.nx, self.ny):
224
+ # Start with terrain/building top
225
+ top_k = 0
226
+ for k in range(self.nz):
227
+ if is_solid[i, j, k] == 1:
228
+ top_k = k
229
+
230
+ # Check vegetation above solid top (iterate downward from top)
231
+ if has_lad == 1:
232
+ # Taichi doesn't support 3-arg range with step, so we iterate forward
233
+ # and compute the reversed index
234
+ num_levels = self.nz - 1 - top_k
235
+ for k_rev in range(num_levels):
236
+ k = self.nz - 1 - k_rev
237
+ if lad[i, j, k] >= self.min_opaque_lad:
238
+ if k > top_k:
239
+ top_k = k
240
+ break
241
+
242
+ self.opaque_top[i, j] = top_k
243
+
244
+ @ti.func
245
+ def _trace_horizon_single_azimuth(
246
+ self,
247
+ i_start: ti.i32,
248
+ j_start: ti.i32,
249
+ k_level: ti.i32,
250
+ dir_x: ti.f32,
251
+ dir_y: ti.f32,
252
+ is_solid: ti.template()
253
+ ) -> ti.f32:
254
+ """
255
+ Trace horizon in a single azimuth direction from a point.
256
+
257
+ Returns the tangent of the horizon elevation angle.
258
+ A higher value means more sky is blocked.
259
+ Only considers solid obstacles (buildings), not vegetation.
260
+ """
261
+ # Starting position (center of grid cell)
262
+ x0 = (ti.cast(i_start, ti.f32) + 0.5) * self.dx
263
+ y0 = (ti.cast(j_start, ti.f32) + 0.5) * self.dy
264
+ z0 = (ti.cast(k_level, ti.f32) + 0.5) * self.dz
265
+
266
+ max_horizon_tan = -1e10 # Start below horizon
267
+
268
+ # Step along the direction
269
+ step_dist = ti.min(self.dx, self.dy)
270
+ n_steps = ti.cast(self.max_dist / step_dist, ti.i32) + 1
271
+
272
+ for step in range(1, n_steps):
273
+ dist = ti.cast(step, ti.f32) * step_dist
274
+
275
+ x = x0 + dir_x * dist
276
+ y = y0 + dir_y * dist
277
+
278
+ # Check if out of domain
279
+ if x < 0.0 or x >= self.nx * self.dx:
280
+ break
281
+ if y < 0.0 or y >= self.ny * self.dy:
282
+ break
283
+
284
+ # Grid indices
285
+ ix = ti.cast(ti.floor(x / self.dx), ti.i32)
286
+ iy = ti.cast(ti.floor(y / self.dy), ti.i32)
287
+
288
+ ix = ti.max(0, ti.min(self.nx - 1, ix))
289
+ iy = ti.max(0, ti.min(self.ny - 1, iy))
290
+
291
+ # Find solid top at this location (not opaque_top which includes vegetation)
292
+ solid_top_k = 0
293
+ for kk in range(self.nz):
294
+ if is_solid[ix, iy, kk] == 1:
295
+ solid_top_k = kk
296
+
297
+ obstacle_z = (ti.cast(solid_top_k, ti.f32) + 1.0) * self.dz # Top of obstacle
298
+
299
+ # Compute elevation angle tangent to obstacle top
300
+ dz = obstacle_z - z0
301
+ horizon_tan = dz / dist
302
+
303
+ if horizon_tan > max_horizon_tan:
304
+ max_horizon_tan = horizon_tan
305
+
306
+ return max_horizon_tan
307
+
308
+ @ti.func
309
+ def _trace_transmissivity_zenith(
310
+ self,
311
+ i: ti.i32,
312
+ j: ti.i32,
313
+ k: ti.i32,
314
+ zenith_angle: ti.f32,
315
+ azimuth: ti.f32,
316
+ is_solid: ti.template(),
317
+ lad: ti.template(),
318
+ has_lad: ti.i32
319
+ ) -> ti.f32:
320
+ """
321
+ Trace transmissivity from a point toward sky at given zenith/azimuth.
322
+
323
+ Returns transmissivity [0, 1] accounting for:
324
+ - Solid obstacles (transmissivity = 0)
325
+ - Vegetation (Beer-Lambert attenuation)
326
+
327
+ Args:
328
+ i, j, k: Starting grid cell
329
+ zenith_angle: Angle from vertical (0 = straight up)
330
+ azimuth: Horizontal angle (0 = north, π/2 = east)
331
+ is_solid: Solid obstacle field
332
+ lad: Leaf Area Density field
333
+ has_lad: Whether LAD field exists
334
+ """
335
+ # Direction vector (pointing toward sky)
336
+ sin_zen = ti.sin(zenith_angle)
337
+ cos_zen = ti.cos(zenith_angle)
338
+ dir_x = sin_zen * ti.sin(azimuth) # East component
339
+ dir_y = sin_zen * ti.cos(azimuth) # North component
340
+ dir_z = cos_zen # Up component
341
+
342
+ # Starting position
343
+ x = (ti.cast(i, ti.f32) + 0.5) * self.dx
344
+ y = (ti.cast(j, ti.f32) + 0.5) * self.dy
345
+ z = (ti.cast(k, ti.f32) + 0.5) * self.dz
346
+
347
+ # Accumulated LAD path length
348
+ cumulative_lad_path = 0.0
349
+ transmissivity = 1.0
350
+
351
+ # Step size based on grid resolution
352
+ step_dist = ti.min(self.dx, ti.min(self.dy, self.dz)) * 0.5
353
+ max_steps = ti.cast(self.max_dist / step_dist, ti.i32) + 1
354
+
355
+ for step in range(1, max_steps):
356
+ dist = ti.cast(step, ti.f32) * step_dist
357
+
358
+ # Current position
359
+ cx = x + dir_x * dist
360
+ cy = y + dir_y * dist
361
+ cz = z + dir_z * dist
362
+
363
+ # Check bounds
364
+ if cx < 0.0 or cx >= self.nx * self.dx:
365
+ break
366
+ if cy < 0.0 or cy >= self.ny * self.dy:
367
+ break
368
+ if cz < 0.0 or cz >= self.nz * self.dz:
369
+ break # Exited domain through top - reached sky
370
+
371
+ # Grid indices
372
+ ix = ti.cast(ti.floor(cx / self.dx), ti.i32)
373
+ iy = ti.cast(ti.floor(cy / self.dy), ti.i32)
374
+ iz = ti.cast(ti.floor(cz / self.dz), ti.i32)
375
+
376
+ ix = ti.max(0, ti.min(self.nx - 1, ix))
377
+ iy = ti.max(0, ti.min(self.ny - 1, iy))
378
+ iz = ti.max(0, ti.min(self.nz - 1, iz))
379
+
380
+ # Check for solid obstacle - completely blocks
381
+ if is_solid[ix, iy, iz] == 1:
382
+ transmissivity = 0.0
383
+ break
384
+
385
+ # Accumulate LAD for Beer-Lambert
386
+ if has_lad == 1:
387
+ cell_lad = lad[ix, iy, iz]
388
+ if cell_lad > 0.0:
389
+ cumulative_lad_path += cell_lad * step_dist
390
+
391
+ # Apply Beer-Lambert if passed through vegetation
392
+ if transmissivity > 0.0 and cumulative_lad_path > 0.0:
393
+ transmissivity = ti.exp(-EXT_COEF * cumulative_lad_path)
394
+
395
+ return transmissivity
396
+
397
+ @ti.kernel
398
+ def _compute_skyvf_vol_kernel(
399
+ self,
400
+ is_solid: ti.template()
401
+ ):
402
+ """
403
+ Compute volumetric sky view factor for all grid cells.
404
+
405
+ For each cell, traces horizons in all azimuth directions
406
+ and integrates the visible sky fraction.
407
+ This version only considers solid obstacles (no vegetation).
408
+ """
409
+ n_az_f = ti.cast(self.n_azimuth, ti.f32)
410
+
411
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
412
+ # Skip cells inside solid obstacles
413
+ if is_solid[i, j, k] == 1:
414
+ self.skyvf_vol[i, j, k] = 0.0
415
+ continue
416
+
417
+ # Integrate sky view over all azimuths
418
+ total_svf = 0.0
419
+
420
+ for iaz in range(self.n_azimuth):
421
+ dir_x = self.azim_dir_x[iaz]
422
+ dir_y = self.azim_dir_y[iaz]
423
+
424
+ # Get horizon tangent in this direction (solid obstacles only)
425
+ horizon_tan = self._trace_horizon_single_azimuth(
426
+ i, j, k, dir_x, dir_y, is_solid
427
+ )
428
+
429
+ # Convert tangent to elevation angle, then to cos(zenith)
430
+ cos_zen = 0.0
431
+ if horizon_tan >= 0.0:
432
+ cos_zen = horizon_tan / ti.sqrt(1.0 + horizon_tan * horizon_tan)
433
+
434
+ # Sky view contribution: (1 - cos_zenith_of_horizon)
435
+ svf_contrib = (1.0 - cos_zen)
436
+ total_svf += svf_contrib
437
+
438
+ # Normalize: divide by number of azimuths and factor of 2 for hemisphere
439
+ self.skyvf_vol[i, j, k] = total_svf / (2.0 * n_az_f)
440
+
441
+ @ti.kernel
442
+ def _compute_skyvf_vol_with_lad_kernel(
443
+ self,
444
+ is_solid: ti.template(),
445
+ lad: ti.template(),
446
+ n_zenith: ti.i32
447
+ ):
448
+ """
449
+ Compute volumetric sky view factor with vegetation transmissivity.
450
+
451
+ Integrates over hemisphere using discrete zenith and azimuth angles,
452
+ applying Beer-Lambert attenuation through vegetation.
453
+
454
+ SVF = (1/2π) ∫∫ τ(θ,φ) cos(θ) sin(θ) dθ dφ
455
+
456
+ where τ is transmissivity through vegetation/obstacles.
457
+ """
458
+ n_az_f = ti.cast(self.n_azimuth, ti.f32)
459
+ n_zen_f = ti.cast(n_zenith, ti.f32)
460
+
461
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
462
+ # Skip cells inside solid obstacles
463
+ if is_solid[i, j, k] == 1:
464
+ self.skyvf_vol[i, j, k] = 0.0
465
+ continue
466
+
467
+ # Integrate over hemisphere
468
+ # SVF = (1/2π) ∫₀^(π/2) ∫₀^(2π) τ(θ,φ) cos(θ) sin(θ) dθ dφ
469
+ # Discretized with uniform spacing
470
+ total_weighted_trans = 0.0
471
+ total_weight = 0.0
472
+
473
+ for izen in range(n_zenith):
474
+ # Zenith angle from 0 (up) to π/2 (horizontal)
475
+ # Use midpoint of each bin
476
+ zenith = (ti.cast(izen, ti.f32) + 0.5) * (PI / 2.0) / n_zen_f
477
+
478
+ # Weight: cos(θ) * sin(θ) * dθ
479
+ # This accounts for solid angle and projection
480
+ cos_zen = ti.cos(zenith)
481
+ sin_zen = ti.sin(zenith)
482
+ weight = cos_zen * sin_zen
483
+
484
+ for iaz in range(self.n_azimuth):
485
+ azimuth = (ti.cast(iaz, ti.f32) + 0.5) * TWO_PI / n_az_f
486
+
487
+ # Trace transmissivity toward sky
488
+ trans = self._trace_transmissivity_zenith(
489
+ i, j, k, zenith, azimuth, is_solid, lad, 1
490
+ )
491
+
492
+ total_weighted_trans += trans * weight
493
+ total_weight += weight
494
+
495
+ # Normalize by total weight (integral of cos*sin over hemisphere = 0.5)
496
+ if total_weight > 0.0:
497
+ self.skyvf_vol[i, j, k] = total_weighted_trans / total_weight
498
+ else:
499
+ self.skyvf_vol[i, j, k] = 0.0
500
+
501
+ @ti.kernel
502
+ def _compute_shadow_top_kernel(
503
+ self,
504
+ sun_dir: ti.types.vector(3, ti.f32),
505
+ is_solid: ti.template()
506
+ ):
507
+ """
508
+ Compute shadow top for current solar direction.
509
+
510
+ Shadow top is the highest grid level that is in shadow
511
+ (direct solar radiation blocked).
512
+ """
513
+ # Horizontal direction magnitude
514
+ horiz_mag = ti.sqrt(sun_dir[0]**2 + sun_dir[1]**2)
515
+
516
+ # Tangent of solar elevation
517
+ solar_tan = 1e10 # Default: sun near zenith
518
+ if horiz_mag > 1e-6:
519
+ solar_tan = sun_dir[2] / horiz_mag
520
+
521
+ # Horizontal direction components (normalized)
522
+ dir_x = 0.0
523
+ dir_y = 1.0
524
+ if horiz_mag > 1e-6:
525
+ dir_x = sun_dir[0] / horiz_mag
526
+ dir_y = sun_dir[1] / horiz_mag
527
+
528
+ for i, j in ti.ndrange(self.nx, self.ny):
529
+ # Start from opaque top
530
+ shadow_k = self.opaque_top[i, j]
531
+
532
+ # Trace upward to find where horizon drops below solar elevation
533
+ for k in range(self.opaque_top[i, j] + 1, self.nz):
534
+ # Get horizon in sun direction
535
+ horizon_tan = self._trace_horizon_single_azimuth(
536
+ i, j, k, dir_x, dir_y, is_solid
537
+ )
538
+
539
+ # If horizon is below sun, this level is sunlit
540
+ if horizon_tan < solar_tan:
541
+ break
542
+
543
+ shadow_k = k
544
+
545
+ self.shadow_top[i, j] = shadow_k
546
+
547
+ @ti.kernel
548
+ def _compute_swflux_vol_kernel(
549
+ self,
550
+ sw_direct: ti.f32,
551
+ sw_diffuse: ti.f32,
552
+ cos_zenith: ti.f32,
553
+ is_solid: ti.template()
554
+ ):
555
+ """
556
+ Compute volumetric shortwave flux at each grid cell.
557
+
558
+ The flux represents average irradiance onto an imaginary sphere,
559
+ combining direct and diffuse components.
560
+ Also stores separate direct and diffuse components.
561
+ """
562
+ # Sun direct factor (convert horizontal to normal)
563
+ sun_factor = 1.0
564
+ if cos_zenith > 0.0262: # min_stable_coszen
565
+ sun_factor = 1.0 / cos_zenith
566
+
567
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
568
+ # Skip solid cells
569
+ if is_solid[i, j, k] == 1:
570
+ self.swflux_vol[i, j, k] = 0.0
571
+ self.swflux_direct_vol[i, j, k] = 0.0
572
+ self.swflux_diffuse_vol[i, j, k] = 0.0
573
+ continue
574
+
575
+ direct_flux = 0.0
576
+ diffuse_flux = 0.0
577
+
578
+ # Direct component: only above shadow level
579
+ if k > self.shadow_top[i, j] and cos_zenith > 0.0:
580
+ # For a sphere, the ratio of projected area to surface area is 1/4
581
+ # Direct flux onto sphere = sw_direct * sun_factor * 0.25
582
+ direct_flux = sw_direct * sun_factor * 0.25
583
+
584
+ # Diffuse component: weighted by volumetric sky view factor
585
+ # For a sphere receiving isotropic diffuse radiation:
586
+ # diffuse_flux = sw_diffuse * skyvf_vol
587
+ diffuse_flux = sw_diffuse * self.skyvf_vol[i, j, k]
588
+
589
+ self.swflux_direct_vol[i, j, k] = direct_flux
590
+ self.swflux_diffuse_vol[i, j, k] = diffuse_flux
591
+ self.swflux_vol[i, j, k] = direct_flux + diffuse_flux
592
+
593
+ @ti.kernel
594
+ def _compute_swflux_vol_with_lad_kernel(
595
+ self,
596
+ sw_direct: ti.f32,
597
+ sw_diffuse: ti.f32,
598
+ cos_zenith: ti.f32,
599
+ sun_dir: ti.types.vector(3, ti.f32),
600
+ is_solid: ti.template(),
601
+ lad: ti.template()
602
+ ):
603
+ """
604
+ Compute volumetric shortwave flux with LAD attenuation.
605
+
606
+ The flux is attenuated through vegetation using Beer-Lambert law.
607
+ Direct radiation is traced toward the sun with proper attenuation.
608
+ """
609
+ # Sun direct factor (convert horizontal irradiance to normal)
610
+ sun_factor = 1.0
611
+ if cos_zenith > 0.0262:
612
+ sun_factor = 1.0 / cos_zenith
613
+
614
+ # Compute solar zenith angle for transmissivity tracing
615
+ solar_zenith = ti.acos(ti.max(-1.0, ti.min(1.0, cos_zenith)))
616
+
617
+ # Compute solar azimuth from sun direction
618
+ solar_azimuth = ti.atan2(sun_dir[0], sun_dir[1]) # atan2(east, north)
619
+
620
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
621
+ # Skip solid cells
622
+ if is_solid[i, j, k] == 1:
623
+ self.swflux_vol[i, j, k] = 0.0
624
+ self.swflux_direct_vol[i, j, k] = 0.0
625
+ self.swflux_diffuse_vol[i, j, k] = 0.0
626
+ continue
627
+
628
+ direct_flux = 0.0
629
+ diffuse_flux = 0.0
630
+
631
+ # Direct component with full 3D transmissivity tracing
632
+ if cos_zenith > 0.0262: # Sun is up
633
+ # Trace transmissivity toward sun through vegetation and obstacles
634
+ trans = self._trace_transmissivity_zenith(
635
+ i, j, k, solar_zenith, solar_azimuth, is_solid, lad, 1
636
+ )
637
+
638
+ # Direct flux onto sphere = sw_direct * sun_factor * 0.25 * transmissivity
639
+ direct_flux = sw_direct * sun_factor * 0.25 * trans
640
+
641
+ # Diffuse component: SVF already accounts for vegetation attenuation
642
+ # (computed by _compute_skyvf_vol_with_lad_kernel)
643
+ diffuse_flux = sw_diffuse * self.skyvf_vol[i, j, k]
644
+
645
+ self.swflux_direct_vol[i, j, k] = direct_flux
646
+ self.swflux_diffuse_vol[i, j, k] = diffuse_flux
647
+ self.swflux_vol[i, j, k] = direct_flux + diffuse_flux
648
+
649
+ @ti.func
650
+ def _compute_canopy_transmissivity(
651
+ self,
652
+ i: ti.i32,
653
+ j: ti.i32,
654
+ k: ti.i32,
655
+ sun_dir: ti.types.vector(3, ti.f32),
656
+ is_solid: ti.template(),
657
+ lad: ti.template()
658
+ ) -> ti.f32:
659
+ """
660
+ Compute transmissivity through canopy from point (i,j,k) toward sun.
661
+
662
+ Uses simplified vertical integration (for efficiency).
663
+ Full 3D ray tracing is done in CSF calculator.
664
+ """
665
+ cumulative_lad_path = 0.0
666
+ blocked = 0
667
+
668
+ # Integrate upward through canopy
669
+ # Simplified: just sum LAD in vertical column above
670
+ # More accurate would trace along sun direction
671
+ for kk in range(k + 1, self.nz):
672
+ if blocked == 0:
673
+ if is_solid[i, j, kk] == 1:
674
+ # Hit solid - mark as fully blocked
675
+ blocked = 1
676
+ cumulative_lad_path = 1e10 # Large value for zero transmissivity
677
+ else:
678
+ cell_lad = lad[i, j, kk]
679
+ if cell_lad > 0.0:
680
+ # Path length through cell (vertical)
681
+ # For non-vertical sun, would need angle correction
682
+ path_len = self.dz / ti.max(0.1, sun_dir[2]) # Avoid division by zero
683
+ cumulative_lad_path += cell_lad * path_len
684
+
685
+ # Beer-Lambert transmissivity
686
+ return ti.exp(-EXT_COEF * cumulative_lad_path)
687
+
688
+ def compute_opaque_top(self):
689
+ """
690
+ Compute opaque top levels considering buildings and vegetation.
691
+ """
692
+ has_lad = 1 if self.domain.lad is not None else 0
693
+
694
+ if has_lad:
695
+ self._compute_opaque_top(
696
+ self.domain.is_solid,
697
+ self.domain.lad,
698
+ has_lad
699
+ )
700
+ else:
701
+ self._compute_opaque_top_no_lad(self.domain.is_solid)
702
+
703
+ @ti.kernel
704
+ def _compute_opaque_top_no_lad(self, is_solid: ti.template()):
705
+ """Compute opaque top without vegetation."""
706
+ for i, j in ti.ndrange(self.nx, self.ny):
707
+ top_k = 0
708
+ for k in range(self.nz):
709
+ if is_solid[i, j, k] == 1:
710
+ top_k = k
711
+ self.opaque_top[i, j] = top_k
712
+
713
+ def compute_skyvf_vol(self, n_zenith: int = 9):
714
+ """
715
+ Compute volumetric sky view factors for all grid cells.
716
+
717
+ This is computationally expensive - call once per domain setup
718
+ or when geometry changes.
719
+
720
+ Args:
721
+ n_zenith: Number of zenith angle divisions for hemisphere integration.
722
+ Higher values give more accurate results but slower computation.
723
+ Default 9 gives ~10° resolution.
724
+ """
725
+ print("Computing opaque top levels...")
726
+ self.compute_opaque_top()
727
+
728
+ has_lad = self.domain.lad is not None
729
+
730
+ if has_lad:
731
+ print(f"Computing volumetric sky view factors with vegetation...")
732
+ print(f" ({self.n_azimuth} azimuths × {n_zenith} zenith angles)")
733
+ self._compute_skyvf_vol_with_lad_kernel(
734
+ self.domain.is_solid,
735
+ self.domain.lad,
736
+ n_zenith
737
+ )
738
+ else:
739
+ print(f"Computing volumetric sky view factors ({self.n_azimuth} azimuths)...")
740
+ self._compute_skyvf_vol_kernel(self.domain.is_solid)
741
+
742
+ self._skyvf_computed = True
743
+ print("Volumetric SVF computation complete.")
744
+
745
+ def compute_shadow_top(self, sun_direction: Tuple[float, float, float]):
746
+ """
747
+ Compute shadow top for a given solar direction.
748
+
749
+ Args:
750
+ sun_direction: Unit vector pointing toward sun (x, y, z)
751
+ """
752
+ if not self._skyvf_computed:
753
+ self.compute_opaque_top()
754
+
755
+ sun_dir = ti.Vector([sun_direction[0], sun_direction[1], sun_direction[2]])
756
+ self._compute_shadow_top_kernel(sun_dir, self.domain.is_solid)
757
+
758
+ def compute_swflux_vol(
759
+ self,
760
+ sw_direct: float,
761
+ sw_diffuse: float,
762
+ cos_zenith: float,
763
+ sun_direction: Tuple[float, float, float],
764
+ lad: Optional[ti.template] = None
765
+ ):
766
+ """
767
+ Compute volumetric shortwave flux for all grid cells.
768
+
769
+ Args:
770
+ sw_direct: Direct normal irradiance (W/m²)
771
+ sw_diffuse: Diffuse horizontal irradiance (W/m²)
772
+ cos_zenith: Cosine of solar zenith angle
773
+ sun_direction: Unit vector toward sun (x, y, z)
774
+ lad: Optional LAD field for canopy attenuation
775
+ """
776
+ if not self._skyvf_computed:
777
+ print("Warning: Volumetric SVF not computed, computing now...")
778
+ self.compute_skyvf_vol()
779
+
780
+ # Compute shadow heights for current sun position
781
+ self.compute_shadow_top(sun_direction)
782
+
783
+ # Compute flux (with or without LAD attenuation)
784
+ if lad is not None:
785
+ sun_dir = ti.Vector([sun_direction[0], sun_direction[1], sun_direction[2]])
786
+ self._compute_swflux_vol_with_lad_kernel(
787
+ sw_direct,
788
+ sw_diffuse,
789
+ cos_zenith,
790
+ sun_dir,
791
+ self.domain.is_solid,
792
+ lad
793
+ )
794
+ else:
795
+ self._compute_swflux_vol_kernel(
796
+ sw_direct,
797
+ sw_diffuse,
798
+ cos_zenith,
799
+ self.domain.is_solid
800
+ )
801
+
802
+ def get_skyvf_vol(self) -> np.ndarray:
803
+ """Get volumetric sky view factor as numpy array."""
804
+ return self.skyvf_vol.to_numpy()
805
+
806
+ def get_swflux_vol(self) -> np.ndarray:
807
+ """Get volumetric SW flux as numpy array (W/m²)."""
808
+ return self.swflux_vol.to_numpy()
809
+
810
+ def get_shadow_top(self) -> np.ndarray:
811
+ """Get shadow top indices as numpy array."""
812
+ return self.shadow_top.to_numpy()
813
+
814
+ def get_opaque_top(self) -> np.ndarray:
815
+ """Get opaque top indices as numpy array."""
816
+ return self.opaque_top.to_numpy()
817
+
818
+ def get_shadow_mask_3d(self) -> np.ndarray:
819
+ """
820
+ Get 3D shadow mask (1=shadowed, 0=sunlit).
821
+
822
+ Returns:
823
+ 3D boolean array where True indicates shadowed cells
824
+ """
825
+ shadow_top = self.shadow_top.to_numpy()
826
+ is_solid = self.domain.is_solid.to_numpy()
827
+
828
+ mask = np.zeros((self.nx, self.ny, self.nz), dtype=bool)
829
+
830
+ for i in range(self.nx):
831
+ for j in range(self.ny):
832
+ k_shadow = shadow_top[i, j]
833
+ mask[i, j, :k_shadow+1] = True
834
+
835
+ # Also mark solid cells
836
+ mask[is_solid == 1] = True
837
+
838
+ return mask
839
+
840
+ def get_horizontal_slice(self, k: int, field: str = 'swflux') -> np.ndarray:
841
+ """
842
+ Get horizontal slice of a volumetric field.
843
+
844
+ Args:
845
+ k: Vertical level index
846
+ field: 'swflux' or 'skyvf'
847
+
848
+ Returns:
849
+ 2D array at level k
850
+ """
851
+ if field == 'swflux':
852
+ return self.swflux_vol.to_numpy()[:, :, k]
853
+ elif field == 'skyvf':
854
+ return self.skyvf_vol.to_numpy()[:, :, k]
855
+ else:
856
+ raise ValueError(f"Unknown field: {field}")
857
+
858
+ def get_vertical_slice(
859
+ self,
860
+ axis: str,
861
+ index: int,
862
+ field: str = 'swflux'
863
+ ) -> np.ndarray:
864
+ """
865
+ Get vertical slice of a volumetric field.
866
+
867
+ Args:
868
+ axis: 'x' or 'y'
869
+ index: Index along the axis
870
+ field: 'swflux' or 'skyvf'
871
+
872
+ Returns:
873
+ 2D array (horizontal_coord, z)
874
+ """
875
+ if field == 'swflux':
876
+ data = self.swflux_vol.to_numpy()
877
+ elif field == 'skyvf':
878
+ data = self.skyvf_vol.to_numpy()
879
+ else:
880
+ raise ValueError(f"Unknown field: {field}")
881
+
882
+ if axis == 'x':
883
+ return data[index, :, :]
884
+ elif axis == 'y':
885
+ return data[:, index, :]
886
+ else:
887
+ raise ValueError(f"Unknown axis: {axis}")
888
+
889
+ def set_mode(self, mode: Union[VolumetricFluxMode, str]):
890
+ """
891
+ Set the volumetric flux computation mode.
892
+
893
+ Args:
894
+ mode: Either a VolumetricFluxMode enum or string:
895
+ 'direct_diffuse' - Only direct + diffuse sky radiation
896
+ 'with_reflections' - Include reflected radiation from surfaces
897
+ """
898
+ if isinstance(mode, str):
899
+ mode = VolumetricFluxMode(mode)
900
+ self.mode = mode
901
+
902
+ @ti.func
903
+ def _trace_transmissivity_to_surface(
904
+ self,
905
+ i: ti.i32,
906
+ j: ti.i32,
907
+ k: ti.i32,
908
+ surf_x: ti.f32,
909
+ surf_y: ti.f32,
910
+ surf_z: ti.f32,
911
+ surf_nx: ti.f32,
912
+ surf_ny: ti.f32,
913
+ surf_nz: ti.f32,
914
+ is_solid: ti.template(),
915
+ lad: ti.template(),
916
+ has_lad: ti.i32
917
+ ) -> ti.f32:
918
+ """
919
+ Trace transmissivity from grid cell (i,j,k) to a surface element.
920
+
921
+ Returns transmissivity [0, 1] accounting for:
922
+ - Solid obstacles (transmissivity = 0)
923
+ - Vegetation (Beer-Lambert attenuation)
924
+ - Visibility check (normal pointing toward cell)
925
+
926
+ Args:
927
+ i, j, k: Grid cell indices
928
+ surf_x, surf_y, surf_z: Surface center position
929
+ surf_nx, surf_ny, surf_nz: Surface normal vector
930
+ is_solid: Solid obstacle field
931
+ lad: Leaf Area Density field
932
+ has_lad: Whether LAD field exists
933
+ """
934
+ # Cell center position
935
+ cell_x = (ti.cast(i, ti.f32) + 0.5) * self.dx
936
+ cell_y = (ti.cast(j, ti.f32) + 0.5) * self.dy
937
+ cell_z = (ti.cast(k, ti.f32) + 0.5) * self.dz
938
+
939
+ # Direction from surface to cell
940
+ dx = cell_x - surf_x
941
+ dy = cell_y - surf_y
942
+ dz = cell_z - surf_z
943
+ dist = ti.sqrt(dx*dx + dy*dy + dz*dz)
944
+
945
+ transmissivity = 0.0
946
+
947
+ if dist > 0.01: # Avoid self-intersection
948
+ # Normalize direction
949
+ dir_x = dx / dist
950
+ dir_y = dy / dist
951
+ dir_z = dz / dist
952
+
953
+ # Check if surface faces the cell (dot product with normal > 0)
954
+ cos_angle = dir_x * surf_nx + dir_y * surf_ny + dir_z * surf_nz
955
+
956
+ if cos_angle > 0.0:
957
+ transmissivity = 1.0
958
+ cumulative_lad_path = 0.0
959
+
960
+ # Step along the ray from surface to cell
961
+ step_dist = ti.min(self.dx, ti.min(self.dy, self.dz)) * 0.5
962
+ n_steps = ti.cast(dist / step_dist, ti.i32) + 1
963
+
964
+ for step in range(1, n_steps):
965
+ t = ti.cast(step, ti.f32) * step_dist
966
+ if t >= dist:
967
+ break
968
+
969
+ # Current position along ray
970
+ cx = surf_x + dir_x * t
971
+ cy = surf_y + dir_y * t
972
+ cz = surf_z + dir_z * t
973
+
974
+ # Check bounds
975
+ if cx < 0.0 or cx >= self.nx * self.dx:
976
+ break
977
+ if cy < 0.0 or cy >= self.ny * self.dy:
978
+ break
979
+ if cz < 0.0 or cz >= self.nz * self.dz:
980
+ break
981
+
982
+ # Grid indices
983
+ ix = ti.cast(ti.floor(cx / self.dx), ti.i32)
984
+ iy = ti.cast(ti.floor(cy / self.dy), ti.i32)
985
+ iz = ti.cast(ti.floor(cz / self.dz), ti.i32)
986
+
987
+ ix = ti.max(0, ti.min(self.nx - 1, ix))
988
+ iy = ti.max(0, ti.min(self.ny - 1, iy))
989
+ iz = ti.max(0, ti.min(self.nz - 1, iz))
990
+
991
+ # Check for solid obstacle - blocks completely
992
+ if is_solid[ix, iy, iz] == 1:
993
+ transmissivity = 0.0
994
+ break
995
+
996
+ # Accumulate LAD for Beer-Lambert
997
+ if has_lad == 1:
998
+ cell_lad = lad[ix, iy, iz]
999
+ if cell_lad > 0.0:
1000
+ cumulative_lad_path += cell_lad * step_dist
1001
+
1002
+ # Apply Beer-Lambert attenuation
1003
+ if transmissivity > 0.0 and cumulative_lad_path > 0.0:
1004
+ transmissivity = ti.exp(-EXT_COEF * cumulative_lad_path)
1005
+
1006
+ # Apply geometric factor: cos(angle) / distance^2
1007
+ # Normalized to produce flux in W/m²
1008
+ transmissivity *= cos_angle
1009
+
1010
+ return transmissivity
1011
+
1012
+ @ti.kernel
1013
+ def _compute_reflected_flux_kernel(
1014
+ self,
1015
+ n_surfaces: ti.i32,
1016
+ surf_center: ti.template(),
1017
+ surf_normal: ti.template(),
1018
+ surf_area: ti.template(),
1019
+ surf_outgoing: ti.template(),
1020
+ is_solid: ti.template(),
1021
+ lad: ti.template(),
1022
+ has_lad: ti.i32
1023
+ ):
1024
+ """
1025
+ Compute volumetric reflected flux from surface outgoing radiation.
1026
+
1027
+ For each grid cell, integrates reflected radiation from all visible
1028
+ surfaces weighted by view factor and transmissivity.
1029
+
1030
+ Uses a max distance cutoff for performance - view factor drops as 1/d²,
1031
+ so distant surfaces contribute negligibly.
1032
+
1033
+ Args:
1034
+ n_surfaces: Number of surface elements
1035
+ surf_center: Surface center positions (n_surfaces, 3)
1036
+ surf_normal: Surface normal vectors (n_surfaces, 3)
1037
+ surf_area: Surface areas (n_surfaces,)
1038
+ surf_outgoing: Surface outgoing radiation in W/m² (n_surfaces,)
1039
+ is_solid: Solid obstacle field
1040
+ lad: Leaf Area Density field
1041
+ has_lad: Whether LAD field exists
1042
+ """
1043
+ # For a sphere at each grid cell, reflected flux is:
1044
+ # flux = Σ (surfout * area * transmissivity * cos_angle) / (4 * π * dist²)
1045
+ # The factor 0.25 accounts for sphere geometry (projected area / surface area)
1046
+
1047
+ # Maximum distance for reflections (meters). Beyond this, VF is negligible.
1048
+ # At 30m with 1m² area, VF contribution is ~1/(π*900) ≈ 0.035%
1049
+ max_dist_sq = 900.0 # 30m max distance
1050
+
1051
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
1052
+ # Skip solid cells
1053
+ if is_solid[i, j, k] == 1:
1054
+ self.swflux_reflected_vol[i, j, k] = 0.0
1055
+ continue
1056
+
1057
+ cell_x = (ti.cast(i, ti.f32) + 0.5) * self.dx
1058
+ cell_y = (ti.cast(j, ti.f32) + 0.5) * self.dy
1059
+ cell_z = (ti.cast(k, ti.f32) + 0.5) * self.dz
1060
+
1061
+ total_reflected = 0.0
1062
+
1063
+ for surf_idx in range(n_surfaces):
1064
+ outgoing = surf_outgoing[surf_idx]
1065
+
1066
+ # Skip surfaces with negligible outgoing radiation
1067
+ if outgoing > 0.1: # W/m² threshold
1068
+ surf_x = surf_center[surf_idx][0]
1069
+ surf_y = surf_center[surf_idx][1]
1070
+ surf_z = surf_center[surf_idx][2]
1071
+
1072
+ # Distance to surface - early exit for distant surfaces
1073
+ dx = cell_x - surf_x
1074
+ dy = cell_y - surf_y
1075
+ dz = cell_z - surf_z
1076
+ dist_sq = dx*dx + dy*dy + dz*dz
1077
+
1078
+ # Skip if beyond max distance or too close
1079
+ if dist_sq > 0.01 and dist_sq < max_dist_sq:
1080
+ surf_nx = surf_normal[surf_idx][0]
1081
+ surf_ny = surf_normal[surf_idx][1]
1082
+ surf_nz = surf_normal[surf_idx][2]
1083
+ area = surf_area[surf_idx]
1084
+
1085
+ dist = ti.sqrt(dist_sq)
1086
+
1087
+ # Direction from surface to cell (normalized)
1088
+ dir_x = dx / dist
1089
+ dir_y = dy / dist
1090
+ dir_z = dz / dist
1091
+
1092
+ # Cosine of angle between normal and direction
1093
+ cos_angle = dir_x * surf_nx + dir_y * surf_ny + dir_z * surf_nz
1094
+
1095
+ if cos_angle > 0.0: # Surface faces the cell
1096
+ # Get transmissivity through vegetation/obstacles
1097
+ trans = self._trace_transmissivity_to_surface(
1098
+ i, j, k, surf_x, surf_y, surf_z,
1099
+ surf_nx, surf_ny, surf_nz,
1100
+ is_solid, lad, has_lad
1101
+ )
1102
+
1103
+ if trans > 0.0:
1104
+ # View factor contribution: (A * cos_θ) / (π * d²)
1105
+ # For omnidirectional sphere: multiply by 0.25
1106
+ vf = area * cos_angle / (PI * dist_sq)
1107
+ contribution = outgoing * vf * trans * 0.25
1108
+ total_reflected += contribution
1109
+
1110
+ self.swflux_reflected_vol[i, j, k] = total_reflected
1111
+
1112
+ def compute_reflected_flux_vol(
1113
+ self,
1114
+ surfaces,
1115
+ surf_outgoing: np.ndarray
1116
+ ):
1117
+ """
1118
+ Compute volumetric reflected flux from surface outgoing radiation.
1119
+
1120
+ This propagates reflected radiation from surfaces into the 3D volume.
1121
+ Should be called after surface reflection calculations are complete.
1122
+
1123
+ NOTE: This is O(N_cells * N_surfaces) and can be slow for large domains.
1124
+ For repeated calls, use compute_c2s_matrix() once followed by
1125
+ compute_reflected_flux_vol_cached() for O(nnz) computation.
1126
+
1127
+ Args:
1128
+ surfaces: Surfaces object with geometry (center, normal, area)
1129
+ surf_outgoing: Array of surface outgoing radiation (W/m²)
1130
+ Shape: (n_surfaces,)
1131
+ """
1132
+ n_surfaces = surfaces.n_surfaces[None]
1133
+
1134
+ if n_surfaces == 0:
1135
+ return
1136
+
1137
+ # Re-use pre-allocated field if available, otherwise create temporary
1138
+ if self._surf_out_field is not None and self._surf_out_max_size >= n_surfaces:
1139
+ self._surf_out_field.from_numpy(surf_outgoing[:n_surfaces].astype(np.float32))
1140
+ surf_out_field = self._surf_out_field
1141
+ else:
1142
+ # Create temporary taichi field for outgoing radiation
1143
+ surf_out_field = ti.field(dtype=ti.f32, shape=(n_surfaces,))
1144
+ surf_out_field.from_numpy(surf_outgoing[:n_surfaces].astype(np.float32))
1145
+
1146
+ has_lad = 1 if self.domain.lad is not None else 0
1147
+
1148
+ if has_lad:
1149
+ self._compute_reflected_flux_kernel(
1150
+ n_surfaces,
1151
+ surfaces.center,
1152
+ surfaces.normal,
1153
+ surfaces.area,
1154
+ surf_out_field,
1155
+ self.domain.is_solid,
1156
+ self.domain.lad,
1157
+ has_lad
1158
+ )
1159
+ else:
1160
+ self._compute_reflected_flux_kernel(
1161
+ n_surfaces,
1162
+ surfaces.center,
1163
+ surfaces.normal,
1164
+ surfaces.area,
1165
+ surf_out_field,
1166
+ self.domain.is_solid,
1167
+ self.domain.lad,
1168
+ 0
1169
+ )
1170
+
1171
+ @ti.kernel
1172
+ def _add_reflected_to_total(self):
1173
+ """Add reflected flux to total volumetric flux."""
1174
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
1175
+ self.swflux_vol[i, j, k] += self.swflux_reflected_vol[i, j, k]
1176
+
1177
+ @ti.kernel
1178
+ def _clear_reflected_flux(self):
1179
+ """Clear reflected flux field."""
1180
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
1181
+ self.swflux_reflected_vol[i, j, k] = 0.0
1182
+
1183
+ @ti.kernel
1184
+ def _compute_reflected_flux_terrain_kernel(
1185
+ self,
1186
+ n_surfaces: ti.i32,
1187
+ surf_center: ti.template(),
1188
+ surf_normal: ti.template(),
1189
+ surf_area: ti.template(),
1190
+ surf_outgoing: ti.template(),
1191
+ is_solid: ti.template(),
1192
+ lad: ti.template(),
1193
+ has_lad: ti.i32,
1194
+ height_offset: ti.i32
1195
+ ):
1196
+ """
1197
+ Compute reflected flux ONLY at terrain-following extraction level.
1198
+
1199
+ This is an optimized version that only computes for the cells at
1200
+ (i, j, ground_k[i,j] + height_offset_k) instead of all 3D cells.
1201
+
1202
+ ~61x faster than full volumetric reflection computation.
1203
+
1204
+ Requires init_cumulative_accumulation() to be called first.
1205
+ """
1206
+ max_dist_sq = 900.0 # 30m max distance
1207
+
1208
+ for i, j in ti.ndrange(self.nx, self.ny):
1209
+ k = self._ground_k[i, j] + height_offset
1210
+
1211
+ # Skip out-of-bounds or solid cells
1212
+ if k < 0 or k >= self.nz:
1213
+ continue
1214
+ if is_solid[i, j, k] == 1:
1215
+ continue
1216
+
1217
+ cell_x = (ti.cast(i, ti.f32) + 0.5) * self.dx
1218
+ cell_y = (ti.cast(j, ti.f32) + 0.5) * self.dy
1219
+ cell_z = (ti.cast(k, ti.f32) + 0.5) * self.dz
1220
+
1221
+ total_reflected = 0.0
1222
+
1223
+ for surf_idx in range(n_surfaces):
1224
+ outgoing = surf_outgoing[surf_idx]
1225
+
1226
+ if outgoing > 0.1:
1227
+ surf_x = surf_center[surf_idx][0]
1228
+ surf_y = surf_center[surf_idx][1]
1229
+ surf_z = surf_center[surf_idx][2]
1230
+
1231
+ dx = cell_x - surf_x
1232
+ dy = cell_y - surf_y
1233
+ dz = cell_z - surf_z
1234
+ dist_sq = dx*dx + dy*dy + dz*dz
1235
+
1236
+ if dist_sq > 0.01 and dist_sq < max_dist_sq:
1237
+ surf_nx = surf_normal[surf_idx][0]
1238
+ surf_ny = surf_normal[surf_idx][1]
1239
+ surf_nz = surf_normal[surf_idx][2]
1240
+ area = surf_area[surf_idx]
1241
+
1242
+ dist = ti.sqrt(dist_sq)
1243
+
1244
+ dir_x = dx / dist
1245
+ dir_y = dy / dist
1246
+ dir_z = dz / dist
1247
+
1248
+ cos_angle = dir_x * surf_nx + dir_y * surf_ny + dir_z * surf_nz
1249
+
1250
+ if cos_angle > 0.0:
1251
+ trans = self._trace_transmissivity_to_surface(
1252
+ i, j, k, surf_x, surf_y, surf_z,
1253
+ surf_nx, surf_ny, surf_nz,
1254
+ is_solid, lad, has_lad
1255
+ )
1256
+
1257
+ if trans > 0.0:
1258
+ vf = area * cos_angle / (PI * dist_sq)
1259
+ contribution = outgoing * vf * trans * 0.25
1260
+ total_reflected += contribution
1261
+
1262
+ self.swflux_reflected_vol[i, j, k] = total_reflected
1263
+
1264
+ def compute_reflected_flux_terrain_following(
1265
+ self,
1266
+ surfaces,
1267
+ surf_outgoing: np.ndarray
1268
+ ):
1269
+ """
1270
+ Compute reflected flux only at terrain-following extraction level.
1271
+
1272
+ This is ~61x faster than compute_reflected_flux_vol() because it only
1273
+ computes for O(nx*ny) cells instead of O(nx*ny*nz) cells.
1274
+
1275
+ Requires init_cumulative_accumulation() to be called first.
1276
+
1277
+ Args:
1278
+ surfaces: Surfaces object with geometry (center, normal, area)
1279
+ surf_outgoing: Array of surface outgoing radiation (W/m²)
1280
+ """
1281
+ if not self._cumulative_initialized:
1282
+ raise RuntimeError("Must call init_cumulative_accumulation() first")
1283
+
1284
+ n_surfaces = surfaces.n_surfaces[None]
1285
+ if n_surfaces == 0:
1286
+ return
1287
+
1288
+ # Re-use pre-allocated field if available
1289
+ if self._surf_out_field is not None and self._surf_out_max_size >= n_surfaces:
1290
+ self._surf_out_field.from_numpy(surf_outgoing[:n_surfaces].astype(np.float32))
1291
+ surf_out_field = self._surf_out_field
1292
+ else:
1293
+ surf_out_field = ti.field(dtype=ti.f32, shape=(n_surfaces,))
1294
+ surf_out_field.from_numpy(surf_outgoing[:n_surfaces].astype(np.float32))
1295
+
1296
+ has_lad = 1 if self.domain.lad is not None else 0
1297
+
1298
+ # Clear reflected flux field before computing
1299
+ self._clear_reflected_flux()
1300
+
1301
+ self._compute_reflected_flux_terrain_kernel(
1302
+ n_surfaces,
1303
+ surfaces.center,
1304
+ surfaces.normal,
1305
+ surfaces.area,
1306
+ surf_out_field,
1307
+ self.domain.is_solid,
1308
+ self.domain.lad,
1309
+ has_lad,
1310
+ self._height_offset_k
1311
+ )
1312
+
1313
+ def compute_swflux_vol_with_reflections(
1314
+ self,
1315
+ sw_direct: float,
1316
+ sw_diffuse: float,
1317
+ cos_zenith: float,
1318
+ sun_direction: Tuple[float, float, float],
1319
+ surfaces,
1320
+ surf_outgoing: np.ndarray,
1321
+ lad: Optional[ti.template] = None
1322
+ ):
1323
+ """
1324
+ Compute volumetric shortwave flux including reflected radiation.
1325
+
1326
+ This is a convenience method that combines direct/diffuse computation
1327
+ with reflected radiation from surfaces.
1328
+
1329
+ Args:
1330
+ sw_direct: Direct normal irradiance (W/m²)
1331
+ sw_diffuse: Diffuse horizontal irradiance (W/m²)
1332
+ cos_zenith: Cosine of solar zenith angle
1333
+ sun_direction: Unit vector toward sun (x, y, z)
1334
+ surfaces: Surfaces object with geometry
1335
+ surf_outgoing: Surface outgoing radiation array (W/m²)
1336
+ lad: Optional LAD field for canopy attenuation
1337
+ """
1338
+ # Compute direct + diffuse
1339
+ self.compute_swflux_vol(sw_direct, sw_diffuse, cos_zenith, sun_direction, lad)
1340
+
1341
+ # Compute and add reflected
1342
+ self.compute_reflected_flux_vol(surfaces, surf_outgoing)
1343
+ self._add_reflected_to_total()
1344
+
1345
+ def get_swflux_reflected_vol(self) -> np.ndarray:
1346
+ """Get volumetric reflected SW flux as numpy array (W/m²)."""
1347
+ return self.swflux_reflected_vol.to_numpy()
1348
+
1349
+ def get_swflux_direct_vol(self) -> np.ndarray:
1350
+ """Get volumetric direct SW flux as numpy array (W/m²)."""
1351
+ return self.swflux_direct_vol.to_numpy()
1352
+
1353
+ def get_swflux_diffuse_vol(self) -> np.ndarray:
1354
+ """Get volumetric diffuse SW flux as numpy array (W/m²)."""
1355
+ return self.swflux_diffuse_vol.to_numpy()
1356
+
1357
+ def get_flux_components(self) -> dict:
1358
+ """
1359
+ Get all volumetric flux components as a dictionary.
1360
+
1361
+ Returns:
1362
+ Dictionary with keys:
1363
+ - 'total': Total SW flux (direct + diffuse + reflected if enabled)
1364
+ - 'direct': Direct solar component
1365
+ - 'diffuse': Diffuse sky component
1366
+ - 'reflected': Reflected from surfaces (if computed)
1367
+ - 'skyvf': Sky view factor
1368
+ """
1369
+ return {
1370
+ 'total': self.swflux_vol.to_numpy(),
1371
+ 'direct': self.swflux_direct_vol.to_numpy(),
1372
+ 'diffuse': self.swflux_diffuse_vol.to_numpy(),
1373
+ 'reflected': self.swflux_reflected_vol.to_numpy(),
1374
+ 'skyvf': self.skyvf_vol.to_numpy()
1375
+ }
1376
+
1377
+ # =========================================================================
1378
+ # Cell-to-Surface View Factor (C2S-VF) Matrix Caching
1379
+ # =========================================================================
1380
+ # These methods pre-compute which surfaces each voxel cell can see,
1381
+ # making repeated reflected flux calculations O(nnz) instead of O(N*M).
1382
+
1383
+ def compute_c2s_matrix(
1384
+ self,
1385
+ surfaces,
1386
+ is_solid,
1387
+ lad=None,
1388
+ min_vf_threshold: float = 1e-6,
1389
+ progress_report: bool = False
1390
+ ):
1391
+ """
1392
+ Pre-compute Cell-to-Surface View Factor matrix for fast reflections.
1393
+
1394
+ This is O(N_cells * N_surfaces) but only needs to be done once for
1395
+ fixed geometry. Subsequent calls to compute_reflected_flux_vol_cached()
1396
+ become O(nnz) instead of O(N*M).
1397
+
1398
+ Call this before running multi-timestep simulations with reflections.
1399
+
1400
+ Args:
1401
+ surfaces: Surfaces object with center, normal, area fields
1402
+ is_solid: 3D solid obstacle field
1403
+ lad: Optional LAD field for vegetation attenuation
1404
+ min_vf_threshold: Minimum view factor to store (sparsity threshold)
1405
+ progress_report: Print progress messages
1406
+ """
1407
+ if self._c2s_matrix_cached:
1408
+ if progress_report:
1409
+ print("C2S-VF matrix already cached, skipping recomputation.")
1410
+ return
1411
+
1412
+ n_surfaces = surfaces.n_surfaces[None]
1413
+ n_cells = self.nx * self.ny * self.nz
1414
+
1415
+ if progress_report:
1416
+ print(f"Pre-computing C2S-VF matrix: {n_cells:,} cells × {n_surfaces:,} surfaces")
1417
+ print(" This is O(N*M) but only runs once for fixed geometry.")
1418
+
1419
+ # Allocate sparse COO arrays if not already done
1420
+ if self._c2s_cell_idx is None:
1421
+ self._c2s_cell_idx = ti.field(dtype=ti.i32, shape=(self._max_c2s_entries,))
1422
+ self._c2s_surf_idx = ti.field(dtype=ti.i32, shape=(self._max_c2s_entries,))
1423
+ self._c2s_vf = ti.field(dtype=ti.f32, shape=(self._max_c2s_entries,))
1424
+ self._c2s_count = ti.field(dtype=ti.i32, shape=())
1425
+
1426
+ has_lad = 1 if lad is not None else 0
1427
+
1428
+ # Compute the matrix
1429
+ self._c2s_count[None] = 0
1430
+ self._compute_c2s_matrix_kernel(
1431
+ n_surfaces,
1432
+ surfaces.center,
1433
+ surfaces.normal,
1434
+ surfaces.area,
1435
+ is_solid,
1436
+ lad if lad is not None else self.domain.lad, # Fallback to domain LAD
1437
+ has_lad,
1438
+ min_vf_threshold
1439
+ )
1440
+
1441
+ computed_nnz = int(self._c2s_count[None])
1442
+ if computed_nnz > self._max_c2s_entries:
1443
+ print(f"Warning: C2S-VF matrix truncated! {computed_nnz:,} > {self._max_c2s_entries:,}")
1444
+ print(" Consider increasing _max_c2s_entries.")
1445
+ self._c2s_nnz = self._max_c2s_entries
1446
+ else:
1447
+ self._c2s_nnz = computed_nnz
1448
+
1449
+ self._c2s_matrix_cached = True
1450
+
1451
+ sparsity_pct = self._c2s_nnz / (n_cells * n_surfaces) * 100 if n_surfaces > 0 else 0
1452
+ if progress_report:
1453
+ print(f" C2S-VF matrix computed: {self._c2s_nnz:,} non-zero entries")
1454
+ print(f" Sparsity: {sparsity_pct:.4f}% of full matrix")
1455
+ speedup = (n_cells * n_surfaces) / max(1, self._c2s_nnz)
1456
+ print(f" Speedup factor: ~{speedup:.0f}x per timestep")
1457
+
1458
+ @ti.kernel
1459
+ def _compute_c2s_matrix_kernel(
1460
+ self,
1461
+ n_surfaces: ti.i32,
1462
+ surf_center: ti.template(),
1463
+ surf_normal: ti.template(),
1464
+ surf_area: ti.template(),
1465
+ is_solid: ti.template(),
1466
+ lad: ti.template(),
1467
+ has_lad: ti.i32,
1468
+ min_threshold: ti.f32
1469
+ ):
1470
+ """
1471
+ Compute C2S-VF matrix entries.
1472
+
1473
+ For each (cell, surface) pair, compute the view factor including
1474
+ geometry and transmissivity. Store if above threshold.
1475
+ """
1476
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
1477
+ # Skip solid cells
1478
+ if is_solid[i, j, k] == 1:
1479
+ continue
1480
+
1481
+ cell_idx = i * (self.ny * self.nz) + j * self.nz + k
1482
+ cell_x = (ti.cast(i, ti.f32) + 0.5) * self.dx
1483
+ cell_y = (ti.cast(j, ti.f32) + 0.5) * self.dy
1484
+ cell_z = (ti.cast(k, ti.f32) + 0.5) * self.dz
1485
+
1486
+ for surf_idx in range(n_surfaces):
1487
+ surf_x = surf_center[surf_idx][0]
1488
+ surf_y = surf_center[surf_idx][1]
1489
+ surf_z = surf_center[surf_idx][2]
1490
+ surf_nx = surf_normal[surf_idx][0]
1491
+ surf_ny = surf_normal[surf_idx][1]
1492
+ surf_nz = surf_normal[surf_idx][2]
1493
+ area = surf_area[surf_idx]
1494
+
1495
+ # Distance to surface
1496
+ dx = cell_x - surf_x
1497
+ dy = cell_y - surf_y
1498
+ dz = cell_z - surf_z
1499
+ dist_sq = dx*dx + dy*dy + dz*dz
1500
+
1501
+ if dist_sq > 0.01: # Avoid numerical issues
1502
+ dist = ti.sqrt(dist_sq)
1503
+
1504
+ # Direction from surface to cell (normalized)
1505
+ dir_x = dx / dist
1506
+ dir_y = dy / dist
1507
+ dir_z = dz / dist
1508
+
1509
+ # Cosine of angle between normal and direction
1510
+ cos_angle = dir_x * surf_nx + dir_y * surf_ny + dir_z * surf_nz
1511
+
1512
+ if cos_angle > 0.0: # Surface faces the cell
1513
+ # Get transmissivity
1514
+ trans = self._trace_transmissivity_to_surface(
1515
+ i, j, k, surf_x, surf_y, surf_z,
1516
+ surf_nx, surf_ny, surf_nz,
1517
+ is_solid, lad, has_lad
1518
+ )
1519
+
1520
+ if trans > 0.0:
1521
+ # View factor: (A * cos_θ) / (π * d²) * 0.25 for sphere
1522
+ vf = area * cos_angle / (PI * dist_sq) * trans * 0.25
1523
+
1524
+ if vf > min_threshold:
1525
+ idx = ti.atomic_add(self._c2s_count[None], 1)
1526
+ if idx < self._max_c2s_entries:
1527
+ self._c2s_cell_idx[idx] = cell_idx
1528
+ self._c2s_surf_idx[idx] = surf_idx
1529
+ self._c2s_vf[idx] = vf
1530
+
1531
+ def compute_reflected_flux_vol_cached(
1532
+ self,
1533
+ surf_outgoing: np.ndarray,
1534
+ progress_report: bool = False
1535
+ ):
1536
+ """
1537
+ Compute volumetric reflected flux using cached C2S-VF matrix.
1538
+
1539
+ This is O(nnz) instead of O(N_cells * N_surfaces), providing
1540
+ massive speedup for repeated calls with different surface radiation.
1541
+
1542
+ Must call compute_c2s_matrix() first.
1543
+
1544
+ Args:
1545
+ surf_outgoing: Array of surface outgoing radiation (W/m²)
1546
+ progress_report: Print progress messages
1547
+ """
1548
+ if not self._c2s_matrix_cached:
1549
+ raise RuntimeError("C2S-VF matrix not computed. Call compute_c2s_matrix() first.")
1550
+
1551
+ n_surfaces = len(surf_outgoing)
1552
+
1553
+ # Allocate or resize surface outgoing field if needed
1554
+ if self._surf_out_field is None or self._surf_out_max_size < n_surfaces:
1555
+ self._surf_out_field = ti.field(dtype=ti.f32, shape=(n_surfaces,))
1556
+ self._surf_out_max_size = n_surfaces
1557
+
1558
+ # Copy outgoing radiation to Taichi field
1559
+ self._surf_out_field.from_numpy(surf_outgoing.astype(np.float32))
1560
+
1561
+ # Clear reflected flux field
1562
+ self._clear_reflected_flux()
1563
+
1564
+ # Use sparse matrix-vector multiply
1565
+ self._apply_c2s_matrix_kernel(self._c2s_nnz)
1566
+
1567
+ if progress_report:
1568
+ print(f"Computed volumetric reflected flux using cached C2S-VF ({self._c2s_nnz:,} entries)")
1569
+
1570
+ @ti.kernel
1571
+ def _apply_c2s_matrix_kernel(self, c2s_nnz: ti.i32):
1572
+ """
1573
+ Apply C2S-VF matrix to compute reflected flux.
1574
+
1575
+ flux[cell] = Σ (vf[cell, surf] * outgoing[surf])
1576
+
1577
+ Uses atomic operations for parallel accumulation.
1578
+ """
1579
+ for idx in range(c2s_nnz):
1580
+ cell_idx = self._c2s_cell_idx[idx]
1581
+ surf_idx = self._c2s_surf_idx[idx]
1582
+ vf = self._c2s_vf[idx]
1583
+
1584
+ outgoing = self._surf_out_field[surf_idx]
1585
+
1586
+ if outgoing > 0.1: # Threshold for negligible contributions
1587
+ # Reconstruct 3D indices from linear index
1588
+ # cell_idx = i * (ny * nz) + j * nz + k
1589
+ tmp = cell_idx
1590
+ k = tmp % self.nz
1591
+ tmp //= self.nz
1592
+ j = tmp % self.ny
1593
+ i = tmp // self.ny
1594
+
1595
+ ti.atomic_add(self.swflux_reflected_vol[i, j, k], outgoing * vf)
1596
+
1597
+ def invalidate_c2s_cache(self):
1598
+ """
1599
+ Invalidate the cached C2S-VF matrix.
1600
+
1601
+ Call this if geometry (buildings, terrain, vegetation) changes.
1602
+ """
1603
+ self._c2s_matrix_cached = False
1604
+ self._c2s_nnz = 0
1605
+
1606
+ @property
1607
+ def c2s_matrix_cached(self) -> bool:
1608
+ """Check if C2S-VF matrix is currently cached."""
1609
+ return self._c2s_matrix_cached
1610
+
1611
+ @property
1612
+ def c2s_matrix_entries(self) -> int:
1613
+ """Get number of non-zero entries in cached C2S-VF matrix."""
1614
+ return self._c2s_nnz
1615
+
1616
+ def compute_swflux_vol_with_reflections_cached(
1617
+ self,
1618
+ sw_direct: float,
1619
+ sw_diffuse: float,
1620
+ cos_zenith: float,
1621
+ sun_direction: Tuple[float, float, float],
1622
+ surf_outgoing: np.ndarray,
1623
+ lad=None
1624
+ ):
1625
+ """
1626
+ Compute volumetric shortwave flux with reflections using cached matrix.
1627
+
1628
+ This is the fast path for multi-timestep simulations. Must call
1629
+ compute_c2s_matrix() once before using this method.
1630
+
1631
+ Args:
1632
+ sw_direct: Direct normal irradiance (W/m²)
1633
+ sw_diffuse: Diffuse horizontal irradiance (W/m²)
1634
+ cos_zenith: Cosine of solar zenith angle
1635
+ sun_direction: Unit vector toward sun (x, y, z)
1636
+ surf_outgoing: Surface outgoing radiation array (W/m²)
1637
+ lad: Optional LAD field for canopy attenuation
1638
+ """
1639
+ # Compute direct + diffuse
1640
+ self.compute_swflux_vol(sw_direct, sw_diffuse, cos_zenith, sun_direction, lad)
1641
+
1642
+ # Compute reflected using cached matrix
1643
+ self.compute_reflected_flux_vol_cached(surf_outgoing)
1644
+ self._add_reflected_to_total()
1645
+
1646
+ # =========================================================================
1647
+ # Terrain-Following Cell-to-Surface VF (T2S-VF) Matrix Caching
1648
+ # =========================================================================
1649
+ # These methods pre-compute view factors from terrain-following evaluation
1650
+ # cells (at volumetric_height above ground) to surfaces.
1651
+ # This makes cumulative volumetric reflections O(nnz) instead of O(N*M).
1652
+
1653
+ def compute_t2s_matrix(
1654
+ self,
1655
+ surfaces,
1656
+ min_vf_threshold: float = 1e-6,
1657
+ progress_report: bool = False
1658
+ ):
1659
+ """
1660
+ Pre-compute Terrain-to-Surface View Factor matrix for fast reflections.
1661
+
1662
+ This computes view factors only for cells at the terrain-following
1663
+ extraction height (O(nx*ny) cells), not the full 3D volume.
1664
+
1665
+ Requires init_cumulative_accumulation() to be called first to set
1666
+ ground_k and height_offset_k.
1667
+
1668
+ Args:
1669
+ surfaces: Surfaces object with center, normal, area fields
1670
+ min_vf_threshold: Minimum view factor to store (sparsity threshold)
1671
+ progress_report: Print progress messages
1672
+ """
1673
+ if not self._cumulative_initialized:
1674
+ raise RuntimeError("Must call init_cumulative_accumulation() first.")
1675
+
1676
+ # Check if we already have a valid cache for this height offset
1677
+ if (self._t2s_matrix_cached and
1678
+ self._t2s_height_offset_k == self._height_offset_k):
1679
+ if progress_report:
1680
+ print(f"T2S-VF matrix already cached for height_offset={self._height_offset_k}, skipping.")
1681
+ return
1682
+
1683
+ n_surfaces = surfaces.n_surfaces[None]
1684
+ n_terrain_cells = self.nx * self.ny
1685
+
1686
+ if progress_report:
1687
+ print(f"Pre-computing T2S-VF matrix: {n_terrain_cells:,} terrain cells × {n_surfaces:,} surfaces")
1688
+ print(f" Height offset: {self._height_offset_k} cells above ground")
1689
+
1690
+ # Allocate sparse COO arrays if not already done
1691
+ if self._t2s_ij_idx is None:
1692
+ self._t2s_ij_idx = ti.field(dtype=ti.i32, shape=(self._max_t2s_entries,))
1693
+ self._t2s_surf_idx = ti.field(dtype=ti.i32, shape=(self._max_t2s_entries,))
1694
+ self._t2s_vf = ti.field(dtype=ti.f32, shape=(self._max_t2s_entries,))
1695
+ self._t2s_count = ti.field(dtype=ti.i32, shape=())
1696
+
1697
+ has_lad = 1 if self.domain.lad is not None else 0
1698
+
1699
+ # Clear count and compute the matrix
1700
+ self._t2s_count[None] = 0
1701
+ self._compute_t2s_matrix_kernel(
1702
+ n_surfaces,
1703
+ surfaces.center,
1704
+ surfaces.normal,
1705
+ surfaces.area,
1706
+ self.domain.is_solid,
1707
+ self.domain.lad,
1708
+ has_lad,
1709
+ self._height_offset_k,
1710
+ min_vf_threshold
1711
+ )
1712
+
1713
+ computed_nnz = int(self._t2s_count[None])
1714
+ if computed_nnz > self._max_t2s_entries:
1715
+ print(f"Warning: T2S-VF matrix truncated! {computed_nnz:,} > {self._max_t2s_entries:,}")
1716
+ print(" Consider increasing _max_t2s_entries.")
1717
+ self._t2s_nnz = self._max_t2s_entries
1718
+ else:
1719
+ self._t2s_nnz = computed_nnz
1720
+
1721
+ self._t2s_matrix_cached = True
1722
+ self._t2s_height_offset_k = self._height_offset_k
1723
+
1724
+ sparsity_pct = self._t2s_nnz / (n_terrain_cells * n_surfaces) * 100 if n_surfaces > 0 else 0
1725
+ memory_mb = self._t2s_nnz * 12 / 1e6 # 12 bytes per entry (2 int32 + 1 float32)
1726
+ if progress_report:
1727
+ print(f" T2S-VF matrix computed: {self._t2s_nnz:,} non-zero entries ({memory_mb:.1f} MB)")
1728
+ print(f" Sparsity: {sparsity_pct:.4f}% of full matrix")
1729
+ speedup = (n_terrain_cells * n_surfaces) / max(1, self._t2s_nnz)
1730
+ print(f" Speedup factor: ~{speedup:.0f}x per sky patch")
1731
+
1732
+ @ti.kernel
1733
+ def _compute_t2s_matrix_kernel(
1734
+ self,
1735
+ n_surfaces: ti.i32,
1736
+ surf_center: ti.template(),
1737
+ surf_normal: ti.template(),
1738
+ surf_area: ti.template(),
1739
+ is_solid: ti.template(),
1740
+ lad: ti.template(),
1741
+ has_lad: ti.i32,
1742
+ height_offset: ti.i32,
1743
+ min_threshold: ti.f32
1744
+ ):
1745
+ """
1746
+ Compute T2S-VF matrix entries for terrain-following cells only.
1747
+
1748
+ For each terrain cell at (i, j, ground_k[i,j] + height_offset),
1749
+ compute view factors to all visible surfaces.
1750
+ """
1751
+ max_dist_sq = 900.0 # 30m max distance (same as terrain kernel)
1752
+
1753
+ for i, j in ti.ndrange(self.nx, self.ny):
1754
+ gk = self._ground_k[i, j]
1755
+ if gk < 0:
1756
+ continue # No valid ground
1757
+
1758
+ k = gk + height_offset
1759
+ if k < 0 or k >= self.nz:
1760
+ continue
1761
+
1762
+ # Skip solid cells
1763
+ if is_solid[i, j, k] == 1:
1764
+ continue
1765
+
1766
+ ij_idx = i * self.ny + j
1767
+ cell_x = (ti.cast(i, ti.f32) + 0.5) * self.dx
1768
+ cell_y = (ti.cast(j, ti.f32) + 0.5) * self.dy
1769
+ cell_z = (ti.cast(k, ti.f32) + 0.5) * self.dz
1770
+
1771
+ for surf_idx in range(n_surfaces):
1772
+ surf_x = surf_center[surf_idx][0]
1773
+ surf_y = surf_center[surf_idx][1]
1774
+ surf_z = surf_center[surf_idx][2]
1775
+ surf_nx = surf_normal[surf_idx][0]
1776
+ surf_ny = surf_normal[surf_idx][1]
1777
+ surf_nz = surf_normal[surf_idx][2]
1778
+ area = surf_area[surf_idx]
1779
+
1780
+ # Distance to surface
1781
+ dx = cell_x - surf_x
1782
+ dy = cell_y - surf_y
1783
+ dz = cell_z - surf_z
1784
+ dist_sq = dx*dx + dy*dy + dz*dz
1785
+
1786
+ if dist_sq > 0.01 and dist_sq < max_dist_sq:
1787
+ dist = ti.sqrt(dist_sq)
1788
+
1789
+ # Direction from surface to cell (normalized)
1790
+ dir_x = dx / dist
1791
+ dir_y = dy / dist
1792
+ dir_z = dz / dist
1793
+
1794
+ # Cosine of angle between normal and direction
1795
+ cos_angle = dir_x * surf_nx + dir_y * surf_ny + dir_z * surf_nz
1796
+
1797
+ if cos_angle > 0.0: # Surface faces the cell
1798
+ # Get transmissivity
1799
+ trans = self._trace_transmissivity_to_surface(
1800
+ i, j, k, surf_x, surf_y, surf_z,
1801
+ surf_nx, surf_ny, surf_nz,
1802
+ is_solid, lad, has_lad
1803
+ )
1804
+
1805
+ if trans > 0.0:
1806
+ # View factor: (A * cos_θ) / (π * d²) * 0.25 for sphere
1807
+ vf = area * cos_angle / (PI * dist_sq) * trans * 0.25
1808
+
1809
+ if vf > min_threshold:
1810
+ idx = ti.atomic_add(self._t2s_count[None], 1)
1811
+ if idx < self._max_t2s_entries:
1812
+ self._t2s_ij_idx[idx] = ij_idx
1813
+ self._t2s_surf_idx[idx] = surf_idx
1814
+ self._t2s_vf[idx] = vf
1815
+
1816
+ def compute_reflected_flux_terrain_cached(
1817
+ self,
1818
+ surf_outgoing: np.ndarray
1819
+ ):
1820
+ """
1821
+ Compute reflected flux at terrain-following level using cached T2S matrix.
1822
+
1823
+ This is O(nnz) instead of O(N_cells * N_surfaces), providing
1824
+ massive speedup for cumulative simulations with multiple sky patches.
1825
+
1826
+ Requires:
1827
+ 1. init_cumulative_accumulation() called first
1828
+ 2. compute_t2s_matrix() called to pre-compute view factors
1829
+
1830
+ Args:
1831
+ surf_outgoing: Array of surface outgoing radiation (W/m²)
1832
+ """
1833
+ if not self._t2s_matrix_cached:
1834
+ raise RuntimeError("T2S-VF matrix not computed. Call compute_t2s_matrix() first.")
1835
+
1836
+ n_surfaces = len(surf_outgoing)
1837
+
1838
+ # Allocate or resize surface outgoing field if needed
1839
+ if self._surf_out_field is None or self._surf_out_max_size < n_surfaces:
1840
+ self._surf_out_field = ti.field(dtype=ti.f32, shape=(n_surfaces,))
1841
+ self._surf_out_max_size = n_surfaces
1842
+
1843
+ # Copy outgoing radiation to Taichi field
1844
+ self._surf_out_field.from_numpy(surf_outgoing.astype(np.float32))
1845
+
1846
+ # Clear reflected flux field
1847
+ self._clear_reflected_flux()
1848
+
1849
+ # Use sparse matrix-vector multiply
1850
+ self._apply_t2s_matrix_kernel(self._t2s_nnz, self._height_offset_k)
1851
+
1852
+ @ti.kernel
1853
+ def _apply_t2s_matrix_kernel(self, t2s_nnz: ti.i32, height_offset: ti.i32):
1854
+ """
1855
+ Apply T2S-VF matrix to compute reflected flux at terrain level.
1856
+
1857
+ flux[i,j,k_terrain] = Σ (vf[ij, surf] * outgoing[surf])
1858
+
1859
+ Uses atomic operations for parallel accumulation.
1860
+ """
1861
+ for idx in range(t2s_nnz):
1862
+ ij_idx = self._t2s_ij_idx[idx]
1863
+ surf_idx = self._t2s_surf_idx[idx]
1864
+ vf = self._t2s_vf[idx]
1865
+
1866
+ outgoing = self._surf_out_field[surf_idx]
1867
+
1868
+ if outgoing > 0.1: # Threshold for negligible contributions
1869
+ # Reconstruct indices from ij_idx
1870
+ j = ij_idx % self.ny
1871
+ i = ij_idx // self.ny
1872
+
1873
+ # Get terrain-following k level
1874
+ gk = self._ground_k[i, j]
1875
+ if gk >= 0:
1876
+ k = gk + height_offset
1877
+ if k >= 0 and k < self.nz:
1878
+ ti.atomic_add(self.swflux_reflected_vol[i, j, k], outgoing * vf)
1879
+
1880
+ def invalidate_t2s_cache(self):
1881
+ """
1882
+ Invalidate the cached T2S-VF matrix.
1883
+
1884
+ Call this if geometry or volumetric_height changes.
1885
+ """
1886
+ self._t2s_matrix_cached = False
1887
+ self._t2s_nnz = 0
1888
+ self._t2s_height_offset_k = -1
1889
+
1890
+ @property
1891
+ def t2s_matrix_cached(self) -> bool:
1892
+ """Check if T2S-VF matrix is currently cached."""
1893
+ return self._t2s_matrix_cached
1894
+
1895
+ @property
1896
+ def t2s_matrix_entries(self) -> int:
1897
+ """Get number of non-zero entries in cached T2S-VF matrix."""
1898
+ return self._t2s_nnz
1899
+
1900
+ # =========================================================================
1901
+ # Cumulative Terrain-Following Accumulation (GPU-Optimized)
1902
+ # =========================================================================
1903
+ # These methods enable efficient cumulative volumetric simulation by
1904
+ # accumulating terrain-following slices directly on GPU, avoiding the
1905
+ # expensive GPU-to-CPU transfer of full 3D arrays for each timestep/patch.
1906
+
1907
+ def init_cumulative_accumulation(
1908
+ self,
1909
+ ground_k: np.ndarray,
1910
+ height_offset_k: int,
1911
+ is_solid: np.ndarray
1912
+ ):
1913
+ """
1914
+ Initialize GPU-side cumulative terrain-following accumulation.
1915
+
1916
+ Must be called before using accumulate_terrain_following_slice_gpu().
1917
+
1918
+ Args:
1919
+ ground_k: 2D array (nx, ny) of ground k-levels. -1 means no valid ground.
1920
+ height_offset_k: Number of cells above ground for extraction.
1921
+ is_solid: 3D array (nx, ny, nz) of solid flags.
1922
+ """
1923
+ # Copy ground_k to GPU
1924
+ self._ground_k.from_numpy(ground_k.astype(np.int32))
1925
+ self._height_offset_k = height_offset_k
1926
+
1927
+ # Clear cumulative map
1928
+ self._clear_cumulative_map()
1929
+
1930
+ self._cumulative_initialized = True
1931
+
1932
+ @ti.kernel
1933
+ def _clear_cumulative_map(self):
1934
+ """Clear the cumulative terrain-following map."""
1935
+ for i, j in ti.ndrange(self.nx, self.ny):
1936
+ self._cumulative_map[i, j] = 0.0
1937
+
1938
+ @ti.kernel
1939
+ def _accumulate_terrain_slice_kernel(
1940
+ self,
1941
+ height_offset_k: ti.i32,
1942
+ weight: ti.f64,
1943
+ is_solid: ti.template()
1944
+ ):
1945
+ """
1946
+ Accumulate terrain-following slice from swflux_vol directly on GPU.
1947
+
1948
+ For each (i,j), extracts swflux_vol[i,j,k_extract] * weight
1949
+ where k_extract = ground_k[i,j] + height_offset_k.
1950
+
1951
+ Args:
1952
+ height_offset_k: Number of cells above ground for extraction.
1953
+ weight: Multiplier for values before accumulating (e.g., time_step_hours).
1954
+ is_solid: 3D solid field for masking.
1955
+ """
1956
+ for i, j in ti.ndrange(self.nx, self.ny):
1957
+ gk = self._ground_k[i, j]
1958
+ if gk < 0:
1959
+ continue # No valid ground
1960
+
1961
+ k_extract = gk + height_offset_k
1962
+ if k_extract >= self.nz:
1963
+ continue # Out of bounds
1964
+
1965
+ # Skip if extraction point is inside solid
1966
+ if is_solid[i, j, k_extract] == 1:
1967
+ continue
1968
+
1969
+ # Accumulate the flux value (atomic add for thread safety)
1970
+ flux_val = ti.cast(self.swflux_vol[i, j, k_extract], ti.f64)
1971
+ ti.atomic_add(self._cumulative_map[i, j], flux_val * weight)
1972
+
1973
+ @ti.kernel
1974
+ def _accumulate_terrain_slice_from_svf_kernel(
1975
+ self,
1976
+ height_offset_k: ti.i32,
1977
+ weight: ti.f64,
1978
+ is_solid: ti.template()
1979
+ ):
1980
+ """
1981
+ Accumulate terrain-following slice from skyvf_vol directly on GPU.
1982
+
1983
+ For each (i,j), extracts skyvf_vol[i,j,k_extract] * weight
1984
+ where k_extract = ground_k[i,j] + height_offset_k.
1985
+
1986
+ Args:
1987
+ height_offset_k: Number of cells above ground for extraction.
1988
+ weight: Multiplier (e.g., total_dhi for diffuse contribution).
1989
+ is_solid: 3D solid field for masking.
1990
+ """
1991
+ for i, j in ti.ndrange(self.nx, self.ny):
1992
+ gk = self._ground_k[i, j]
1993
+ if gk < 0:
1994
+ continue
1995
+
1996
+ k_extract = gk + height_offset_k
1997
+ if k_extract >= self.nz:
1998
+ continue
1999
+
2000
+ if is_solid[i, j, k_extract] == 1:
2001
+ continue
2002
+
2003
+ svf_val = ti.cast(self.skyvf_vol[i, j, k_extract], ti.f64)
2004
+ ti.atomic_add(self._cumulative_map[i, j], svf_val * weight)
2005
+
2006
+ def accumulate_terrain_following_slice_gpu(
2007
+ self,
2008
+ weight: float = 1.0
2009
+ ):
2010
+ """
2011
+ Accumulate current swflux_vol terrain-following slice to cumulative map on GPU.
2012
+
2013
+ This is the fast path for cumulative simulations. Must call
2014
+ init_cumulative_accumulation() first.
2015
+
2016
+ Args:
2017
+ weight: Multiplier for values (e.g., time_step_hours for Wh conversion).
2018
+ """
2019
+ if not self._cumulative_initialized:
2020
+ raise RuntimeError("Cumulative accumulation not initialized. "
2021
+ "Call init_cumulative_accumulation() first.")
2022
+
2023
+ self._accumulate_terrain_slice_kernel(
2024
+ self._height_offset_k,
2025
+ float(weight),
2026
+ self.domain.is_solid
2027
+ )
2028
+
2029
+ def accumulate_svf_diffuse_gpu(
2030
+ self,
2031
+ total_dhi: float
2032
+ ):
2033
+ """
2034
+ Accumulate diffuse contribution using SVF field directly on GPU.
2035
+
2036
+ Args:
2037
+ total_dhi: Total cumulative diffuse horizontal irradiance (Wh/m²).
2038
+ """
2039
+ if not self._cumulative_initialized:
2040
+ raise RuntimeError("Cumulative accumulation not initialized. "
2041
+ "Call init_cumulative_accumulation() first.")
2042
+
2043
+ self._accumulate_terrain_slice_from_svf_kernel(
2044
+ self._height_offset_k,
2045
+ float(total_dhi),
2046
+ self.domain.is_solid
2047
+ )
2048
+
2049
+ def get_cumulative_map(self) -> np.ndarray:
2050
+ """
2051
+ Get the accumulated terrain-following cumulative map.
2052
+
2053
+ Returns:
2054
+ 2D numpy array (nx, ny) of cumulative irradiance values.
2055
+ """
2056
+ if not self._cumulative_initialized:
2057
+ raise RuntimeError("Cumulative accumulation not initialized.")
2058
+
2059
+ return self._cumulative_map.to_numpy()
2060
+
2061
+ def finalize_cumulative_map(self, apply_nan_mask: bool = True) -> np.ndarray:
2062
+ """
2063
+ Get final cumulative map with optional NaN masking for invalid cells.
2064
+
2065
+ Args:
2066
+ apply_nan_mask: If True, set cells with no valid ground or inside
2067
+ solid to NaN.
2068
+
2069
+ Returns:
2070
+ 2D numpy array (nx, ny) of cumulative irradiance values.
2071
+ """
2072
+ if not self._cumulative_initialized:
2073
+ raise RuntimeError("Cumulative accumulation not initialized.")
2074
+
2075
+ result = self._cumulative_map.to_numpy()
2076
+
2077
+ if apply_nan_mask:
2078
+ ground_k_np = self._ground_k.to_numpy()
2079
+ is_solid_np = self.domain.is_solid.to_numpy()
2080
+
2081
+ for i in range(self.nx):
2082
+ for j in range(self.ny):
2083
+ gk = ground_k_np[i, j]
2084
+ if gk < 0:
2085
+ result[i, j] = np.nan
2086
+ continue
2087
+ k_extract = gk + self._height_offset_k
2088
+ if k_extract >= self.nz:
2089
+ result[i, j] = np.nan
2090
+ continue
2091
+ if is_solid_np[i, j, k_extract] == 1:
2092
+ result[i, j] = np.nan
2093
+
2094
+ return result
2095
+
2096
+ def reset_cumulative_accumulation(self):
2097
+ """Reset the cumulative map to zero without reinitializing ground_k."""
2098
+ if self._cumulative_initialized:
2099
+ self._clear_cumulative_map()