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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. voxcity/__init__.py +14 -14
  2. voxcity/downloader/ocean.py +559 -0
  3. voxcity/exporter/__init__.py +12 -12
  4. voxcity/exporter/cityles.py +633 -633
  5. voxcity/exporter/envimet.py +733 -728
  6. voxcity/exporter/magicavoxel.py +333 -333
  7. voxcity/exporter/netcdf.py +238 -238
  8. voxcity/exporter/obj.py +1480 -1480
  9. voxcity/generator/__init__.py +47 -44
  10. voxcity/generator/api.py +727 -675
  11. voxcity/generator/grids.py +394 -379
  12. voxcity/generator/io.py +94 -94
  13. voxcity/generator/pipeline.py +582 -282
  14. voxcity/generator/update.py +429 -0
  15. voxcity/generator/voxelizer.py +18 -6
  16. voxcity/geoprocessor/__init__.py +75 -75
  17. voxcity/geoprocessor/draw.py +1494 -1219
  18. voxcity/geoprocessor/merge_utils.py +91 -91
  19. voxcity/geoprocessor/mesh.py +806 -806
  20. voxcity/geoprocessor/network.py +708 -708
  21. voxcity/geoprocessor/raster/__init__.py +2 -0
  22. voxcity/geoprocessor/raster/buildings.py +435 -428
  23. voxcity/geoprocessor/raster/core.py +31 -0
  24. voxcity/geoprocessor/raster/export.py +93 -93
  25. voxcity/geoprocessor/raster/landcover.py +178 -51
  26. voxcity/geoprocessor/raster/raster.py +1 -1
  27. voxcity/geoprocessor/utils.py +824 -824
  28. voxcity/models.py +115 -113
  29. voxcity/simulator/solar/__init__.py +66 -43
  30. voxcity/simulator/solar/integration.py +336 -336
  31. voxcity/simulator/solar/sky.py +668 -0
  32. voxcity/simulator/solar/temporal.py +792 -434
  33. voxcity/simulator_gpu/__init__.py +115 -0
  34. voxcity/simulator_gpu/common/__init__.py +9 -0
  35. voxcity/simulator_gpu/common/geometry.py +11 -0
  36. voxcity/simulator_gpu/core.py +322 -0
  37. voxcity/simulator_gpu/domain.py +262 -0
  38. voxcity/simulator_gpu/environment.yml +11 -0
  39. voxcity/simulator_gpu/init_taichi.py +154 -0
  40. voxcity/simulator_gpu/integration.py +15 -0
  41. voxcity/simulator_gpu/kernels.py +56 -0
  42. voxcity/simulator_gpu/radiation.py +28 -0
  43. voxcity/simulator_gpu/raytracing.py +623 -0
  44. voxcity/simulator_gpu/sky.py +9 -0
  45. voxcity/simulator_gpu/solar/__init__.py +178 -0
  46. voxcity/simulator_gpu/solar/core.py +66 -0
  47. voxcity/simulator_gpu/solar/csf.py +1249 -0
  48. voxcity/simulator_gpu/solar/domain.py +561 -0
  49. voxcity/simulator_gpu/solar/epw.py +421 -0
  50. voxcity/simulator_gpu/solar/integration.py +2953 -0
  51. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  52. voxcity/simulator_gpu/solar/raytracing.py +686 -0
  53. voxcity/simulator_gpu/solar/reflection.py +533 -0
  54. voxcity/simulator_gpu/solar/sky.py +907 -0
  55. voxcity/simulator_gpu/solar/solar.py +337 -0
  56. voxcity/simulator_gpu/solar/svf.py +446 -0
  57. voxcity/simulator_gpu/solar/volumetric.py +1151 -0
  58. voxcity/simulator_gpu/solar/voxcity.py +2953 -0
  59. voxcity/simulator_gpu/temporal.py +13 -0
  60. voxcity/simulator_gpu/utils.py +25 -0
  61. voxcity/simulator_gpu/view.py +32 -0
  62. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  63. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  64. voxcity/simulator_gpu/visibility/integration.py +808 -0
  65. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  66. voxcity/simulator_gpu/visibility/view.py +944 -0
  67. voxcity/utils/__init__.py +11 -0
  68. voxcity/utils/classes.py +194 -0
  69. voxcity/utils/lc.py +80 -39
  70. voxcity/utils/shape.py +230 -0
  71. voxcity/visualizer/__init__.py +24 -24
  72. voxcity/visualizer/builder.py +43 -43
  73. voxcity/visualizer/grids.py +141 -141
  74. voxcity/visualizer/maps.py +187 -187
  75. voxcity/visualizer/renderer.py +1146 -928
  76. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/METADATA +56 -52
  77. voxcity-1.0.13.dist-info/RECORD +116 -0
  78. voxcity-0.7.0.dist-info/RECORD +0 -77
  79. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
  80. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1249 @@
