voxcity 1.0.13__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/simulator/solar/__init__.py +13 -0
- voxcity/simulator_gpu/__init__.py +73 -98
- voxcity/simulator_gpu/domain.py +30 -256
- voxcity/simulator_gpu/raytracing.py +153 -0
- voxcity/simulator_gpu/solar/__init__.py +45 -1
- voxcity/simulator_gpu/solar/domain.py +57 -0
- voxcity/simulator_gpu/solar/integration.py +1622 -253
- voxcity/simulator_gpu/solar/mask.py +459 -0
- voxcity/simulator_gpu/solar/raytracing.py +28 -532
- voxcity/simulator_gpu/solar/volumetric.py +962 -14
- {voxcity-1.0.13.dist-info → voxcity-1.0.15.dist-info}/METADATA +1 -1
- {voxcity-1.0.13.dist-info → voxcity-1.0.15.dist-info}/RECORD +15 -25
- voxcity/simulator_gpu/common/__init__.py +0 -9
- voxcity/simulator_gpu/common/geometry.py +0 -11
- voxcity/simulator_gpu/environment.yml +0 -11
- voxcity/simulator_gpu/integration.py +0 -15
- voxcity/simulator_gpu/kernels.py +0 -56
- voxcity/simulator_gpu/radiation.py +0 -28
- voxcity/simulator_gpu/sky.py +0 -9
- voxcity/simulator_gpu/solar/voxcity.py +0 -2953
- voxcity/simulator_gpu/temporal.py +0 -13
- voxcity/simulator_gpu/utils.py +0 -25
- voxcity/simulator_gpu/view.py +0 -32
- {voxcity-1.0.13.dist-info → voxcity-1.0.15.dist-info}/WHEEL +0 -0
- {voxcity-1.0.13.dist-info → voxcity-1.0.15.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-1.0.13.dist-info → voxcity-1.0.15.dist-info}/licenses/LICENSE +0 -0
|
@@ -117,6 +117,87 @@ class VolumetricFluxCalculator:
|
|
|
117
117
|
|
|
118
118
|
# Flag for computed state
|
|
119
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
|
|
120
201
|
|
|
121
202
|
@ti.kernel
|
|
122
203
|
def _init_azimuth_directions(self):
|
|
@@ -946,6 +1027,9 @@ class VolumetricFluxCalculator:
|
|
|
946
1027
|
For each grid cell, integrates reflected radiation from all visible
|
|
947
1028
|
surfaces weighted by view factor and transmissivity.
|
|
948
1029
|
|
|
1030
|
+
Uses a max distance cutoff for performance - view factor drops as 1/d²,
|
|
1031
|
+
so distant surfaces contribute negligibly.
|
|
1032
|
+
|
|
949
1033
|
Args:
|
|
950
1034
|
n_surfaces: Number of surface elements
|
|
951
1035
|
surf_center: Surface center positions (n_surfaces, 3)
|
|
@@ -960,6 +1044,10 @@ class VolumetricFluxCalculator:
|
|
|
960
1044
|
# flux = Σ (surfout * area * transmissivity * cos_angle) / (4 * π * dist²)
|
|
961
1045
|
# The factor 0.25 accounts for sphere geometry (projected area / surface area)
|
|
962
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
|
+
|
|
963
1051
|
for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
|
|
964
1052
|
# Skip solid cells
|
|
965
1053
|
if is_solid[i, j, k] == 1:
|
|
@@ -980,18 +1068,20 @@ class VolumetricFluxCalculator:
|
|
|
980
1068
|
surf_x = surf_center[surf_idx][0]
|
|
981
1069
|
surf_y = surf_center[surf_idx][1]
|
|
982
1070
|
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
1071
|
|
|
988
|
-
# Distance to surface
|
|
1072
|
+
# Distance to surface - early exit for distant surfaces
|
|
989
1073
|
dx = cell_x - surf_x
|
|
990
1074
|
dy = cell_y - surf_y
|
|
991
1075
|
dz = cell_z - surf_z
|
|
992
1076
|
dist_sq = dx*dx + dy*dy + dz*dz
|
|
993
1077
|
|
|
994
|
-
if
|
|
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
|
+
|
|
995
1085
|
dist = ti.sqrt(dist_sq)
|
|
996
1086
|
|
|
997
1087
|
# Direction from surface to cell (normalized)
|
|
@@ -1030,6 +1120,10 @@ class VolumetricFluxCalculator:
|
|
|
1030
1120
|
This propagates reflected radiation from surfaces into the 3D volume.
|
|
1031
1121
|
Should be called after surface reflection calculations are complete.
|
|
1032
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
|
+
|
|
1033
1127
|
Args:
|
|
1034
1128
|
surfaces: Surfaces object with geometry (center, normal, area)
|
|
1035
1129
|
surf_outgoing: Array of surface outgoing radiation (W/m²)
|
|
@@ -1038,17 +1132,19 @@ class VolumetricFluxCalculator:
|
|
|
1038
1132
|
n_surfaces = surfaces.n_surfaces[None]
|
|
1039
1133
|
|
|
1040
1134
|
if n_surfaces == 0:
|
|
1041
|
-
print("Warning: No surfaces defined, skipping reflected flux calculation")
|
|
1042
1135
|
return
|
|
1043
1136
|
|
|
1044
|
-
#
|
|
1045
|
-
|
|
1046
|
-
|
|
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))
|
|
1047
1145
|
|
|
1048
1146
|
has_lad = 1 if self.domain.lad is not None else 0
|
|
1049
1147
|
|
|
1050
|
-
print(f"Computing volumetric reflected flux from {n_surfaces} surfaces...")
|
|
1051
|
-
|
|
1052
1148
|
if has_lad:
|
|
1053
1149
|
self._compute_reflected_flux_kernel(
|
|
1054
1150
|
n_surfaces,
|
|
@@ -1071,8 +1167,6 @@ class VolumetricFluxCalculator:
|
|
|
1071
1167
|
self.domain.lad,
|
|
1072
1168
|
0
|
|
1073
1169
|
)
|
|
1074
|
-
|
|
1075
|
-
print("Volumetric reflected flux computation complete.")
|
|
1076
1170
|
|
|
1077
1171
|
@ti.kernel
|
|
1078
1172
|
def _add_reflected_to_total(self):
|
|
@@ -1086,6 +1180,136 @@ class VolumetricFluxCalculator:
|
|
|
1086
1180
|
for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
|
|
1087
1181
|
self.swflux_reflected_vol[i, j, k] = 0.0
|
|
1088
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
|
+
|
|
1089
1313
|
def compute_swflux_vol_with_reflections(
|
|
1090
1314
|
self,
|
|
1091
1315
|
sw_direct: float,
|
|
@@ -1149,3 +1373,727 @@ class VolumetricFluxCalculator:
|
|
|
1149
1373
|
'reflected': self.swflux_reflected_vol.to_numpy(),
|
|
1150
1374
|
'skyvf': self.skyvf_vol.to_numpy()
|
|
1151
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()
|