voxcity 0.7.0__py3-none-any.whl → 1.0.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. voxcity/__init__.py +14 -14
  2. voxcity/downloader/ocean.py +559 -0
  3. voxcity/exporter/__init__.py +12 -12
  4. voxcity/exporter/cityles.py +633 -633
  5. voxcity/exporter/envimet.py +733 -728
  6. voxcity/exporter/magicavoxel.py +333 -333
  7. voxcity/exporter/netcdf.py +238 -238
  8. voxcity/exporter/obj.py +1480 -1480
  9. voxcity/generator/__init__.py +47 -44
  10. voxcity/generator/api.py +727 -675
  11. voxcity/generator/grids.py +394 -379
  12. voxcity/generator/io.py +94 -94
  13. voxcity/generator/pipeline.py +582 -282
  14. voxcity/generator/update.py +429 -0
  15. voxcity/generator/voxelizer.py +18 -6
  16. voxcity/geoprocessor/__init__.py +75 -75
  17. voxcity/geoprocessor/draw.py +1494 -1219
  18. voxcity/geoprocessor/merge_utils.py +91 -91
  19. voxcity/geoprocessor/mesh.py +806 -806
  20. voxcity/geoprocessor/network.py +708 -708
  21. voxcity/geoprocessor/raster/__init__.py +2 -0
  22. voxcity/geoprocessor/raster/buildings.py +435 -428
  23. voxcity/geoprocessor/raster/core.py +31 -0
  24. voxcity/geoprocessor/raster/export.py +93 -93
  25. voxcity/geoprocessor/raster/landcover.py +178 -51
  26. voxcity/geoprocessor/raster/raster.py +1 -1
  27. voxcity/geoprocessor/utils.py +824 -824
  28. voxcity/models.py +115 -113
  29. voxcity/simulator/solar/__init__.py +66 -43
  30. voxcity/simulator/solar/integration.py +336 -336
  31. voxcity/simulator/solar/sky.py +668 -0
  32. voxcity/simulator/solar/temporal.py +792 -434
  33. voxcity/simulator_gpu/__init__.py +115 -0
  34. voxcity/simulator_gpu/common/__init__.py +9 -0
  35. voxcity/simulator_gpu/common/geometry.py +11 -0
  36. voxcity/simulator_gpu/core.py +322 -0
  37. voxcity/simulator_gpu/domain.py +262 -0
  38. voxcity/simulator_gpu/environment.yml +11 -0
  39. voxcity/simulator_gpu/init_taichi.py +154 -0
  40. voxcity/simulator_gpu/integration.py +15 -0
  41. voxcity/simulator_gpu/kernels.py +56 -0
  42. voxcity/simulator_gpu/radiation.py +28 -0
  43. voxcity/simulator_gpu/raytracing.py +623 -0
  44. voxcity/simulator_gpu/sky.py +9 -0
  45. voxcity/simulator_gpu/solar/__init__.py +178 -0
  46. voxcity/simulator_gpu/solar/core.py +66 -0
  47. voxcity/simulator_gpu/solar/csf.py +1249 -0
  48. voxcity/simulator_gpu/solar/domain.py +561 -0
  49. voxcity/simulator_gpu/solar/epw.py +421 -0
  50. voxcity/simulator_gpu/solar/integration.py +2953 -0
  51. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  52. voxcity/simulator_gpu/solar/raytracing.py +686 -0
  53. voxcity/simulator_gpu/solar/reflection.py +533 -0
  54. voxcity/simulator_gpu/solar/sky.py +907 -0
  55. voxcity/simulator_gpu/solar/solar.py +337 -0
  56. voxcity/simulator_gpu/solar/svf.py +446 -0
  57. voxcity/simulator_gpu/solar/volumetric.py +1151 -0
  58. voxcity/simulator_gpu/solar/voxcity.py +2953 -0
  59. voxcity/simulator_gpu/temporal.py +13 -0
  60. voxcity/simulator_gpu/utils.py +25 -0
  61. voxcity/simulator_gpu/view.py +32 -0
  62. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  63. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  64. voxcity/simulator_gpu/visibility/integration.py +808 -0
  65. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  66. voxcity/simulator_gpu/visibility/view.py +944 -0
  67. voxcity/utils/__init__.py +11 -0
  68. voxcity/utils/classes.py +194 -0
  69. voxcity/utils/lc.py +80 -39
  70. voxcity/utils/shape.py +230 -0
  71. voxcity/visualizer/__init__.py +24 -24
  72. voxcity/visualizer/builder.py +43 -43
  73. voxcity/visualizer/grids.py +141 -141
  74. voxcity/visualizer/maps.py +187 -187
  75. voxcity/visualizer/renderer.py +1146 -928
  76. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/METADATA +56 -52
  77. voxcity-1.0.13.dist-info/RECORD +116 -0
  78. voxcity-0.7.0.dist-info/RECORD +0 -77
  79. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
  80. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,561 @@
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
+ @ti.kernel
242
+ def _set_lad_kernel(self, lad_array: ti.types.ndarray()):
243
+ for i, j, k in self.lad:
244
+ self.lad[i, j, k] = lad_array[i, j, k]
245
+
246
+ @ti.kernel
247
+ def _update_plant_top(self):
248
+ """Update plant canopy top index for each column."""
249
+ for i, j in self.plant_top:
250
+ max_k = 0
251
+ # Taichi doesn't support 3-arg range, so iterate forward and track highest
252
+ for k in range(self.nz):
253
+ if self.lad[i, j, k] > 0.0:
254
+ max_k = k
255
+ self.plant_top[i, j] = max_k
256
+
257
+ def add_tree(
258
+ self,
259
+ center: Optional[Tuple[float, float]] = None,
260
+ height: Optional[float] = None,
261
+ crown_radius: Optional[float] = None,
262
+ crown_height: Optional[float] = None,
263
+ trunk_height: Optional[float] = None,
264
+ max_lad: float = 1.0,
265
+ *,
266
+ center_x: Optional[float] = None,
267
+ center_y: Optional[float] = None,
268
+ lad: Optional[float] = None
269
+ ):
270
+ """
271
+ Add a simple tree with cylindrical trunk and spherical crown.
272
+
273
+ Args:
274
+ center: (x, y) position in meters
275
+ height: Total tree height in meters (optional, computed from crown+trunk)
276
+ crown_radius: Radius of crown in meters
277
+ crown_height: Height of crown sphere in meters
278
+ trunk_height: Height of trunk (no leaves) in meters
279
+ max_lad: Maximum LAD at crown center
280
+
281
+ Or with keyword arguments:
282
+ center_x, center_y: Position in meters
283
+ lad: Alias for max_lad
284
+ """
285
+ # Handle convenience parameters
286
+ if center_x is not None and center_y is not None:
287
+ center = (center_x, center_y)
288
+ if lad is not None:
289
+ max_lad = lad
290
+
291
+ if center is None or crown_radius is None or crown_height is None or trunk_height is None:
292
+ raise ValueError("Must provide center, crown_radius, crown_height, and trunk_height")
293
+ # Convert to grid indices
294
+ ci = int((center[0] - self.origin[0]) / self.dx)
295
+ cj = int((center[1] - self.origin[1]) / self.dy)
296
+ crown_center_k = int((trunk_height + crown_height / 2) / self.dz)
297
+
298
+ ri = int(crown_radius / self.dx) + 1
299
+ rj = int(crown_radius / self.dy) + 1
300
+ rk = int(crown_height / 2 / self.dz) + 1
301
+
302
+ self._add_tree_kernel(ci, cj, crown_center_k, ri, rj, rk,
303
+ crown_radius, crown_height / 2, max_lad)
304
+ self._update_plant_top()
305
+
306
+ @ti.kernel
307
+ def _add_tree_kernel(
308
+ self,
309
+ ci: ti.i32, cj: ti.i32, ck: ti.i32,
310
+ ri: ti.i32, rj: ti.i32, rk: ti.i32,
311
+ rx: ti.f32, rz: ti.f32, max_lad: ti.f32
312
+ ):
313
+ for di in range(-ri, ri + 1):
314
+ for dj in range(-rj, rj + 1):
315
+ for dk in range(-rk, rk + 1):
316
+ i = ci + di
317
+ j = cj + dj
318
+ k = ck + dk
319
+
320
+ if 0 <= i < self.nx and 0 <= j < self.ny and 0 <= k < self.nz:
321
+ # Ellipsoid distance
322
+ dx_norm = (di * self.dx) / rx
323
+ dy_norm = (dj * self.dy) / rx
324
+ dz_norm = (dk * self.dz) / rz
325
+ dist = ti.sqrt(dx_norm**2 + dy_norm**2 + dz_norm**2)
326
+
327
+ if dist <= 1.0:
328
+ # LAD decreases from center
329
+ lad_val = max_lad * (1.0 - dist**2)
330
+ if lad_val > self.lad[i, j, k]:
331
+ self.lad[i, j, k] = lad_val
332
+ # Mark as tree voxel
333
+ self.is_tree[i, j, k] = 1
334
+
335
+ @ti.func
336
+ def get_cell_indices(self, point: Point3) -> ti.math.ivec3:
337
+ """Get grid cell indices for a point."""
338
+ i = ti.cast((point.x - self.origin[0]) / self.dx, ti.i32)
339
+ j = ti.cast((point.y - self.origin[1]) / self.dy, ti.i32)
340
+ k = ti.cast((point.z - self.origin[2]) / self.dz, ti.i32)
341
+ return ti.math.ivec3(i, j, k)
342
+
343
+ @ti.func
344
+ def get_cell_center(self, i: ti.i32, j: ti.i32, k: ti.i32) -> Point3:
345
+ """Get center coordinates of grid cell."""
346
+ x = self.origin[0] + (i + 0.5) * self.dx
347
+ y = self.origin[1] + (j + 0.5) * self.dy
348
+ z = self.origin[2] + (k + 0.5) * self.dz
349
+ return Point3(x, y, z)
350
+
351
+ @ti.func
352
+ def is_inside(self, point: Point3) -> ti.i32:
353
+ """Check if point is inside domain."""
354
+ inside = 1
355
+ if point.x < self.x_min or point.x > self.x_max:
356
+ inside = 0
357
+ if point.y < self.y_min or point.y > self.y_max:
358
+ inside = 0
359
+ if point.z < self.z_min or point.z > self.z_max:
360
+ inside = 0
361
+ return inside
362
+
363
+ @ti.func
364
+ def is_cell_solid(self, i: ti.i32, j: ti.i32, k: ti.i32) -> ti.i32:
365
+ """Check if cell is solid (building or terrain)."""
366
+ solid = 0
367
+ if 0 <= i < self.nx and 0 <= j < self.ny and 0 <= k < self.nz:
368
+ solid = self.is_solid[i, j, k]
369
+ return solid
370
+
371
+ def get_max_dist(self) -> float:
372
+ """Get maximum ray distance (domain diagonal)."""
373
+ import math
374
+ return math.sqrt(
375
+ (self.nx * self.dx)**2 +
376
+ (self.ny * self.dy)**2 +
377
+ (self.nz * self.dz)**2
378
+ )
379
+
380
+ @ti.func
381
+ def get_lad(self, i: ti.i32, j: ti.i32, k: ti.i32) -> ti.f32:
382
+ """Get LAD value at cell."""
383
+ lad_val = 0.0
384
+ if 0 <= i < self.nx and 0 <= j < self.ny and 0 <= k < self.nz:
385
+ lad_val = self.lad[i, j, k]
386
+ return lad_val
387
+
388
+
389
+ @ti.data_oriented
390
+ class Surfaces:
391
+ """
392
+ Collection of surface elements for radiation calculations.
393
+
394
+ Each surface has:
395
+ - Position (grid indices i, j, k)
396
+ - Direction (normal direction index)
397
+ - Area
398
+ - Albedo (reflectivity)
399
+ """
400
+
401
+ def __init__(self, max_surfaces: int):
402
+ """
403
+ Initialize surface storage.
404
+
405
+ Args:
406
+ max_surfaces: Maximum number of surfaces to allocate
407
+ """
408
+ self.max_surfaces = max_surfaces
409
+ self.n_surfaces = ti.field(dtype=ti.i32, shape=())
410
+
411
+ # Surface geometry
412
+ self.position = ti.Vector.field(3, dtype=ti.i32, shape=(max_surfaces,)) # i, j, k
413
+ self.direction = ti.field(dtype=ti.i32, shape=(max_surfaces,)) # direction index
414
+ self.center = ti.Vector.field(3, dtype=ti.f32, shape=(max_surfaces,)) # world coords
415
+ self.normal = ti.Vector.field(3, dtype=ti.f32, shape=(max_surfaces,))
416
+ self.area = ti.field(dtype=ti.f32, shape=(max_surfaces,))
417
+
418
+ # Surface properties
419
+ self.albedo = ti.field(dtype=ti.f32, shape=(max_surfaces,))
420
+
421
+ # Radiation fluxes (shortwave only)
422
+ self.sw_in_direct = ti.field(dtype=ti.f32, shape=(max_surfaces,))
423
+ self.sw_in_diffuse = ti.field(dtype=ti.f32, shape=(max_surfaces,))
424
+ self.sw_out = ti.field(dtype=ti.f32, shape=(max_surfaces,))
425
+
426
+ # Sky view factor (total SVF and SVF from urban surfaces only)
427
+ self.svf = ti.field(dtype=ti.f32, shape=(max_surfaces,))
428
+ self.svf_urban = ti.field(dtype=ti.f32, shape=(max_surfaces,))
429
+
430
+ # Shadow factor (0 = fully shadowed, 1 = fully lit)
431
+ self.shadow = ti.field(dtype=ti.f32, shape=(max_surfaces,))
432
+ self.shadow_factor = ti.field(dtype=ti.f32, shape=(max_surfaces,)) # same as shadow, for compatibility
433
+
434
+ # Canopy transmissivity (for direct solar through vegetation)
435
+ self.canopy_transmissivity = ti.field(dtype=ti.f32, shape=(max_surfaces,))
436
+
437
+ self.n_surfaces[None] = 0
438
+
439
+ @ti.func
440
+ def add_surface(
441
+ self,
442
+ i: ti.i32, j: ti.i32, k: ti.i32,
443
+ direction: ti.i32,
444
+ center: Point3,
445
+ normal: Vector3,
446
+ area: ti.f32,
447
+ albedo: ti.f32 = 0.2
448
+ ) -> ti.i32:
449
+ """Add a surface and return its index."""
450
+ idx = ti.atomic_add(self.n_surfaces[None], 1)
451
+ if idx < self.max_surfaces:
452
+ self.position[idx] = ti.math.ivec3(i, j, k)
453
+ self.direction[idx] = direction
454
+ self.center[idx] = center
455
+ self.normal[idx] = normal
456
+ self.area[idx] = area
457
+ self.albedo[idx] = albedo
458
+ self.svf[idx] = 1.0
459
+ self.shadow[idx] = 1.0
460
+ return idx
461
+
462
+ @property
463
+ def count(self) -> int:
464
+ """Get current number of surfaces."""
465
+ return self.n_surfaces[None]
466
+
467
+ def get_count(self) -> int:
468
+ """Get current number of surfaces (alias for count property)."""
469
+ return self.n_surfaces[None]
470
+
471
+ @ti.kernel
472
+ def reset_fluxes(self):
473
+ """Reset all radiation fluxes to zero."""
474
+ for idx in range(self.n_surfaces[None]):
475
+ self.sw_in_direct[idx] = 0.0
476
+ self.sw_in_diffuse[idx] = 0.0
477
+ self.sw_out[idx] = 0.0
478
+
479
+
480
+ def extract_surfaces_from_domain(domain: Domain,
481
+ default_albedo: float = 0.2) -> Surfaces:
482
+ """
483
+ Extract all surface elements from domain geometry.
484
+
485
+ Creates surface elements at all interfaces between solid and air cells.
486
+
487
+ Args:
488
+ domain: The computational domain
489
+ default_albedo: Default surface albedo
490
+
491
+ Returns:
492
+ Surfaces object containing all extracted surfaces
493
+ """
494
+ # Estimate max surfaces (6 faces per building cell, 1 top face per ground cell)
495
+ max_surfaces = domain.nx * domain.ny * 6 # Conservative estimate
496
+ surfaces = Surfaces(max_surfaces)
497
+
498
+ _extract_surfaces_kernel(domain, surfaces, default_albedo)
499
+
500
+ return surfaces
501
+
502
+
503
+ @ti.kernel
504
+ def _extract_surfaces_kernel(
505
+ domain: ti.template(),
506
+ surfaces: ti.template(),
507
+ default_albedo: ti.f32
508
+ ):
509
+ """Kernel to extract surfaces from domain."""
510
+ dx = domain.dx
511
+ dy = domain.dy
512
+ dz = domain.dz
513
+
514
+ for i, j, k in domain.is_solid:
515
+ if domain.is_solid[i, j, k] == 1:
516
+ # Check each neighbor direction for air interface
517
+
518
+ # Up (z+)
519
+ if k + 1 >= domain.nz or domain.is_solid[i, j, k + 1] == 0:
520
+ center = domain.get_cell_center(i, j, k)
521
+ center.z += dz / 2
522
+ normal = Vector3(0.0, 0.0, 1.0)
523
+ area = dx * dy
524
+ surfaces.add_surface(i, j, k, IUP, center, normal, area,
525
+ default_albedo)
526
+
527
+ # North (y+)
528
+ if j + 1 >= domain.ny or domain.is_solid[i, j + 1, k] == 0:
529
+ center = domain.get_cell_center(i, j, k)
530
+ center.y += dy / 2
531
+ normal = Vector3(0.0, 1.0, 0.0)
532
+ area = dx * dz
533
+ surfaces.add_surface(i, j, k, INORTH, center, normal, area,
534
+ default_albedo)
535
+
536
+ # South (y-)
537
+ if j - 1 < 0 or domain.is_solid[i, j - 1, k] == 0:
538
+ center = domain.get_cell_center(i, j, k)
539
+ center.y -= dy / 2
540
+ normal = Vector3(0.0, -1.0, 0.0)
541
+ area = dx * dz
542
+ surfaces.add_surface(i, j, k, ISOUTH, center, normal, area,
543
+ default_albedo)
544
+
545
+ # East (x+)
546
+ if i + 1 >= domain.nx or domain.is_solid[i + 1, j, k] == 0:
547
+ center = domain.get_cell_center(i, j, k)
548
+ center.x += dx / 2
549
+ normal = Vector3(1.0, 0.0, 0.0)
550
+ area = dy * dz
551
+ surfaces.add_surface(i, j, k, IEAST, center, normal, area,
552
+ default_albedo)
553
+
554
+ # West (x-)
555
+ if i - 1 < 0 or domain.is_solid[i - 1, j, k] == 0:
556
+ center = domain.get_cell_center(i, j, k)
557
+ center.x -= dx / 2
558
+ normal = Vector3(-1.0, 0.0, 0.0)
559
+ area = dy * dz
560
+ surfaces.add_surface(i, j, k, IWEST, center, normal, area,
561
+ default_albedo)