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.
@@ -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 dist_sq > 0.01: # Avoid numerical issues
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
- # Create temporary taichi field for outgoing radiation
1045
- surf_out_field = ti.field(dtype=ti.f32, shape=(n_surfaces,))
1046
- surf_out_field.from_numpy(surf_outgoing[:n_surfaces].astype(np.float32))
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()