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.
- voxcity/__init__.py +14 -14
- voxcity/downloader/ocean.py +559 -0
- voxcity/exporter/__init__.py +12 -12
- voxcity/exporter/cityles.py +633 -633
- voxcity/exporter/envimet.py +733 -728
- voxcity/exporter/magicavoxel.py +333 -333
- voxcity/exporter/netcdf.py +238 -238
- voxcity/exporter/obj.py +1480 -1480
- voxcity/generator/__init__.py +47 -44
- voxcity/generator/api.py +727 -675
- voxcity/generator/grids.py +394 -379
- voxcity/generator/io.py +94 -94
- voxcity/generator/pipeline.py +582 -282
- voxcity/generator/update.py +429 -0
- voxcity/generator/voxelizer.py +18 -6
- voxcity/geoprocessor/__init__.py +75 -75
- voxcity/geoprocessor/draw.py +1494 -1219
- voxcity/geoprocessor/merge_utils.py +91 -91
- voxcity/geoprocessor/mesh.py +806 -806
- voxcity/geoprocessor/network.py +708 -708
- voxcity/geoprocessor/raster/__init__.py +2 -0
- voxcity/geoprocessor/raster/buildings.py +435 -428
- voxcity/geoprocessor/raster/core.py +31 -0
- voxcity/geoprocessor/raster/export.py +93 -93
- voxcity/geoprocessor/raster/landcover.py +178 -51
- voxcity/geoprocessor/raster/raster.py +1 -1
- voxcity/geoprocessor/utils.py +824 -824
- voxcity/models.py +115 -113
- voxcity/simulator/solar/__init__.py +66 -43
- voxcity/simulator/solar/integration.py +336 -336
- voxcity/simulator/solar/sky.py +668 -0
- voxcity/simulator/solar/temporal.py +792 -434
- voxcity/simulator_gpu/__init__.py +115 -0
- voxcity/simulator_gpu/common/__init__.py +9 -0
- voxcity/simulator_gpu/common/geometry.py +11 -0
- voxcity/simulator_gpu/core.py +322 -0
- voxcity/simulator_gpu/domain.py +262 -0
- voxcity/simulator_gpu/environment.yml +11 -0
- voxcity/simulator_gpu/init_taichi.py +154 -0
- voxcity/simulator_gpu/integration.py +15 -0
- voxcity/simulator_gpu/kernels.py +56 -0
- voxcity/simulator_gpu/radiation.py +28 -0
- voxcity/simulator_gpu/raytracing.py +623 -0
- voxcity/simulator_gpu/sky.py +9 -0
- voxcity/simulator_gpu/solar/__init__.py +178 -0
- voxcity/simulator_gpu/solar/core.py +66 -0
- voxcity/simulator_gpu/solar/csf.py +1249 -0
- voxcity/simulator_gpu/solar/domain.py +561 -0
- voxcity/simulator_gpu/solar/epw.py +421 -0
- voxcity/simulator_gpu/solar/integration.py +2953 -0
- voxcity/simulator_gpu/solar/radiation.py +3019 -0
- voxcity/simulator_gpu/solar/raytracing.py +686 -0
- voxcity/simulator_gpu/solar/reflection.py +533 -0
- voxcity/simulator_gpu/solar/sky.py +907 -0
- voxcity/simulator_gpu/solar/solar.py +337 -0
- voxcity/simulator_gpu/solar/svf.py +446 -0
- voxcity/simulator_gpu/solar/volumetric.py +1151 -0
- voxcity/simulator_gpu/solar/voxcity.py +2953 -0
- voxcity/simulator_gpu/temporal.py +13 -0
- voxcity/simulator_gpu/utils.py +25 -0
- voxcity/simulator_gpu/view.py +32 -0
- voxcity/simulator_gpu/visibility/__init__.py +109 -0
- voxcity/simulator_gpu/visibility/geometry.py +278 -0
- voxcity/simulator_gpu/visibility/integration.py +808 -0
- voxcity/simulator_gpu/visibility/landmark.py +753 -0
- voxcity/simulator_gpu/visibility/view.py +944 -0
- voxcity/utils/__init__.py +11 -0
- voxcity/utils/classes.py +194 -0
- voxcity/utils/lc.py +80 -39
- voxcity/utils/shape.py +230 -0
- voxcity/visualizer/__init__.py +24 -24
- voxcity/visualizer/builder.py +43 -43
- voxcity/visualizer/grids.py +141 -141
- voxcity/visualizer/maps.py +187 -187
- voxcity/visualizer/renderer.py +1146 -928
- {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/METADATA +56 -52
- voxcity-1.0.13.dist-info/RECORD +116 -0
- voxcity-0.7.0.dist-info/RECORD +0 -77
- {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
- {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())
|