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