1
+ """
2
+ Canopy Sink Factor (CSF) calculation for palm-solar.
3
+
4
+ Computes how much radiation is absorbed by plant canopy (LAD) before
5
+ reaching surfaces. Based on PALM's RTM methodology using Beer-Lambert law.
6
+
7
+ PALM CSF Structure (from radiation_model_mod.f90 lines ~920-930):
8
+ - TYPE t_csf contains:
9
+ - isurfs: Index of source face (-1 for sky, >= 0 for surface sources)
10
+ - rcvf: Canopy view factor for faces / canopy sink factor for sky
11
+
12
+ PALM Canopy Absorption (radiation_model_mod.f90 lines ~9200-9250):
13
+ - Diffuse from sky: pcbinswdif = csf * rad_sw_in_diff
14
+ - Direct from sun: pcbinswdir = rad_sw_in_dir * pc_box_area * pc_abs_frac * dsitransc
15
+ - From reflections: pcbinsw += csf * surfoutsl(isurfsrc) * asrc * grid_volume_inverse
16
+
17
+ palm_solar implements equivalent physics with GPU-parallel raytracing.
18
+ """
19
+
20
+ import taichi as ti
21
+ import math
22
+ from typing import Optional, Tuple, List
23
+ from dataclasses import dataclass
24
+
25
+ from .core import Vector3, Point3, EXT_COEF, PI, TWO_PI
26
+ from .raytracing import ray_aabb_intersect
27
+
28
+
29
+ # Prototype LAD for computing effective absorption coefficient (PALM default)
30
+ PROTOTYPE_LAD = 1.0
31
+
32
+ # Source type constants (matching PALM's isurfs convention)
33
+ CSF_SOURCE_SKY = -1 # Sky source (diffuse sky radiation)
34
+
35
+
36
+ @ti.func
37
+ def box_absorb_mc(
38
+ boxsize_z: ti.f32, boxsize_y: ti.f32, boxsize_x: ti.f32,
39
+ uvec_z: ti.f32, uvec_y: ti.f32, uvec_x: ti.f32,
40
+ dens: ti.f32, ext_coef: ti.f32, resol: ti.i32
41
+ ) -> ti.types.vector(2, ti.f32):
42
+ """
43
+ PALM's box_absorb: Monte Carlo integration for box absorption.
44
+
45
+ Computes effective cross-sectional area and transmissivity by
46
+ tracing multiple rays through a box at the given angle.
47
+
48
+ Args:
49
+ boxsize_z, boxsize_y, boxsize_x: Box dimensions
50
+ uvec_z, uvec_y, uvec_x: Unit vector of incoming flux (must have uvec_z > 0)
51
+ dens: Box density (LAD)
52
+ ext_coef: Extinction coefficient
53
+ resol: Number of rays in x and y directions
54
+
55
+ Returns:
56
+ Vector of (area, transmissivity)
57
+ """
58
+ # Compute shift of ray footprint due to angle
59
+ xshift = uvec_x / uvec_z * boxsize_z
60
+ yshift = uvec_y / uvec_z * boxsize_z
61
+
62
+ xmin = ti.min(0.0, -xshift)
63
+ xmax = boxsize_x + ti.max(0.0, -xshift)
64
+ ymin = ti.min(0.0, -yshift)
65
+ ymax = boxsize_y + ti.max(0.0, -yshift)
66
+
67
+ transp = 0.0
68
+
69
+ # Monte Carlo integration over ray entry points
70
+ for i in range(resol):
71
+ xorig = xmin + (xmax - xmin) * (i + 0.5) / resol
72
+ for j in range(resol):
73
+ yorig = ymin + (ymax - ymin) * (j + 0.5) / resol
74
+
75
+ # Find ray path through box (entry and exit t values)
76
+ dz1 = 0.0
77
+ dz2 = boxsize_z / uvec_z
78
+
79
+ # Y boundaries
80
+ dy1 = -1e30
81
+ dy2 = 1e30
82
+ if uvec_y > 1e-10:
83
+ dy1 = -yorig / uvec_y
84
+ dy2 = (boxsize_y - yorig) / uvec_y
85
+ elif uvec_y < -1e-10:
86
+ dy1 = (boxsize_y - yorig) / uvec_y
87
+ dy2 = -yorig / uvec_y
88
+
89
+ # X boundaries
90
+ dx1 = -1e30
91
+ dx2 = 1e30
92
+ if uvec_x > 1e-10:
93
+ dx1 = -xorig / uvec_x
94
+ dx2 = (boxsize_x - xorig) / uvec_x
95
+ elif uvec_x < -1e-10:
96
+ dx1 = (boxsize_x - xorig) / uvec_x
97
+ dx2 = -xorig / uvec_x
98
+
99
+ # Path length through box
100
+ t_enter = ti.max(dz1, ti.max(dy1, dx1))
101
+ t_exit = ti.min(dz2, ti.min(dy2, dx2))
102
+ crdist = ti.max(0.0, t_exit - t_enter)
103
+
104
+ # Transmissivity for this ray
105
+ transp += ti.exp(-ext_coef * dens * crdist)
106
+
107
+ # Average transmissivity
108
+ transp = transp / (resol * resol)
109
+
110
+ # Effective area (footprint including slant)
111
+ area = (boxsize_x + ti.abs(xshift)) * (boxsize_y + ti.abs(yshift))
112
+
113
+ return ti.Vector([area, transp])
114
+
115
+
116
+ @ti.data_oriented
117
+ class CSFCalculator:
118
+ """
119
+ GPU-accelerated Canopy Sink Factor calculator.
120
+
121
+ CSF represents the fraction of radiation absorbed by each vegetation
122
+ cell along ray paths from surfaces to sky/sun.
123
+
124
+ Following PALM's methodology:
125
+ - CSF entries have a source index (isurfs): -1 for sky, >= 0 for surfaces
126
+ - During reflection iterations, canopy absorption is accumulated using CSF
127
+ - pcbinsw += csf * surfoutsl(isurfsrc) * asrc * grid_volume_inverse
128
+ """
129
+
130
+ def __init__(self, domain, n_azimuth: int = 80, n_elevation: int = 40,
131
+ max_surfaces: int = 10000):
132
+ """
133
+ Initialize CSF calculator.
134
+
135
+ Args:
136
+ domain: Domain object with grid geometry and LAD
137
+ n_azimuth: Number of azimuthal divisions
138
+ n_elevation: Number of elevation divisions
139
+ max_surfaces: Maximum number of surfaces (for CSF from reflections)
140
+ """
141
+ self.domain = domain
142
+ self.nx = domain.nx
143
+ self.ny = domain.ny
144
+ self.nz = domain.nz
145
+ self.dx = domain.dx
146
+ self.dy = domain.dy
147
+ self.dz = domain.dz
148
+
149
+ self.n_azimuth = n_azimuth
150
+ self.n_elevation = n_elevation
151
+ self.ext_coef = EXT_COEF
152
+ self.max_surfaces = max_surfaces
153
+
154
+ # Maximum ray distance
155
+ self.max_dist = math.sqrt(
156
+ (self.nx * self.dx)**2 +
157
+ (self.ny * self.dy)**2 +
158
+ (self.nz * self.dz)**2
159
+ )
160
+
161
+ # CSF storage: fraction of radiation absorbed per canopy cell
162
+ # Indexed by (canopy_i, canopy_j, canopy_k)
163
+ # This stores total absorbed power (W) - divide by grid volume for W/m³
164
+ self.csf = ti.field(dtype=ti.f32, shape=(self.nx, self.ny, self.nz))
165
+
166
+ # CSF from sky only (isurfs = -1 in PALM terminology)
167
+ # Stores view factor × absorption fraction from sky to each canopy cell
168
+ self.csf_sky = ti.field(dtype=ti.f32, shape=(self.nx, self.ny, self.nz))
169
+
170
+ # CSF from surfaces (indexed by surface and canopy cell)
171
+ # For memory efficiency, we use a dense 4D array for moderate domain sizes
172
+ # csf_surf[surf_idx, i, j, k] = view factor × absorption from surface surf_idx
173
+ # Only allocated if needed (for reflection-step canopy absorption)
174
+ self._csf_surf_allocated = False
175
+ self._max_csf_surfaces = min(max_surfaces, 5000) # Limit memory usage
176
+
177
+ # Accumulated LAD path for diagnostics
178
+ self.lad_path = ti.field(dtype=ti.f32, shape=(self.nx, self.ny, self.nz))
179
+
180
+ # PALM-style: dsitransc - direct solar transmissivity for each canopy box
181
+ # Transmissivity from sky to each canopy cell along sun direction
182
+ self.dsitransc = ti.field(dtype=ti.f32, shape=(self.nx, self.ny, self.nz))
183
+
184
+ # Pre-computed box absorption parameters (updated per sun position)
185
+ self.pc_box_area = ti.field(dtype=ti.f32, shape=()) # Effective cross-sectional area
186
+ self.pc_abs_eff = ti.field(dtype=ti.f32, shape=()) # Effective absorption coefficient
187
+
188
+ # Grid volume (m³) for normalization
189
+ self.grid_volume = self.dx * self.dy * self.dz
190
+ self.grid_volume_inverse = 1.0 / self.grid_volume
191
+
192
+ # CSF sky caching flag - csf_sky is geometry-dependent and only needs to compute once
193
+ self._csf_sky_cached = False
194
+ self._csf_sky_n_azim = 0
195
+ self._csf_sky_n_elev = 0
196
+
197
+ def allocate_surface_csf(self, n_surfaces: int):
198
+ """
199
+ Allocate surface-to-canopy CSF storage for reflection calculations.
200
+
201
+ This is called when canopy absorption during reflections is needed.
202
+
203
+ Args:
204
+ n_surfaces: Number of surfaces in the domain
205
+ """
206
+ if self._csf_surf_allocated:
207
+ return
208
+
209
+ n_to_alloc = min(n_surfaces, self._max_csf_surfaces)
210
+ # csf_surf[surf_idx, i, j, k] stores CSF from surface surf_idx to canopy (i,j,k)
211
+ self.csf_surf = ti.field(dtype=ti.f32,
212
+ shape=(n_to_alloc, self.nx, self.ny, self.nz))
213
+ self._n_csf_surfaces = n_to_alloc
214
+ self._csf_surf_allocated = True
215
+ print(f"Allocated surface-indexed CSF storage for {n_to_alloc} surfaces")
216
+
217
+ @ti.kernel
218
+ def reset_csf(self):
219
+ """Reset CSF fields to zero."""
220
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
221
+ self.csf[i, j, k] = 0.0
222
+ self.csf_sky[i, j, k] = 0.0
223
+ self.lad_path[i, j, k] = 0.0
224
+ self.dsitransc[i, j, k] = 0.0
225
+
226
+ @ti.kernel
227
+ def _reset_csf_surf(self, n_surfaces: ti.i32):
228
+ """Reset surface-indexed CSF fields to zero."""
229
+ for s, i, j, k in ti.ndrange(n_surfaces, self.nx, self.ny, self.nz):
230
+ self.csf_surf[s, i, j, k] = 0.0
231
+
232
+ def reset_surface_csf(self, n_surfaces: int):
233
+ """Reset surface-indexed CSF storage."""
234
+ if self._csf_surf_allocated:
235
+ self._reset_csf_surf(min(n_surfaces, self._n_csf_surfaces))
236
+
237
+ @ti.kernel
238
+ def _compute_box_params(
239
+ self,
240
+ sun_dir: ti.types.vector(3, ti.f32),
241
+ prototype_lad: ti.f32,
242
+ mc_resolution: ti.i32
243
+ ):
244
+ """
245
+ Compute effective box absorption parameters (PALM's box_absorb).
246
+
247
+ This precomputes pc_box_area and pc_abs_eff for the current sun position.
248
+ These are used for all canopy boxes.
249
+
250
+ PALM uses CSHIFT to reorder dimensions so the largest sun direction
251
+ component is first. Then adjusts area by the ratio of the shifted
252
+ first component to the original first component (z).
253
+
254
+ Args:
255
+ sun_dir: Sun direction unit vector (pointing toward sun)
256
+ prototype_lad: Reference LAD for computing effective coefficient
257
+ mc_resolution: Monte Carlo resolution (rays per dimension)
258
+ """
259
+ # PALM's sunorig is (z, y, x) - we need to handle dimension reordering
260
+ # sunorig(1) = z component, sunorig(2) = y component, sunorig(3) = x component
261
+ abs_z = ti.abs(sun_dir[2]) # sunorig(1)
262
+ abs_y = ti.abs(sun_dir[1]) # sunorig(2)
263
+ abs_x = ti.abs(sun_dir[0]) # sunorig(3)
264
+
265
+ # Find dominant direction (PALM: MAXLOC(ABS(sunorig), 1) - 1)
266
+ # dimshift = 0: z dominant, dimshift = 1: y dominant, dimshift = 2: x dominant
267
+ dimshift = 0
268
+ max_component = abs_z
269
+ if abs_y > max_component:
270
+ dimshift = 1
271
+ max_component = abs_y
272
+ if abs_x > max_component:
273
+ dimshift = 2
274
+ max_component = abs_x
275
+
276
+ # Reorder box dimensions and direction vector (CSHIFT)
277
+ # Original order: (dz, dy, dx), (abs_z, abs_y, abs_x)
278
+ boxsize_0 = 0.0
279
+ boxsize_1 = 0.0
280
+ boxsize_2 = 0.0
281
+ uvec_0 = 0.0
282
+ uvec_1 = 0.0
283
+ uvec_2 = 0.0
284
+
285
+ if dimshift == 0:
286
+ # z dominant: no shift
287
+ boxsize_0, boxsize_1, boxsize_2 = self.dz, self.dy, self.dx
288
+ uvec_0, uvec_1, uvec_2 = abs_z, abs_y, abs_x
289
+ elif dimshift == 1:
290
+ # y dominant: shift by 1 -> (dy, dx, dz), (abs_y, abs_x, abs_z)
291
+ boxsize_0, boxsize_1, boxsize_2 = self.dy, self.dx, self.dz
292
+ uvec_0, uvec_1, uvec_2 = abs_y, abs_x, abs_z
293
+ else:
294
+ # x dominant: shift by 2 -> (dx, dz, dy), (abs_x, abs_z, abs_y)
295
+ boxsize_0, boxsize_1, boxsize_2 = self.dx, self.dz, self.dy
296
+ uvec_0, uvec_1, uvec_2 = abs_x, abs_z, abs_y
297
+
298
+ if uvec_0 > 1e-6:
299
+ result = box_absorb_mc(
300
+ boxsize_0, boxsize_1, boxsize_2,
301
+ uvec_0, uvec_1, uvec_2,
302
+ prototype_lad, self.ext_coef, mc_resolution
303
+ )
304
+
305
+ area = result[0]
306
+ transp = result[1]
307
+
308
+ # Adjust area for dimension shift (PALM: pc_box_area * sunorig(dimshift+1) / sunorig(1))
309
+ # dimshift+1 index in shifted array is uvec_0, original sunorig(1) is abs_z
310
+ if abs_z > 1e-10:
311
+ area = area * uvec_0 / abs_z
312
+
313
+ self.pc_box_area[None] = area
314
+
315
+ # Compute effective absorption coefficient
316
+ # pc_abs_eff = LOG(1 - pc_abs_frac) / prototype_lad = LOG(transp) / prototype_lad
317
+ abs_frac = 1.0 - transp
318
+ if abs_frac > 1e-10 and abs_frac < 1.0 - 1e-10:
319
+ self.pc_abs_eff[None] = ti.log(1.0 - abs_frac) / prototype_lad
320
+ else:
321
+ # Fallback for edge cases (very transparent or very opaque)
322
+ self.pc_abs_eff[None] = -self.ext_coef * boxsize_0 / uvec_0
323
+
324
+ @ti.kernel
325
+ def _compute_dsitransc(
326
+ self,
327
+ sun_dir: ti.types.vector(3, ti.f32),
328
+ is_solid: ti.template(),
329
+ lad: ti.template()
330
+ ):
331
+ """
332
+ Compute direct solar transmissivity to each canopy box (PALM's dsitransc).
333
+
334
+ Traces rays from each canopy box toward the sun to compute how much
335
+ direct radiation reaches that box.
336
+
337
+ Args:
338
+ sun_dir: Sun direction unit vector (pointing toward sun)
339
+ is_solid: 3D solid field
340
+ lad: 3D Leaf Area Density field
341
+ """
342
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
343
+ # Only compute for canopy cells
344
+ if lad[i, j, k] > 0.0:
345
+ # Start from center of this canopy box
346
+ pos = Vector3(
347
+ (i + 0.5) * self.dx,
348
+ (j + 0.5) * self.dy,
349
+ (k + 0.5) * self.dz
350
+ )
351
+
352
+ # Trace toward sun, accumulating opacity
353
+ domain_min = Vector3(0.0, 0.0, 0.0)
354
+ domain_max = Vector3(self.nx * self.dx, self.ny * self.dy, self.nz * self.dz)
355
+
356
+ cumulative_opacity = 0.0
357
+
358
+ # Start slightly offset in sun direction
359
+ t = 0.01
360
+ current_pos = pos + sun_dir * t
361
+
362
+ ci = ti.cast(ti.floor(current_pos[0] / self.dx), ti.i32)
363
+ cj = ti.cast(ti.floor(current_pos[1] / self.dy), ti.i32)
364
+ ck = ti.cast(ti.floor(current_pos[2] / self.dz), ti.i32)
365
+
366
+ ci = ti.max(0, ti.min(self.nx - 1, ci))
367
+ cj = ti.max(0, ti.min(self.ny - 1, cj))
368
+ ck = ti.max(0, ti.min(self.nz - 1, ck))
369
+
370
+ step_x = 1 if sun_dir[0] >= 0 else -1
371
+ step_y = 1 if sun_dir[1] >= 0 else -1
372
+ step_z = 1 if sun_dir[2] >= 0 else -1
373
+
374
+ t_max_x = 1e30
375
+ t_max_y = 1e30
376
+ t_max_z = 1e30
377
+ t_delta_x = 1e30
378
+ t_delta_y = 1e30
379
+ t_delta_z = 1e30
380
+
381
+ if ti.abs(sun_dir[0]) > 1e-10:
382
+ if step_x > 0:
383
+ t_max_x = ((ci + 1) * self.dx - current_pos[0]) / sun_dir[0] + t
384
+ else:
385
+ t_max_x = (ci * self.dx - current_pos[0]) / sun_dir[0] + t
386
+ t_delta_x = ti.abs(self.dx / sun_dir[0])
387
+
388
+ if ti.abs(sun_dir[1]) > 1e-10:
389
+ if step_y > 0:
390
+ t_max_y = ((cj + 1) * self.dy - current_pos[1]) / sun_dir[1] + t
391
+ else:
392
+ t_max_y = (cj * self.dy - current_pos[1]) / sun_dir[1] + t
393
+ t_delta_y = ti.abs(self.dy / sun_dir[1])
394
+
395
+ if ti.abs(sun_dir[2]) > 1e-10:
396
+ if step_z > 0:
397
+ t_max_z = ((ck + 1) * self.dz - current_pos[2]) / sun_dir[2] + t
398
+ else:
399
+ t_max_z = (ck * self.dz - current_pos[2]) / sun_dir[2] + t
400
+ t_delta_z = ti.abs(self.dz / sun_dir[2])
401
+
402
+ t_prev = t
403
+ max_steps = self.nx + self.ny + self.nz
404
+ hit_solid = 0
405
+
406
+ # GPU-optimized loop with done flag pattern
407
+ done = 0
408
+ for _ in range(max_steps):
409
+ if done == 0:
410
+ # Check bounds
411
+ if ci < 0 or ci >= self.nx or cj < 0 or cj >= self.ny or ck < 0 or ck >= self.nz:
412
+ done = 1
413
+ # Stop if hit solid
414
+ elif is_solid[ci, cj, ck] == 1:
415
+ hit_solid = 1
416
+ done = 1
417
+ else:
418
+ # Get path length through this cell using branchless min
419
+ t_next = ti.min(t_max_x, ti.min(t_max_y, t_max_z))
420
+ path_len = t_next - t_prev
421
+
422
+ # Accumulate opacity from LAD (skip the starting cell)
423
+ if not (ci == i and cj == j and ck == k):
424
+ cell_lad = lad[ci, cj, ck]
425
+ if cell_lad > 0.0:
426
+ cumulative_opacity += self.ext_coef * cell_lad * path_len
427
+
428
+ t_prev = t_next
429
+
430
+ # Step to next cell
431
+ if t_max_x < t_max_y and t_max_x < t_max_z:
432
+ ci += step_x
433
+ t_max_x += t_delta_x
434
+ elif t_max_y < t_max_z:
435
+ cj += step_y
436
+ t_max_y += t_delta_y
437
+ else:
438
+ ck += step_z
439
+ t_max_z += t_delta_z
440
+
441
+ # Store transmissivity (0 if hit solid)
442
+ if hit_solid == 1:
443
+ self.dsitransc[i, j, k] = 0.0
444
+ else:
445
+ self.dsitransc[i, j, k] = ti.exp(-cumulative_opacity)
446
+
447
+ @ti.kernel
448
+ def _compute_pcbinswdir_palm(
449
+ self,
450
+ lad: ti.template(),
451
+ incoming_flux: ti.f32,
452
+ grid_volume: ti.f32
453
+ ):
454
+ """
455
+ Compute canopy absorption using PALM's exact formula.
456
+
457
+ pcbinswdir = rad_sw_in_dir * pc_box_area * pc_abs_frac * dsitransc / grid_volume
458
+
459
+ Args:
460
+ lad: 3D Leaf Area Density field
461
+ incoming_flux: Incoming direct solar flux (W/m²)
462
+ grid_volume: Volume of one grid cell (m³)
463
+ """
464
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
465
+ cell_lad = lad[i, j, k]
466
+ if cell_lad > 0.0:
467
+ # PALM's formula: pc_abs_frac = 1 - exp(pc_abs_eff * lad)
468
+ pc_abs_frac = 1.0 - ti.exp(self.pc_abs_eff[None] * cell_lad)
469
+
470
+ # Absorbed power = flux * area * absorption_fraction * transmissivity_to_box
471
+ absorbed_power = (incoming_flux * self.pc_box_area[None] *
472
+ pc_abs_frac * self.dsitransc[i, j, k])
473
+
474
+ # Convert to W/m³
475
+ self.csf[i, j, k] = absorbed_power / grid_volume
476
+ else:
477
+ self.csf[i, j, k] = 0.0
478
+
479
+ def compute_canopy_absorption_direct_palm(
480
+ self,
481
+ sun_dir,
482
+ is_solid,
483
+ lad,
484
+ incoming_flux: float,
485
+ prototype_lad: float = PROTOTYPE_LAD,
486
+ mc_resolution: int = 60
487
+ ):
488
+ """
489
+ Compute direct solar canopy absorption using PALM's method.
490
+
491
+ This is the main entry point that follows PALM's approach:
492
+ 1. Compute box absorption parameters (pc_box_area, pc_abs_eff)
493
+ 2. Compute dsitransc (transmissivity to each canopy box)
494
+ 3. Compute absorption per box using PALM's formula
495
+
496
+ Args:
497
+ sun_dir: Sun direction vector (ti.Vector or list)
498
+ is_solid: 3D solid field
499
+ lad: 3D LAD field
500
+ incoming_flux: Direct solar flux (W/m²)
501
+ prototype_lad: Reference LAD for effective coefficient
502
+ mc_resolution: Monte Carlo resolution for box_absorb
503
+ """
504
+ # Convert sun_dir to ti.Vector if needed
505
+ if hasattr(sun_dir, '__len__') and not isinstance(sun_dir, ti.lang.matrix.VectorType):
506
+ sun_dir_vec = ti.Vector([float(sun_dir[0]), float(sun_dir[1]), float(sun_dir[2])])
507
+ else:
508
+ sun_dir_vec = sun_dir
509
+
510
+ grid_volume = self.dx * self.dy * self.dz
511
+
512
+ # Step 1: Compute box parameters
513
+ self._compute_box_params(sun_dir_vec, prototype_lad, mc_resolution)
514
+
515
+ # Step 2: Compute transmissivity to each canopy box
516
+ self._compute_dsitransc(sun_dir_vec, is_solid, lad)
517
+
518
+ # Step 3: Compute absorption using PALM's formula
519
+ self._compute_pcbinswdir_palm(lad, incoming_flux, grid_volume)
520
+
521
+ @ti.kernel
522
+ def _compute_pcbinswdif_palm(
523
+ self,
524
+ lad: ti.template(),
525
+ diffuse_flux: ti.f32,
526
+ pcbinswdif: ti.template()
527
+ ):
528
+ """
529
+ Compute diffuse canopy absorption using PALM's formula.
530
+
531
+ The csf_sky field contains the hemisphere-integrated absorption factor:
532
+ csf_sky = integral(trans_above * abs_in_cell * cos_elev * d_omega / pi)
533
+
534
+ This is dimensionless and represents the fraction of isotropic sky
535
+ radiance that gets absorbed by this cell.
536
+
537
+ To convert to absorbed power per unit volume:
538
+ absorbed_power = diffuse_flux * horizontal_area * csf_sky
539
+ pcbinswdif = absorbed_power / grid_volume
540
+ = diffuse_flux * dx * dy * csf_sky / (dx * dy * dz)
541
+ = diffuse_flux * csf_sky / dz
542
+
543
+ Args:
544
+ lad: 3D Leaf Area Density field
545
+ diffuse_flux: Diffuse sky flux (W/m²)
546
+ pcbinswdif: Output array for diffuse absorbed (W/m³)
547
+ """
548
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
549
+ if lad[i, j, k] > 0.0:
550
+ # Power absorbed = diffuse_flux * horizontal_area * csf_sky
551
+ # Rate (W/m³) = Power / grid_volume = diffuse_flux * csf_sky / dz
552
+ pcbinswdif[i, j, k] = self.csf_sky[i, j, k] * diffuse_flux / self.dz
553
+ else:
554
+ pcbinswdif[i, j, k] = 0.0
555
+
556
+ def compute_canopy_absorption_diffuse_palm(
557
+ self,
558
+ is_solid,
559
+ lad,
560
+ diffuse_flux: float,
561
+ pcbinswdif,
562
+ n_azimuth: int = None,
563
+ n_elevation: int = None
564
+ ):
565
+ """
566
+ Compute diffuse sky canopy absorption using PALM's method.
567
+
568
+ This computes:
569
+ 1. CSF from sky (if not already cached - geometry-dependent, computed once)
570
+ 2. Diffuse absorption using pcbinswdif = csf_sky * diffuse_flux * grid_volume_inverse
571
+
572
+ Args:
573
+ is_solid: 3D solid field
574
+ lad: 3D LAD field
575
+ diffuse_flux: Diffuse sky flux (W/m²)
576
+ pcbinswdif: Output array for diffuse absorbed (W/m³)
577
+ n_azimuth: Number of azimuthal divisions for sky CSF
578
+ n_elevation: Number of elevation divisions for sky CSF
579
+ """
580
+ n_azim = n_azimuth if n_azimuth is not None else self.n_azimuth
581
+ n_elev = n_elevation if n_elevation is not None else self.n_elevation
582
+
583
+ # Compute CSF from sky (this is isurfs = -1 in PALM)
584
+ # Use cached version if available (csf_sky is geometry-dependent only)
585
+ self.compute_csf_sky_cached(is_solid, lad, n_azim, n_elev)
586
+
587
+ # Compute diffuse absorption
588
+ self._compute_pcbinswdif_palm(lad, diffuse_flux, pcbinswdif)
589
+
590
+ def compute_csf_sky_cached(
591
+ self,
592
+ is_solid,
593
+ lad,
594
+ n_azim: int,
595
+ n_elev: int
596
+ ):
597
+ """
598
+ Compute CSF from sky with caching.
599
+
600
+ CSF sky is purely geometry-dependent (LAD + is_solid) and does not change
601
+ with sun position. This wrapper caches the result after first computation.
602
+
603
+ Args:
604
+ is_solid: 3D solid field
605
+ lad: 3D LAD field
606
+ n_azim: Number of azimuthal divisions
607
+ n_elev: Number of elevation divisions
608
+ """
609
+ # Check if already cached with same parameters
610
+ if (self._csf_sky_cached and
611
+ self._csf_sky_n_azim == n_azim and
612
+ self._csf_sky_n_elev == n_elev):
613
+ # Already computed, skip expensive ray tracing
614
+ return
615
+
616
+ # Compute CSF from sky (expensive ray tracing)
617
+ self.compute_csf_sky(is_solid, lad, n_azim, n_elev)
618
+
619
+ # Mark as cached
620
+ self._csf_sky_cached = True
621
+ self._csf_sky_n_azim = n_azim
622
+ self._csf_sky_n_elev = n_elev
623
+
624
+ def invalidate_csf_sky_cache(self):
625
+ """Invalidate the CSF sky cache (call if geometry changes)."""
626
+ self._csf_sky_cached = False
627
+ self._csf_sky_n_azim = 0
628
+ self._csf_sky_n_elev = 0
629
+
630
+ @ti.kernel
631
+ def compute_csf_sky(
632
+ self,
633
+ is_solid: ti.template(),
634
+ lad: ti.template(),
635
+ n_azim: ti.i32,
636
+ n_elev: ti.i32
637
+ ):
638
+ """
639
+ Compute canopy sink factors from sky (isurfs = -1 in PALM terminology).
640
+
641
+ For each canopy cell, traces rays toward the sky hemisphere and
642
+ computes the fraction of diffuse sky radiation that would be
643
+ absorbed by this cell.
644
+
645
+ The result is stored in csf_sky as a view factor × absorption fraction.
646
+ To get absorbed power: csf_sky[i,j,k] * diffuse_flux * horizontal_area / grid_volume
647
+
648
+ Args:
649
+ is_solid: 3D solid field
650
+ lad: 3D LAD field
651
+ n_azim: Number of azimuthal divisions
652
+ n_elev: Number of elevation divisions
653
+ """
654
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
655
+ cell_lad = lad[i, j, k]
656
+ if cell_lad > 0.0:
657
+ # Cell center position
658
+ pos = Vector3(
659
+ (i + 0.5) * self.dx,
660
+ (j + 0.5) * self.dy,
661
+ (k + 0.5) * self.dz
662
+ )
663
+
664
+ domain_min = Vector3(0.0, 0.0, 0.0)
665
+ domain_max = Vector3(self.nx * self.dx, self.ny * self.dy, self.nz * self.dz)
666
+
667
+ total_sky_factor = 0.0
668
+
669
+ # Trace rays to sky hemisphere
670
+ for i_azim, i_elev in ti.ndrange(n_azim, n_elev):
671
+ # Compute direction toward sky
672
+ elev_angle = (i_elev + 0.5) * (PI / 2.0) / n_elev
673
+ azim_angle = (i_azim + 0.5) * TWO_PI / n_azim
674
+
675
+ sin_elev = ti.sin(elev_angle)
676
+ cos_elev = ti.cos(elev_angle)
677
+
678
+ ray_dir = Vector3(
679
+ sin_elev * ti.sin(azim_angle),
680
+ sin_elev * ti.cos(azim_angle),
681
+ cos_elev # Upward
682
+ )
683
+
684
+ # Solid angle weight
685
+ elev_low = i_elev * (PI / 2.0) / n_elev
686
+ elev_high = (i_elev + 1) * (PI / 2.0) / n_elev
687
+ d_omega = (TWO_PI / n_azim) * (ti.cos(elev_low) - ti.cos(elev_high))
688
+
689
+ # Trace ray from cell toward sky (opposite direction for finding opacity)
690
+ cumulative_opacity_above = 0.0
691
+ blocked = 0
692
+
693
+ # Start from cell center and trace upward
694
+ t = 0.01
695
+ current_pos = pos + ray_dir * t
696
+
697
+ ci = ti.cast(ti.floor(current_pos[0] / self.dx), ti.i32)
698
+ cj = ti.cast(ti.floor(current_pos[1] / self.dy), ti.i32)
699
+ ck = ti.cast(ti.floor(current_pos[2] / self.dz), ti.i32)
700
+
701
+ step_x = 1 if ray_dir[0] >= 0 else -1
702
+ step_y = 1 if ray_dir[1] >= 0 else -1
703
+ step_z = 1 if ray_dir[2] >= 0 else -1
704
+
705
+ t_max_x, t_max_y, t_max_z = 1e30, 1e30, 1e30
706
+ t_delta_x, t_delta_y, t_delta_z = 1e30, 1e30, 1e30
707
+
708
+ if ti.abs(ray_dir[0]) > 1e-10:
709
+ if step_x > 0:
710
+ t_max_x = ((ci + 1) * self.dx - current_pos[0]) / ray_dir[0] + t
711
+ else:
712
+ t_max_x = (ci * self.dx - current_pos[0]) / ray_dir[0] + t
713
+ t_delta_x = ti.abs(self.dx / ray_dir[0])
714
+
715
+ if ti.abs(ray_dir[1]) > 1e-10:
716
+ if step_y > 0:
717
+ t_max_y = ((cj + 1) * self.dy - current_pos[1]) / ray_dir[1] + t
718
+ else:
719
+ t_max_y = (cj * self.dy - current_pos[1]) / ray_dir[1] + t
720
+ t_delta_y = ti.abs(self.dy / ray_dir[1])
721
+
722
+ if ti.abs(ray_dir[2]) > 1e-10:
723
+ if step_z > 0:
724
+ t_max_z = ((ck + 1) * self.dz - current_pos[2]) / ray_dir[2] + t
725
+ else:
726
+ t_max_z = (ck * self.dz - current_pos[2]) / ray_dir[2] + t
727
+ t_delta_z = ti.abs(self.dz / ray_dir[2])
728
+
729
+ t_prev = t
730
+ max_steps = self.nx + self.ny + self.nz
731
+
732
+ for _ in range(max_steps):
733
+ if ci < 0 or ci >= self.nx or cj < 0 or cj >= self.ny:
734
+ break
735
+ if ck >= self.nz: # Reached top of domain (sky)
736
+ break
737
+ if ck < 0: # Went below domain
738
+ blocked = 1
739
+ break
740
+
741
+ if is_solid[ci, cj, ck] == 1:
742
+ blocked = 1
743
+ break
744
+
745
+ t_next = ti.min(t_max_x, ti.min(t_max_y, t_max_z))
746
+ path_len = t_next - t_prev
747
+
748
+ # Accumulate opacity from LAD above this cell
749
+ if not (ci == i and cj == j and ck == k):
750
+ above_lad = lad[ci, cj, ck]
751
+ if above_lad > 0.0:
752
+ cumulative_opacity_above += self.ext_coef * above_lad * path_len
753
+
754
+ t_prev = t_next
755
+
756
+ if t_max_x < t_max_y and t_max_x < t_max_z:
757
+ ci += step_x
758
+ t_max_x += t_delta_x
759
+ elif t_max_y < t_max_z:
760
+ cj += step_y
761
+ t_max_y += t_delta_y
762
+ else:
763
+ ck += step_z
764
+ t_max_z += t_delta_z
765
+
766
+ if blocked == 0:
767
+ # Transmissivity from sky to this cell
768
+ trans_to_cell = ti.exp(-cumulative_opacity_above)
769
+
770
+ # Path length through this cell (approximate)
771
+ path_in_cell = self.dz / cos_elev if cos_elev > 0.1 else self.dz * 10.0
772
+
773
+ # Absorption in this cell
774
+ abs_in_cell = 1.0 - ti.exp(-self.ext_coef * cell_lad * path_in_cell)
775
+
776
+ # Contribution from this sky direction
777
+ # Weight by solid angle and cosine (Lambertian sky)
778
+ total_sky_factor += trans_to_cell * abs_in_cell * d_omega * cos_elev / PI
779
+
780
+ self.csf_sky[i, j, k] = total_sky_factor
781
+ else:
782
+ self.csf_sky[i, j, k] = 0.0
783
+
784
+ @ti.kernel
785
+ def compute_csf_direct(
786
+ self,
787
+ surf_pos: ti.template(),
788
+ surf_area: ti.template(),
789
+ sun_dir: ti.types.vector(3, ti.f32),
790
+ is_solid: ti.template(),
791
+ lad: ti.template(),
792
+ n_surf: ti.i32,
793
+ incoming_flux: ti.f32
794
+ ):
795
+ """
796
+ Compute CSF for direct solar radiation.
797
+
798
+ Traces rays from surfaces toward sun and accumulates absorption
799
+ in each canopy cell along the path.
800
+
801
+ Args:
802
+ surf_pos: Surface positions
803
+ surf_area: Surface areas
804
+ sun_dir: Sun direction unit vector
805
+ is_solid: 3D solid field
806
+ lad: 3D Leaf Area Density field
807
+ n_surf: Number of surfaces
808
+ incoming_flux: Incoming direct solar flux (W/m²)
809
+ """
810
+ for surf_i in range(n_surf):
811
+ pos = Vector3(surf_pos[surf_i][0], surf_pos[surf_i][1], surf_pos[surf_i][2])
812
+ area = surf_area[surf_i]
813
+
814
+ # Total power from this surface toward sun
815
+ power = incoming_flux * area
816
+
817
+ # Find entry into domain
818
+ domain_min = Vector3(0.0, 0.0, 0.0)
819
+ domain_max = Vector3(self.nx * self.dx, self.ny * self.dy, self.nz * self.dz)
820
+
821
+ in_domain, t_enter, t_exit = ray_aabb_intersect(
822
+ pos, sun_dir, domain_min, domain_max, 0.0, self.max_dist
823
+ )
824
+
825
+ if in_domain == 1:
826
+ t = 1e-5 # Start slightly above surface
827
+ cumulative_opacity = 0.0
828
+
829
+ # 3D-DDA traversal
830
+ step_x = 1 if sun_dir[0] >= 0 else -1
831
+ step_y = 1 if sun_dir[1] >= 0 else -1
832
+ step_z = 1 if sun_dir[2] >= 0 else -1
833
+
834
+ current_pos = pos + sun_dir * t
835
+
836
+ ix = ti.cast(ti.floor(current_pos[0] / self.dx), ti.i32)
837
+ iy = ti.cast(ti.floor(current_pos[1] / self.dy), ti.i32)
838
+ iz = ti.cast(ti.floor(current_pos[2] / self.dz), ti.i32)
839
+
840
+ ix = ti.max(0, ti.min(self.nx - 1, ix))
841
+ iy = ti.max(0, ti.min(self.ny - 1, iy))
842
+ iz = ti.max(0, ti.min(self.nz - 1, iz))
843
+
844
+ # Initialize t_max values (must be before branching for Taichi)
845
+ t_max_x = 1e30
846
+ t_max_y = 1e30
847
+ t_max_z = 1e30
848
+ t_delta_x = 1e30
849
+ t_delta_y = 1e30
850
+ t_delta_z = 1e30
851
+
852
+ if ti.abs(sun_dir[0]) > 1e-10:
853
+ if step_x > 0:
854
+ t_max_x = ((ix + 1) * self.dx - current_pos[0]) / sun_dir[0] + t
855
+ else:
856
+ t_max_x = (ix * self.dx - current_pos[0]) / sun_dir[0] + t
857
+ t_delta_x = ti.abs(self.dx / sun_dir[0])
858
+
859
+ if ti.abs(sun_dir[1]) > 1e-10:
860
+ if step_y > 0:
861
+ t_max_y = ((iy + 1) * self.dy - current_pos[1]) / sun_dir[1] + t
862
+ else:
863
+ t_max_y = (iy * self.dy - current_pos[1]) / sun_dir[1] + t
864
+ t_delta_y = ti.abs(self.dy / sun_dir[1])
865
+
866
+ if ti.abs(sun_dir[2]) > 1e-10:
867
+ if step_z > 0:
868
+ t_max_z = ((iz + 1) * self.dz - current_pos[2]) / sun_dir[2] + t
869
+ else:
870
+ t_max_z = (iz * self.dz - current_pos[2]) / sun_dir[2] + t
871
+ t_delta_z = ti.abs(self.dz / sun_dir[2])
872
+
873
+ t_prev = t
874
+ max_steps = self.nx + self.ny + self.nz
875
+
876
+ for _ in range(max_steps):
877
+ if ix < 0 or ix >= self.nx or iy < 0 or iy >= self.ny or iz < 0 or iz >= self.nz:
878
+ break
879
+ if t > t_exit:
880
+ break
881
+
882
+ # Stop at solid obstacle
883
+ if is_solid[ix, iy, iz] == 1:
884
+ break
885
+
886
+ # Find next t
887
+ t_next = ti.min(t_max_x, ti.min(t_max_y, t_max_z))
888
+ path_len = t_next - t_prev
889
+
890
+ # Process canopy cell
891
+ cell_lad = lad[ix, iy, iz]
892
+ if cell_lad > 0.0:
893
+ # Transmissivity to this point
894
+ trans_before = ti.exp(-cumulative_opacity)
895
+
896
+ # Opacity through this cell
897
+ cell_opacity = self.ext_coef * cell_lad * path_len
898
+
899
+ # Transmissivity after this cell
900
+ trans_after = ti.exp(-(cumulative_opacity + cell_opacity))
901
+
902
+ # Absorbed fraction in this cell
903
+ absorbed_frac = trans_before - trans_after
904
+
905
+ # Add to CSF (atomic add for thread safety)
906
+ ti.atomic_add(self.csf[ix, iy, iz], absorbed_frac * power)
907
+ ti.atomic_add(self.lad_path[ix, iy, iz], cell_lad * path_len)
908
+
909
+ # Update cumulative opacity
910
+ cumulative_opacity += cell_opacity
911
+
912
+ t_prev = t_next
913
+
914
+ # Step to next voxel
915
+ if t_max_x < t_max_y and t_max_x < t_max_z:
916
+ t = t_max_x
917
+ ix += step_x
918
+ t_max_x += t_delta_x
919
+ elif t_max_y < t_max_z:
920
+ t = t_max_y
921
+ iy += step_y
922
+ t_max_y += t_delta_y
923
+ else:
924
+ t = t_max_z
925
+ iz += step_z
926
+ t_max_z += t_delta_z
927
+
928
+ @ti.kernel
929
+ def compute_csf_diffuse_hemisphere(
930
+ self,
931
+ surf_pos: ti.template(),
932
+ surf_dir: ti.template(),
933
+ surf_area: ti.template(),
934
+ is_solid: ti.template(),
935
+ lad: ti.template(),
936
+ n_surf: ti.i32,
937
+ diffuse_flux: ti.f32,
938
+ n_azim: ti.i32,
939
+ n_elev: ti.i32
940
+ ):
941
+ """
942
+ Compute CSF for diffuse sky radiation.
943
+
944
+ Traces rays from surfaces to multiple sky directions.
945
+ """
946
+ for surf_i in range(n_surf):
947
+ pos = Vector3(surf_pos[surf_i][0], surf_pos[surf_i][1], surf_pos[surf_i][2])
948
+ direction = surf_dir[surf_i]
949
+ area = surf_area[surf_i]
950
+
951
+ # Get surface normal
952
+ normal = Vector3(0.0, 0.0, 0.0)
953
+ if direction == 0:
954
+ normal = Vector3(0.0, 0.0, 1.0)
955
+ elif direction == 1:
956
+ normal = Vector3(0.0, 0.0, -1.0)
957
+ elif direction == 2:
958
+ normal = Vector3(0.0, 1.0, 0.0)
959
+ elif direction == 3:
960
+ normal = Vector3(0.0, -1.0, 0.0)
961
+ elif direction == 4:
962
+ normal = Vector3(1.0, 0.0, 0.0)
963
+ elif direction == 5:
964
+ normal = Vector3(-1.0, 0.0, 0.0)
965
+
966
+ domain_min = Vector3(0.0, 0.0, 0.0)
967
+ domain_max = Vector3(self.nx * self.dx, self.ny * self.dy, self.nz * self.dz)
968
+
969
+ # Loop over hemisphere directions
970
+ for i_azim, i_elev in ti.ndrange(n_azim, n_elev):
971
+ # Compute direction
972
+ elev_angle = (i_elev + 0.5) * (PI / 2.0) / n_elev
973
+ azim_angle = (i_azim + 0.5) * TWO_PI / n_azim
974
+
975
+ sin_elev = ti.sin(elev_angle)
976
+ cos_elev = ti.cos(elev_angle)
977
+
978
+ ray_dir = Vector3(
979
+ sin_elev * ti.sin(azim_angle),
980
+ sin_elev * ti.cos(azim_angle),
981
+ cos_elev
982
+ )
983
+
984
+ # Solid angle
985
+ elev_low = i_elev * (PI / 2.0) / n_elev
986
+ elev_high = (i_elev + 1) * (PI / 2.0) / n_elev
987
+ d_omega = (TWO_PI / n_azim) * (ti.cos(elev_low) - ti.cos(elev_high))
988
+
989
+ # Check if direction is valid for this surface
990
+ cos_angle = (ray_dir[0] * normal[0] + ray_dir[1] * normal[1] +
991
+ ray_dir[2] * normal[2])
992
+
993
+ if cos_angle > 0:
994
+ # Fraction of diffuse flux from this direction
995
+ dir_flux = diffuse_flux * cos_angle * d_omega / PI * area
996
+
997
+ # Ray trace with CSF accumulation
998
+ in_domain, t_enter, t_exit = ray_aabb_intersect(
999
+ pos, ray_dir, domain_min, domain_max, 0.0, self.max_dist
1000
+ )
1001
+
1002
+ if in_domain == 1:
1003
+ t = 1e-5
1004
+ cumulative_opacity = 0.0
1005
+
1006
+ current_pos = pos + ray_dir * t
1007
+
1008
+ ix = ti.cast(ti.floor(current_pos[0] / self.dx), ti.i32)
1009
+ iy = ti.cast(ti.floor(current_pos[1] / self.dy), ti.i32)
1010
+ iz = ti.cast(ti.floor(current_pos[2] / self.dz), ti.i32)
1011
+
1012
+ ix = ti.max(0, ti.min(self.nx - 1, ix))
1013
+ iy = ti.max(0, ti.min(self.ny - 1, iy))
1014
+ iz = ti.max(0, ti.min(self.nz - 1, iz))
1015
+
1016
+ step_x = 1 if ray_dir[0] >= 0 else -1
1017
+ step_y = 1 if ray_dir[1] >= 0 else -1
1018
+ step_z = 1 if ray_dir[2] >= 0 else -1
1019
+
1020
+ # Initialize t_max values before branching (for Taichi)
1021
+ t_max_x = 1e30
1022
+ t_max_y = 1e30
1023
+ t_max_z = 1e30
1024
+ t_delta_x = 1e30
1025
+ t_delta_y = 1e30
1026
+ t_delta_z = 1e30
1027
+
1028
+ if ti.abs(ray_dir[0]) > 1e-10:
1029
+ if step_x > 0:
1030
+ t_max_x = ((ix + 1) * self.dx - current_pos[0]) / ray_dir[0] + t
1031
+ else:
1032
+ t_max_x = (ix * self.dx - current_pos[0]) / ray_dir[0] + t
1033
+ t_delta_x = ti.abs(self.dx / ray_dir[0])
1034
+
1035
+ if ti.abs(ray_dir[1]) > 1e-10:
1036
+ if step_y > 0:
1037
+ t_max_y = ((iy + 1) * self.dy - current_pos[1]) / ray_dir[1] + t
1038
+ else:
1039
+ t_max_y = (iy * self.dy - current_pos[1]) / ray_dir[1] + t
1040
+ t_delta_y = ti.abs(self.dy / ray_dir[1])
1041
+
1042
+ if ti.abs(ray_dir[2]) > 1e-10:
1043
+ if step_z > 0:
1044
+ t_max_z = ((iz + 1) * self.dz - current_pos[2]) / ray_dir[2] + t
1045
+ else:
1046
+ t_max_z = (iz * self.dz - current_pos[2]) / ray_dir[2] + t
1047
+ t_delta_z = ti.abs(self.dz / ray_dir[2])
1048
+
1049
+ t_prev = t
1050
+ max_steps = self.nx + self.ny + self.nz
1051
+
1052
+ for _ in range(max_steps):
1053
+ if ix < 0 or ix >= self.nx or iy < 0 or iy >= self.ny or iz < 0 or iz >= self.nz:
1054
+ break
1055
+ if t > t_exit:
1056
+ break
1057
+ if is_solid[ix, iy, iz] == 1:
1058
+ break
1059
+
1060
+ t_next = ti.min(t_max_x, ti.min(t_max_y, t_max_z))
1061
+ path_len = t_next - t_prev
1062
+
1063
+ cell_lad = lad[ix, iy, iz]
1064
+ if cell_lad > 0.0:
1065
+ trans_before = ti.exp(-cumulative_opacity)
1066
+ cell_opacity = self.ext_coef * cell_lad * path_len
1067
+ trans_after = ti.exp(-(cumulative_opacity + cell_opacity))
1068
+ absorbed_frac = trans_before - trans_after
1069
+
1070
+ ti.atomic_add(self.csf[ix, iy, iz], absorbed_frac * dir_flux)
1071
+ cumulative_opacity += cell_opacity
1072
+
1073
+ t_prev = t_next
1074
+
1075
+ if t_max_x < t_max_y and t_max_x < t_max_z:
1076
+ t = t_max_x
1077
+ ix += step_x
1078
+ t_max_x += t_delta_x
1079
+ elif t_max_y < t_max_z:
1080
+ t = t_max_y
1081
+ iy += step_y
1082
+ t_max_y += t_delta_y
1083
+ else:
1084
+ t = t_max_z
1085
+ iz += step_z
1086
+ t_max_z += t_delta_z
1087
+
1088
+ @ti.kernel
1089
+ def compute_canopy_absorption_direct(
1090
+ self,
1091
+ sun_dir: ti.types.vector(3, ti.f32),
1092
+ is_solid: ti.template(),
1093
+ lad: ti.template(),
1094
+ incoming_flux: ti.f32
1095
+ ):
1096
+ """
1097
+ Compute direct solar absorption in canopy by tracing rays from sky.
1098
+
1099
+ This traces ONE ray per column from the top of the domain downward,
1100
+ following the sun direction. This correctly computes absorption without
1101
+ overcounting from multiple surfaces.
1102
+
1103
+ The result is stored in self.csf as total absorbed power (W) per cell.
1104
+ To convert to PALM-compatible W/m³, divide by grid_volume:
1105
+ pcbinswdir = csf[i,j,k] * grid_volume_inverse
1106
+
1107
+ Note: This is an alternative method to compute_canopy_absorption_direct_palm
1108
+ which directly follows PALM's box_absorb methodology.
1109
+
1110
+ Args:
1111
+ sun_dir: Sun direction unit vector (pointing toward sun)
1112
+ is_solid: 3D solid field
1113
+ lad: 3D Leaf Area Density field
1114
+ incoming_flux: Incoming direct solar flux (W/m²)
1115
+ """
1116
+ # Trace from each (i,j) column on the top of the domain
1117
+ for ix, iy in ti.ndrange(self.nx, self.ny):
1118
+ # Start position at top of domain
1119
+ start_x = (ix + 0.5) * self.dx
1120
+ start_y = (iy + 0.5) * self.dy
1121
+ start_z = self.nz * self.dz - 0.01 # Just below top
1122
+
1123
+ pos = Vector3(start_x, start_y, start_z)
1124
+
1125
+ # Ray direction: opposite of sun direction (tracing FROM sun)
1126
+ ray_dir = Vector3(-sun_dir[0], -sun_dir[1], -sun_dir[2])
1127
+
1128
+ # Only trace if sun is above horizon (ray goes down)
1129
+ if ray_dir[2] < 0:
1130
+ domain_min = Vector3(0.0, 0.0, 0.0)
1131
+ domain_max = Vector3(self.nx * self.dx, self.ny * self.dy, self.nz * self.dz)
1132
+
1133
+ in_domain, t_enter, t_exit = ray_aabb_intersect(
1134
+ pos, ray_dir, domain_min, domain_max, 0.0, self.max_dist
1135
+ )
1136
+
1137
+ if in_domain == 1:
1138
+ t = 0.01 # Start tracing
1139
+ cumulative_opacity = 0.0
1140
+
1141
+ # Initialize position
1142
+ ci = ix
1143
+ cj = iy
1144
+ ck = ti.cast(ti.floor((pos[2] + ray_dir[2] * t) / self.dz), ti.i32)
1145
+ ck = ti.max(0, ti.min(self.nz - 1, ck))
1146
+
1147
+ step_x = 1 if ray_dir[0] >= 0 else -1
1148
+ step_y = 1 if ray_dir[1] >= 0 else -1
1149
+ step_z = -1 # Always going down
1150
+
1151
+ # Initialize t_max values
1152
+ t_max_x = 1e30
1153
+ t_max_y = 1e30
1154
+ t_max_z = 1e30
1155
+ t_delta_x = 1e30
1156
+ t_delta_y = 1e30
1157
+ t_delta_z = 1e30
1158
+
1159
+ current_pos = pos + ray_dir * t
1160
+
1161
+ if ti.abs(ray_dir[0]) > 1e-10:
1162
+ if step_x > 0:
1163
+ t_max_x = ((ci + 1) * self.dx - current_pos[0]) / ray_dir[0] + t
1164
+ else:
1165
+ t_max_x = (ci * self.dx - current_pos[0]) / ray_dir[0] + t
1166
+ t_delta_x = ti.abs(self.dx / ray_dir[0])
1167
+
1168
+ if ti.abs(ray_dir[1]) > 1e-10:
1169
+ if step_y > 0:
1170
+ t_max_y = ((cj + 1) * self.dy - current_pos[1]) / ray_dir[1] + t
1171
+ else:
1172
+ t_max_y = (cj * self.dy - current_pos[1]) / ray_dir[1] + t
1173
+ t_delta_y = ti.abs(self.dy / ray_dir[1])
1174
+
1175
+ if ti.abs(ray_dir[2]) > 1e-10:
1176
+ # Always step_z = -1 (going down)
1177
+ t_max_z = (ck * self.dz - current_pos[2]) / ray_dir[2] + t
1178
+ t_delta_z = ti.abs(self.dz / ray_dir[2])
1179
+
1180
+ t_prev = t
1181
+ max_steps = self.nx + self.ny + self.nz
1182
+
1183
+ # Cross-sectional area of the cell perpendicular to sun
1184
+ # For a cell of size dx*dy, when sun is at zenith angle θ:
1185
+ # The horizontal area is dx*dy, flux is per horizontal m²
1186
+ # So power through cell = flux * dx * dy
1187
+ cell_area = self.dx * self.dy
1188
+
1189
+ for _ in range(max_steps):
1190
+ if ci < 0 or ci >= self.nx or cj < 0 or cj >= self.ny or ck < 0 or ck >= self.nz:
1191
+ break
1192
+
1193
+ # Stop at solid obstacle
1194
+ if is_solid[ci, cj, ck] == 1:
1195
+ break
1196
+
1197
+ # Find next t
1198
+ t_next = ti.min(t_max_x, ti.min(t_max_y, t_max_z))
1199
+ path_len = t_next - t_prev
1200
+
1201
+ # Process canopy cell
1202
+ cell_lad = lad[ci, cj, ck]
1203
+ if cell_lad > 0.0:
1204
+ trans_before = ti.exp(-cumulative_opacity)
1205
+ cell_opacity = self.ext_coef * cell_lad * path_len
1206
+ trans_after = ti.exp(-(cumulative_opacity + cell_opacity))
1207
+ absorbed_frac = trans_before - trans_after
1208
+
1209
+ # Power absorbed = flux * area * absorbed_frac
1210
+ # Store as power (W) - will be divided by volume later
1211
+ ti.atomic_add(self.csf[ci, cj, ck], absorbed_frac * incoming_flux * cell_area)
1212
+
1213
+ cumulative_opacity += cell_opacity
1214
+
1215
+ t_prev = t_next
1216
+
1217
+ # Step to next voxel
1218
+ if t_max_x < t_max_y and t_max_x < t_max_z:
1219
+ t = t_max_x
1220
+ ci += step_x
1221
+ t_max_x += t_delta_x
1222
+ elif t_max_y < t_max_z:
1223
+ t = t_max_y
1224
+ cj += step_y
1225
+ t_max_y += t_delta_y
1226
+ else:
1227
+ t = t_max_z
1228
+ ck += step_z
1229
+ t_max_z += t_delta_z
1230
+
1231
+ def get_csf_numpy(self):
1232
+ """Get CSF field as numpy array (units depend on computation method)."""
1233
+ return self.csf.to_numpy()
1234
+
1235
+ def get_csf_wm3(self):
1236
+ """
1237
+ Get CSF field as numpy array in W/m³ (PALM-compatible units).
1238
+
1239
+ This divides the stored values by grid_volume to ensure consistent units.
1240
+ """
1241
+ return self.csf.to_numpy() * self.grid_volume_inverse
1242
+
1243
+ def get_total_canopy_absorption(self) -> float:
1244
+ """
1245
+ Get total radiation absorbed by canopy (W).
1246
+
1247
+ For PALM-compatible pcbinsw in W/m³, use get_csf_wm3().
1248
+ """
1249
+ return float(self.csf.to_numpy().sum())