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