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.
Files changed (41) hide show
  1. voxcity/downloader/ocean.py +559 -0
  2. voxcity/generator/api.py +6 -0
  3. voxcity/generator/grids.py +45 -32
  4. voxcity/generator/pipeline.py +327 -27
  5. voxcity/geoprocessor/draw.py +14 -8
  6. voxcity/geoprocessor/raster/__init__.py +2 -0
  7. voxcity/geoprocessor/raster/core.py +31 -0
  8. voxcity/geoprocessor/raster/landcover.py +173 -49
  9. voxcity/geoprocessor/raster/raster.py +1 -1
  10. voxcity/models.py +2 -0
  11. voxcity/simulator/solar/__init__.py +13 -0
  12. voxcity/simulator_gpu/__init__.py +90 -0
  13. voxcity/simulator_gpu/core.py +322 -0
  14. voxcity/simulator_gpu/domain.py +36 -0
  15. voxcity/simulator_gpu/init_taichi.py +154 -0
  16. voxcity/simulator_gpu/raytracing.py +776 -0
  17. voxcity/simulator_gpu/solar/__init__.py +222 -0
  18. voxcity/simulator_gpu/solar/core.py +66 -0
  19. voxcity/simulator_gpu/solar/csf.py +1249 -0
  20. voxcity/simulator_gpu/solar/domain.py +618 -0
  21. voxcity/simulator_gpu/solar/epw.py +421 -0
  22. voxcity/simulator_gpu/solar/integration.py +4322 -0
  23. voxcity/simulator_gpu/solar/mask.py +459 -0
  24. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  25. voxcity/simulator_gpu/solar/raytracing.py +182 -0
  26. voxcity/simulator_gpu/solar/reflection.py +533 -0
  27. voxcity/simulator_gpu/solar/sky.py +907 -0
  28. voxcity/simulator_gpu/solar/solar.py +337 -0
  29. voxcity/simulator_gpu/solar/svf.py +446 -0
  30. voxcity/simulator_gpu/solar/volumetric.py +2099 -0
  31. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  32. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  33. voxcity/simulator_gpu/visibility/integration.py +808 -0
  34. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  35. voxcity/simulator_gpu/visibility/view.py +944 -0
  36. voxcity/visualizer/renderer.py +2 -1
  37. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/METADATA +16 -53
  38. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/RECORD +41 -16
  39. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/WHEEL +0 -0
  40. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/AUTHORS.rst +0 -0
  41. {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,618 @@
1
+ """Domain definition for palm-solar.
2
+
3
+ Represents the 3D computational domain with:
4
+ - Grid cells (dx, dy, dz spacing)
5
+ - Topography (terrain height)
6
+ - Building geometry (3D obstacles)
7
+ - Plant canopy (Leaf Area Density - LAD)
8
+ - Surface properties (albedo)
9
+ """
10
+
11
+ import taichi as ti
12
+ import numpy as np
13
+ from typing import Tuple, Optional, Union
14
+ from .core import Vector3, Point3, EXT_COEF
15
+ from ..init_taichi import ensure_initialized
16
+
17
+
18
+ # Surface direction indices (matching PALM convention)
19
+ IUP = 0 # Upward facing (horizontal roof/ground)
20
+ IDOWN = 1 # Downward facing
21
+ INORTH = 2 # North facing (positive y)
22
+ ISOUTH = 3 # South facing (negative y)
23
+ IEAST = 4 # East facing (positive x)
24
+ IWEST = 5 # West facing (negative x)
25
+
26
+ # Direction normal vectors (x, y, z)
27
+ DIR_NORMALS = {
28
+ IUP: (0.0, 0.0, 1.0),
29
+ IDOWN: (0.0, 0.0, -1.0),
30
+ INORTH: (0.0, 1.0, 0.0),
31
+ ISOUTH: (0.0, -1.0, 0.0),
32
+ IEAST: (1.0, 0.0, 0.0),
33
+ IWEST: (-1.0, 0.0, 0.0),
34
+ }
35
+
36
+
37
+ @ti.data_oriented
38
+ class Domain:
39
+ """
40
+ 3D computational domain for solar radiation simulation.
41
+
42
+ The domain uses a regular grid with:
43
+ - x: West to East
44
+ - y: South to North
45
+ - z: Ground to Sky
46
+
47
+ Attributes:
48
+ nx, ny, nz: Number of grid cells in each direction
49
+ dx, dy, dz: Grid spacing in meters
50
+ origin: (x, y, z) coordinates of domain origin
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ nx: int,
56
+ ny: int,
57
+ nz: int,
58
+ dx: float = 1.0,
59
+ dy: float = 1.0,
60
+ dz: float = 1.0,
61
+ origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
62
+ origin_lat: Optional[float] = None,
63
+ origin_lon: Optional[float] = None
64
+ ):
65
+ """
66
+ Initialize the domain.
67
+
68
+ Args:
69
+ nx, ny, nz: Grid dimensions
70
+ dx, dy, dz: Grid spacing (m)
71
+ origin: Domain origin coordinates
72
+ origin_lat: Latitude for solar calculations (degrees)
73
+ origin_lon: Longitude for solar calculations (degrees)
74
+ """
75
+ # Ensure Taichi is initialized before creating any fields
76
+ ensure_initialized()
77
+
78
+ self.nx = nx
79
+ self.ny = ny
80
+ self.nz = nz
81
+ self.dx = dx
82
+ self.dy = dy
83
+ self.dz = dz
84
+ self.origin = origin
85
+ self.origin_lat = origin_lat if origin_lat is not None else 0.0
86
+ self.origin_lon = origin_lon if origin_lon is not None else 0.0
87
+
88
+ # Domain bounds
89
+ self.x_min = origin[0]
90
+ self.x_max = origin[0] + nx * dx
91
+ self.y_min = origin[1]
92
+ self.y_max = origin[1] + ny * dy
93
+ self.z_min = origin[2]
94
+ self.z_max = origin[2] + nz * dz
95
+
96
+ # Grid cell volume
97
+ self.cell_volume = dx * dy * dz
98
+
99
+ # Topography: terrain height at each (i, j) column
100
+ # Value is the k-index of the topmost solid cell
101
+ self.topo_top = ti.field(dtype=ti.i32, shape=(nx, ny))
102
+
103
+ # Building mask: 1 if cell is solid (building), 0 if air
104
+ self.is_solid = ti.field(dtype=ti.i32, shape=(nx, ny, nz))
105
+
106
+ # Tree mask: 1 if cell is tree canopy, 0 otherwise (for view calculations)
107
+ self.is_tree = ti.field(dtype=ti.i32, shape=(nx, ny, nz))
108
+
109
+ # Leaf Area Density (m^2/m^3) for plant canopy
110
+ self.lad = ti.field(dtype=ti.f32, shape=(nx, ny, nz))
111
+
112
+ # Plant canopy top index for each column
113
+ self.plant_top = ti.field(dtype=ti.i32, shape=(nx, ny))
114
+
115
+ # Surface count
116
+ self.n_surfaces = ti.field(dtype=ti.i32, shape=())
117
+
118
+ # Initialize arrays
119
+ self._init_arrays()
120
+
121
+ @ti.kernel
122
+ def _init_arrays(self):
123
+ """Initialize all arrays to default values."""
124
+ for i, j in self.topo_top:
125
+ self.topo_top[i, j] = 0
126
+ self.plant_top[i, j] = 0
127
+
128
+ for i, j, k in self.is_solid:
129
+ self.is_solid[i, j, k] = 0
130
+ self.is_tree[i, j, k] = 0
131
+ self.lad[i, j, k] = 0.0
132
+
133
+ def set_flat_terrain(self, height: float = 0.0):
134
+ """Set flat terrain at given height."""
135
+ k_top = int(height / self.dz)
136
+ self._set_flat_terrain_kernel(k_top)
137
+
138
+ # Alias for backwards compatibility
139
+ def initialize_terrain(self, height: float = 0.0):
140
+ """Alias for set_flat_terrain."""
141
+ self.set_flat_terrain(height)
142
+
143
+ @ti.kernel
144
+ def _set_flat_terrain_kernel(self, k_top: ti.i32):
145
+ for i, j in self.topo_top:
146
+ self.topo_top[i, j] = k_top
147
+ for k in range(k_top + 1):
148
+ self.is_solid[i, j, k] = 1
149
+
150
+ def set_terrain_from_array(self, terrain_height: np.ndarray):
151
+ """
152
+ Set terrain from 2D numpy array of heights.
153
+
154
+ Args:
155
+ terrain_height: 2D array (nx, ny) of terrain heights in meters
156
+ """
157
+ terrain_k = (terrain_height / self.dz).astype(np.int32)
158
+ self._set_terrain_kernel(terrain_k)
159
+
160
+ @ti.kernel
161
+ def _set_terrain_kernel(self, terrain_k: ti.types.ndarray()):
162
+ for i, j in self.topo_top:
163
+ k_top = terrain_k[i, j]
164
+ self.topo_top[i, j] = k_top
165
+ for k in range(self.nz):
166
+ if k <= k_top:
167
+ self.is_solid[i, j, k] = 1
168
+ else:
169
+ self.is_solid[i, j, k] = 0
170
+
171
+ def add_building(
172
+ self,
173
+ x_range: Optional[Tuple[int, int]] = None,
174
+ y_range: Optional[Tuple[int, int]] = None,
175
+ z_range: Optional[Tuple[int, int]] = None,
176
+ *,
177
+ x_start: Optional[int] = None,
178
+ x_end: Optional[int] = None,
179
+ y_start: Optional[int] = None,
180
+ y_end: Optional[int] = None,
181
+ height: Optional[float] = None
182
+ ):
183
+ """
184
+ Add a rectangular building to the domain.
185
+
186
+ Can be called with either range tuples or individual parameters:
187
+
188
+ Args:
189
+ x_range: (i_start, i_end) grid indices
190
+ y_range: (j_start, j_end) grid indices
191
+ z_range: (k_start, k_end) grid indices
192
+
193
+ Or with keyword arguments:
194
+ x_start, x_end: X grid indices
195
+ y_start, y_end: Y grid indices
196
+ height: Building height in meters (z_range computed from this)
197
+ """
198
+ # Handle convenience parameters
199
+ if x_start is not None and x_end is not None:
200
+ x_range = (x_start, x_end)
201
+ if y_start is not None and y_end is not None:
202
+ y_range = (y_start, y_end)
203
+ if height is not None and z_range is None:
204
+ k_top = int(height / self.dz) + 1
205
+ z_range = (0, k_top)
206
+
207
+ if x_range is None or y_range is None or z_range is None:
208
+ raise ValueError("Must provide either range tuples or individual parameters")
209
+
210
+ self._add_building_kernel(
211
+ x_range[0], x_range[1],
212
+ y_range[0], y_range[1],
213
+ z_range[0], z_range[1]
214
+ )
215
+
216
+ @ti.kernel
217
+ def _add_building_kernel(
218
+ self,
219
+ i0: ti.i32, i1: ti.i32,
220
+ j0: ti.i32, j1: ti.i32,
221
+ k0: ti.i32, k1: ti.i32
222
+ ):
223
+ for i in range(i0, i1):
224
+ for j in range(j0, j1):
225
+ for k in range(k0, k1):
226
+ if 0 <= i < self.nx and 0 <= j < self.ny and 0 <= k < self.nz:
227
+ self.is_solid[i, j, k] = 1
228
+ if k > self.topo_top[i, j]:
229
+ self.topo_top[i, j] = k
230
+
231
+ def set_lad_from_array(self, lad_array: np.ndarray):
232
+ """
233
+ Set Leaf Area Density from 3D numpy array.
234
+
235
+ Args:
236
+ lad_array: 3D array (nx, ny, nz) of LAD values (m^2/m^3)
237
+ """
238
+ self._set_lad_kernel(lad_array)
239
+ self._update_plant_top()
240
+
241
+ def set_from_voxel_data(self, voxel_data: np.ndarray, tree_code: int = -2, solid_codes: Optional[list] = None):
242
+ """
243
+ Set domain from a 3D voxel data array.
244
+
245
+ Args:
246
+ voxel_data: 3D numpy array with voxel class codes
247
+ tree_code: Class code for trees (default -2)
248
+ solid_codes: List of codes that are solid (default: all non-zero except tree_code)
249
+ """
250
+ if solid_codes is None:
251
+ # All non-zero codes except tree are solid
252
+ solid_codes = []
253
+
254
+ self._set_from_voxel_data_kernel(voxel_data, tree_code)
255
+
256
+ @ti.kernel
257
+ def _set_from_voxel_data_kernel(self, voxel_data: ti.types.ndarray(), tree_code: ti.i32):
258
+ for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
259
+ val = voxel_data[i, j, k]
260
+ if val == tree_code:
261
+ self.is_tree[i, j, k] = 1
262
+ self.is_solid[i, j, k] = 0
263
+ elif val != 0:
264
+ self.is_solid[i, j, k] = 1
265
+ self.is_tree[i, j, k] = 0
266
+ else:
267
+ self.is_solid[i, j, k] = 0
268
+ self.is_tree[i, j, k] = 0
269
+
270
+ def add_tree_box(
271
+ self,
272
+ x_range: Tuple[int, int],
273
+ y_range: Tuple[int, int],
274
+ z_range: Tuple[int, int],
275
+ lad_value: float = 1.0
276
+ ):
277
+ """
278
+ Add a box-shaped tree canopy region to the domain.
279
+
280
+ This is a simpler alternative to add_tree() for rectangular tree regions.
281
+
282
+ Args:
283
+ x_range, y_range, z_range: Grid index ranges (start, end)
284
+ lad_value: Leaf Area Density value (m^2/m^3)
285
+ """
286
+ self._add_tree_box_kernel(x_range[0], x_range[1], y_range[0], y_range[1],
287
+ z_range[0], z_range[1], lad_value)
288
+ self._update_plant_top()
289
+
290
+ @ti.kernel
291
+ def _add_tree_box_kernel(self, i_min: ti.i32, i_max: ti.i32, j_min: ti.i32,
292
+ j_max: ti.i32, k_min: ti.i32, k_max: ti.i32, lad: ti.f32):
293
+ for i, j, k in ti.ndrange((i_min, i_max), (j_min, j_max), (k_min, k_max)):
294
+ if 0 <= i < self.nx and 0 <= j < self.ny and 0 <= k < self.nz:
295
+ self.is_tree[i, j, k] = 1
296
+ self.lad[i, j, k] = lad
297
+
298
+ @ti.kernel
299
+ def _set_lad_kernel(self, lad_array: ti.types.ndarray()):
300
+ for i, j, k in self.lad:
301
+ self.lad[i, j, k] = lad_array[i, j, k]
302
+
303
+ @ti.kernel
304
+ def _update_plant_top(self):
305
+ """Update plant canopy top index for each column."""
306
+ for i, j in self.plant_top:
307
+ max_k = 0
308
+ # Taichi doesn't support 3-arg range, so iterate forward and track highest
309
+ for k in range(self.nz):
310
+ if self.lad[i, j, k] > 0.0:
311
+ max_k = k
312
+ self.plant_top[i, j] = max_k
313
+
314
+ def add_tree(
315
+ self,
316
+ center: Optional[Tuple[float, float]] = None,
317
+ height: Optional[float] = None,
318
+ crown_radius: Optional[float] = None,
319
+ crown_height: Optional[float] = None,
320
+ trunk_height: Optional[float] = None,
321
+ max_lad: float = 1.0,
322
+ *,
323
+ center_x: Optional[float] = None,
324
+ center_y: Optional[float] = None,
325
+ lad: Optional[float] = None
326
+ ):
327
+ """
328
+ Add a simple tree with cylindrical trunk and spherical crown.
329
+
330
+ Args:
331
+ center: (x, y) position in meters
332
+ height: Total tree height in meters (optional, computed from crown+trunk)
333
+ crown_radius: Radius of crown in meters
334
+ crown_height: Height of crown sphere in meters
335
+ trunk_height: Height of trunk (no leaves) in meters
336
+ max_lad: Maximum LAD at crown center
337
+
338
+ Or with keyword arguments:
339
+ center_x, center_y: Position in meters
340
+ lad: Alias for max_lad
341
+ """
342
+ # Handle convenience parameters
343
+ if center_x is not None and center_y is not None:
344
+ center = (center_x, center_y)
345
+ if lad is not None:
346
+ max_lad = lad
347
+
348
+ if center is None or crown_radius is None or crown_height is None or trunk_height is None:
349
+ raise ValueError("Must provide center, crown_radius, crown_height, and trunk_height")
350
+ # Convert to grid indices
351
+ ci = int((center[0] - self.origin[0]) / self.dx)
352
+ cj = int((center[1] - self.origin[1]) / self.dy)
353
+ crown_center_k = int((trunk_height + crown_height / 2) / self.dz)
354
+
355
+ ri = int(crown_radius / self.dx) + 1
356
+ rj = int(crown_radius / self.dy) + 1
357
+ rk = int(crown_height / 2 / self.dz) + 1
358
+
359
+ self._add_tree_kernel(ci, cj, crown_center_k, ri, rj, rk,
360
+ crown_radius, crown_height / 2, max_lad)
361
+ self._update_plant_top()
362
+
363
+ @ti.kernel
364
+ def _add_tree_kernel(
365
+ self,
366
+ ci: ti.i32, cj: ti.i32, ck: ti.i32,
367
+ ri: ti.i32, rj: ti.i32, rk: ti.i32,
368
+ rx: ti.f32, rz: ti.f32, max_lad: ti.f32
369
+ ):
370
+ for di in range(-ri, ri + 1):
371
+ for dj in range(-rj, rj + 1):
372
+ for dk in range(-rk, rk + 1):
373
+ i = ci + di
374
+ j = cj + dj
375
+ k = ck + dk
376
+
377
+ if 0 <= i < self.nx and 0 <= j < self.ny and 0 <= k < self.nz:
378
+ # Ellipsoid distance
379
+ dx_norm = (di * self.dx) / rx
380
+ dy_norm = (dj * self.dy) / rx
381
+ dz_norm = (dk * self.dz) / rz
382
+ dist = ti.sqrt(dx_norm**2 + dy_norm**2 + dz_norm**2)
383
+
384
+ if dist <= 1.0:
385
+ # LAD decreases from center
386
+ lad_val = max_lad * (1.0 - dist**2)
387
+ if lad_val > self.lad[i, j, k]:
388
+ self.lad[i, j, k] = lad_val
389
+ # Mark as tree voxel
390
+ self.is_tree[i, j, k] = 1
391
+
392
+ @ti.func
393
+ def get_cell_indices(self, point: Point3) -> ti.math.ivec3:
394
+ """Get grid cell indices for a point."""
395
+ i = ti.cast((point.x - self.origin[0]) / self.dx, ti.i32)
396
+ j = ti.cast((point.y - self.origin[1]) / self.dy, ti.i32)
397
+ k = ti.cast((point.z - self.origin[2]) / self.dz, ti.i32)
398
+ return ti.math.ivec3(i, j, k)
399
+
400
+ @ti.func
401
+ def get_cell_center(self, i: ti.i32, j: ti.i32, k: ti.i32) -> Point3:
402
+ """Get center coordinates of grid cell."""
403
+ x = self.origin[0] + (i + 0.5) * self.dx
404
+ y = self.origin[1] + (j + 0.5) * self.dy
405
+ z = self.origin[2] + (k + 0.5) * self.dz
406
+ return Point3(x, y, z)
407
+
408
+ @ti.func
409
+ def is_inside(self, point: Point3) -> ti.i32:
410
+ """Check if point is inside domain."""
411
+ inside = 1
412
+ if point.x < self.x_min or point.x > self.x_max:
413
+ inside = 0
414
+ if point.y < self.y_min or point.y > self.y_max:
415
+ inside = 0
416
+ if point.z < self.z_min or point.z > self.z_max:
417
+ inside = 0
418
+ return inside
419
+
420
+ @ti.func
421
+ def is_cell_solid(self, i: ti.i32, j: ti.i32, k: ti.i32) -> ti.i32:
422
+ """Check if cell is solid (building or terrain)."""
423
+ solid = 0
424
+ if 0 <= i < self.nx and 0 <= j < self.ny and 0 <= k < self.nz:
425
+ solid = self.is_solid[i, j, k]
426
+ return solid
427
+
428
+ def get_max_dist(self) -> float:
429
+ """Get maximum ray distance (domain diagonal)."""
430
+ import math
431
+ return math.sqrt(
432
+ (self.nx * self.dx)**2 +
433
+ (self.ny * self.dy)**2 +
434
+ (self.nz * self.dz)**2
435
+ )
436
+
437
+ @ti.func
438
+ def get_lad(self, i: ti.i32, j: ti.i32, k: ti.i32) -> ti.f32:
439
+ """Get LAD value at cell."""
440
+ lad_val = 0.0
441
+ if 0 <= i < self.nx and 0 <= j < self.ny and 0 <= k < self.nz:
442
+ lad_val = self.lad[i, j, k]
443
+ return lad_val
444
+
445
+
446
+ @ti.data_oriented
447
+ class Surfaces:
448
+ """
449
+ Collection of surface elements for radiation calculations.
450
+
451
+ Each surface has:
452
+ - Position (grid indices i, j, k)
453
+ - Direction (normal direction index)
454
+ - Area
455
+ - Albedo (reflectivity)
456
+ """
457
+
458
+ def __init__(self, max_surfaces: int):
459
+ """
460
+ Initialize surface storage.
461
+
462
+ Args:
463
+ max_surfaces: Maximum number of surfaces to allocate
464
+ """
465
+ self.max_surfaces = max_surfaces
466
+ self.n_surfaces = ti.field(dtype=ti.i32, shape=())
467
+
468
+ # Surface geometry
469
+ self.position = ti.Vector.field(3, dtype=ti.i32, shape=(max_surfaces,)) # i, j, k
470
+ self.direction = ti.field(dtype=ti.i32, shape=(max_surfaces,)) # direction index
471
+ self.center = ti.Vector.field(3, dtype=ti.f32, shape=(max_surfaces,)) # world coords
472
+ self.normal = ti.Vector.field(3, dtype=ti.f32, shape=(max_surfaces,))
473
+ self.area = ti.field(dtype=ti.f32, shape=(max_surfaces,))
474
+
475
+ # Surface properties
476
+ self.albedo = ti.field(dtype=ti.f32, shape=(max_surfaces,))
477
+
478
+ # Radiation fluxes (shortwave only)
479
+ self.sw_in_direct = ti.field(dtype=ti.f32, shape=(max_surfaces,))
480
+ self.sw_in_diffuse = ti.field(dtype=ti.f32, shape=(max_surfaces,))
481
+ self.sw_out = ti.field(dtype=ti.f32, shape=(max_surfaces,))
482
+
483
+ # Sky view factor (total SVF and SVF from urban surfaces only)
484
+ self.svf = ti.field(dtype=ti.f32, shape=(max_surfaces,))
485
+ self.svf_urban = ti.field(dtype=ti.f32, shape=(max_surfaces,))
486
+
487
+ # Shadow factor (0 = fully shadowed, 1 = fully lit)
488
+ self.shadow = ti.field(dtype=ti.f32, shape=(max_surfaces,))
489
+ self.shadow_factor = ti.field(dtype=ti.f32, shape=(max_surfaces,)) # same as shadow, for compatibility
490
+
491
+ # Canopy transmissivity (for direct solar through vegetation)
492
+ self.canopy_transmissivity = ti.field(dtype=ti.f32, shape=(max_surfaces,))
493
+
494
+ self.n_surfaces[None] = 0
495
+
496
+ @ti.func
497
+ def add_surface(
498
+ self,
499
+ i: ti.i32, j: ti.i32, k: ti.i32,
500
+ direction: ti.i32,
501
+ center: Point3,
502
+ normal: Vector3,
503
+ area: ti.f32,
504
+ albedo: ti.f32 = 0.2
505
+ ) -> ti.i32:
506
+ """Add a surface and return its index."""
507
+ idx = ti.atomic_add(self.n_surfaces[None], 1)
508
+ if idx < self.max_surfaces:
509
+ self.position[idx] = ti.math.ivec3(i, j, k)
510
+ self.direction[idx] = direction
511
+ self.center[idx] = center
512
+ self.normal[idx] = normal
513
+ self.area[idx] = area
514
+ self.albedo[idx] = albedo
515
+ self.svf[idx] = 1.0
516
+ self.shadow[idx] = 1.0
517
+ return idx
518
+
519
+ @property
520
+ def count(self) -> int:
521
+ """Get current number of surfaces."""
522
+ return self.n_surfaces[None]
523
+
524
+ def get_count(self) -> int:
525
+ """Get current number of surfaces (alias for count property)."""
526
+ return self.n_surfaces[None]
527
+
528
+ @ti.kernel
529
+ def reset_fluxes(self):
530
+ """Reset all radiation fluxes to zero."""
531
+ for idx in range(self.n_surfaces[None]):
532
+ self.sw_in_direct[idx] = 0.0
533
+ self.sw_in_diffuse[idx] = 0.0
534
+ self.sw_out[idx] = 0.0
535
+
536
+
537
+ def extract_surfaces_from_domain(domain: Domain,
538
+ default_albedo: float = 0.2) -> Surfaces:
539
+ """
540
+ Extract all surface elements from domain geometry.
541
+
542
+ Creates surface elements at all interfaces between solid and air cells.
543
+
544
+ Args:
545
+ domain: The computational domain
546
+ default_albedo: Default surface albedo
547
+
548
+ Returns:
549
+ Surfaces object containing all extracted surfaces
550
+ """
551
+ # Estimate max surfaces (6 faces per building cell, 1 top face per ground cell)
552
+ max_surfaces = domain.nx * domain.ny * 6 # Conservative estimate
553
+ surfaces = Surfaces(max_surfaces)
554
+
555
+ _extract_surfaces_kernel(domain, surfaces, default_albedo)
556
+
557
+ return surfaces
558
+
559
+
560
+ @ti.kernel
561
+ def _extract_surfaces_kernel(
562
+ domain: ti.template(),
563
+ surfaces: ti.template(),
564
+ default_albedo: ti.f32
565
+ ):
566
+ """Kernel to extract surfaces from domain."""
567
+ dx = domain.dx
568
+ dy = domain.dy
569
+ dz = domain.dz
570
+
571
+ for i, j, k in domain.is_solid:
572
+ if domain.is_solid[i, j, k] == 1:
573
+ # Check each neighbor direction for air interface
574
+
575
+ # Up (z+)
576
+ if k + 1 >= domain.nz or domain.is_solid[i, j, k + 1] == 0:
577
+ center = domain.get_cell_center(i, j, k)
578
+ center.z += dz / 2
579
+ normal = Vector3(0.0, 0.0, 1.0)
580
+ area = dx * dy
581
+ surfaces.add_surface(i, j, k, IUP, center, normal, area,
582
+ default_albedo)
583
+
584
+ # North (y+)
585
+ if j + 1 >= domain.ny or domain.is_solid[i, j + 1, k] == 0:
586
+ center = domain.get_cell_center(i, j, k)
587
+ center.y += dy / 2
588
+ normal = Vector3(0.0, 1.0, 0.0)
589
+ area = dx * dz
590
+ surfaces.add_surface(i, j, k, INORTH, center, normal, area,
591
+ default_albedo)
592
+
593
+ # South (y-)
594
+ if j - 1 < 0 or domain.is_solid[i, j - 1, k] == 0:
595
+ center = domain.get_cell_center(i, j, k)
596
+ center.y -= dy / 2
597
+ normal = Vector3(0.0, -1.0, 0.0)
598
+ area = dx * dz
599
+ surfaces.add_surface(i, j, k, ISOUTH, center, normal, area,
600
+ default_albedo)
601
+
602
+ # East (x+)
603
+ if i + 1 >= domain.nx or domain.is_solid[i + 1, j, k] == 0:
604
+ center = domain.get_cell_center(i, j, k)
605
+ center.x += dx / 2
606
+ normal = Vector3(1.0, 0.0, 0.0)
607
+ area = dy * dz
608
+ surfaces.add_surface(i, j, k, IEAST, center, normal, area,
609
+ default_albedo)
610
+
611
+ # West (x-)
612
+ if i - 1 < 0 or domain.is_solid[i - 1, j, k] == 0:
613
+ center = domain.get_cell_center(i, j, k)
614
+ center.x -= dx / 2
615
+ normal = Vector3(-1.0, 0.0, 0.0)
616
+ area = dy * dz
617
+ surfaces.add_surface(i, j, k, IWEST, center, normal, area,
618
+ default_albedo)