voxcity 1.0.2__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.
- voxcity/downloader/ocean.py +559 -0
- voxcity/generator/api.py +6 -0
- voxcity/generator/grids.py +45 -32
- voxcity/generator/pipeline.py +327 -27
- voxcity/geoprocessor/draw.py +14 -8
- voxcity/geoprocessor/raster/__init__.py +2 -0
- voxcity/geoprocessor/raster/core.py +31 -0
- voxcity/geoprocessor/raster/landcover.py +173 -49
- voxcity/geoprocessor/raster/raster.py +1 -1
- voxcity/models.py +2 -0
- voxcity/simulator_gpu/__init__.py +115 -0
- voxcity/simulator_gpu/common/__init__.py +9 -0
- voxcity/simulator_gpu/common/geometry.py +11 -0
- voxcity/simulator_gpu/core.py +322 -0
- voxcity/simulator_gpu/domain.py +262 -0
- voxcity/simulator_gpu/environment.yml +11 -0
- voxcity/simulator_gpu/init_taichi.py +154 -0
- voxcity/simulator_gpu/integration.py +15 -0
- voxcity/simulator_gpu/kernels.py +56 -0
- voxcity/simulator_gpu/radiation.py +28 -0
- voxcity/simulator_gpu/raytracing.py +623 -0
- voxcity/simulator_gpu/sky.py +9 -0
- voxcity/simulator_gpu/solar/__init__.py +178 -0
- voxcity/simulator_gpu/solar/core.py +66 -0
- voxcity/simulator_gpu/solar/csf.py +1249 -0
- voxcity/simulator_gpu/solar/domain.py +561 -0
- voxcity/simulator_gpu/solar/epw.py +421 -0
- voxcity/simulator_gpu/solar/integration.py +2953 -0
- voxcity/simulator_gpu/solar/radiation.py +3019 -0
- voxcity/simulator_gpu/solar/raytracing.py +686 -0
- voxcity/simulator_gpu/solar/reflection.py +533 -0
- voxcity/simulator_gpu/solar/sky.py +907 -0
- voxcity/simulator_gpu/solar/solar.py +337 -0
- voxcity/simulator_gpu/solar/svf.py +446 -0
- voxcity/simulator_gpu/solar/volumetric.py +1151 -0
- voxcity/simulator_gpu/solar/voxcity.py +2953 -0
- voxcity/simulator_gpu/temporal.py +13 -0
- voxcity/simulator_gpu/utils.py +25 -0
- voxcity/simulator_gpu/view.py +32 -0
- voxcity/simulator_gpu/visibility/__init__.py +109 -0
- voxcity/simulator_gpu/visibility/geometry.py +278 -0
- voxcity/simulator_gpu/visibility/integration.py +808 -0
- voxcity/simulator_gpu/visibility/landmark.py +753 -0
- voxcity/simulator_gpu/visibility/view.py +944 -0
- voxcity/visualizer/renderer.py +2 -1
- {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/METADATA +16 -53
- {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/RECORD +50 -15
- {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
- {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared core utilities for simulator_gpu.
|
|
3
|
+
|
|
4
|
+
Vector and ray utilities using Taichi for GPU acceleration.
|
|
5
|
+
Based on ray-tracing-one-weekend-taichi patterns.
|
|
6
|
+
|
|
7
|
+
GPU Optimization Notes:
|
|
8
|
+
- All functions use @ti.func for GPU inlining
|
|
9
|
+
- Branchless operations preferred where possible
|
|
10
|
+
- Memory coalescing friendly access patterns
|
|
11
|
+
- done-flag pattern for early termination (reduces warp divergence)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import taichi as ti
|
|
15
|
+
import math
|
|
16
|
+
|
|
17
|
+
# Type aliases for clarity
|
|
18
|
+
Vector3 = ti.math.vec3
|
|
19
|
+
Point3 = ti.math.vec3
|
|
20
|
+
Color3 = ti.math.vec3
|
|
21
|
+
|
|
22
|
+
# Constants - using Taichi static for compile-time optimization
|
|
23
|
+
PI = math.pi
|
|
24
|
+
TWO_PI = 2.0 * math.pi
|
|
25
|
+
HALF_PI = math.pi / 2.0
|
|
26
|
+
DEG_TO_RAD = math.pi / 180.0
|
|
27
|
+
RAD_TO_DEG = 180.0 / math.pi
|
|
28
|
+
|
|
29
|
+
# Solar constant (W/m^2) - matching PALM's solar_constant
|
|
30
|
+
SOLAR_CONSTANT = 1361.0
|
|
31
|
+
|
|
32
|
+
# Default extinction coefficient for vegetation
|
|
33
|
+
# PALM: ext_coef = 0.6_wp (radiation_model_mod.f90 line ~890)
|
|
34
|
+
EXT_COEF = 0.6
|
|
35
|
+
|
|
36
|
+
# Minimum stable cosine of zenith angle
|
|
37
|
+
MIN_STABLE_COSZEN = 0.0262
|
|
38
|
+
|
|
39
|
+
# GPU block size hint for optimal thread occupancy
|
|
40
|
+
GPU_BLOCK_SIZE = 256
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@ti.func
|
|
44
|
+
def normalize(v: Vector3) -> Vector3:
|
|
45
|
+
"""Normalize a vector."""
|
|
46
|
+
return v / v.norm()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@ti.func
|
|
50
|
+
def normalize_safe(v: Vector3) -> Vector3:
|
|
51
|
+
"""Normalize a vector with safety check for zero-length."""
|
|
52
|
+
len_sq = v.dot(v)
|
|
53
|
+
if len_sq > 1e-10:
|
|
54
|
+
return v / ti.sqrt(len_sq)
|
|
55
|
+
return Vector3(0.0, 0.0, 1.0)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@ti.func
|
|
59
|
+
def dot(v1: Vector3, v2: Vector3) -> ti.f32:
|
|
60
|
+
"""Dot product of two vectors."""
|
|
61
|
+
return v1.dot(v2)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@ti.func
|
|
65
|
+
def cross(v1: Vector3, v2: Vector3) -> Vector3:
|
|
66
|
+
"""Cross product of two vectors."""
|
|
67
|
+
return v1.cross(v2)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@ti.func
|
|
71
|
+
def reflect(v: Vector3, n: Vector3) -> Vector3:
|
|
72
|
+
"""Reflect vector v around normal n."""
|
|
73
|
+
return v - 2.0 * v.dot(n) * n
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@ti.func
|
|
77
|
+
def ray_at(origin: Point3, direction: Vector3, t: ti.f32) -> Point3:
|
|
78
|
+
"""Get point along ray at parameter t."""
|
|
79
|
+
return origin + t * direction
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@ti.func
|
|
83
|
+
def length_squared(v: Vector3) -> ti.f32:
|
|
84
|
+
"""Compute squared length of vector (avoids sqrt)."""
|
|
85
|
+
return v.dot(v)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@ti.func
|
|
89
|
+
def distance_squared(p1: Point3, p2: Point3) -> ti.f32:
|
|
90
|
+
"""Compute squared distance between two points (avoids sqrt)."""
|
|
91
|
+
diff = p2 - p1
|
|
92
|
+
return diff.dot(diff)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@ti.func
|
|
96
|
+
def min3(a: ti.f32, b: ti.f32, c: ti.f32) -> ti.f32:
|
|
97
|
+
"""Branchless minimum of three values."""
|
|
98
|
+
return ti.min(a, ti.min(b, c))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@ti.func
|
|
102
|
+
def max3(a: ti.f32, b: ti.f32, c: ti.f32) -> ti.f32:
|
|
103
|
+
"""Branchless maximum of three values."""
|
|
104
|
+
return ti.max(a, ti.max(b, c))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@ti.func
|
|
108
|
+
def clamp(x: ti.f32, lo: ti.f32, hi: ti.f32) -> ti.f32:
|
|
109
|
+
"""Clamp value to range [lo, hi]."""
|
|
110
|
+
return ti.max(lo, ti.min(hi, x))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@ti.func
|
|
114
|
+
def random_in_unit_sphere() -> Vector3:
|
|
115
|
+
"""Generate random point in unit sphere."""
|
|
116
|
+
theta = ti.random() * TWO_PI
|
|
117
|
+
v = ti.random()
|
|
118
|
+
phi = ti.acos(2.0 * v - 1.0)
|
|
119
|
+
r = ti.random() ** (1.0 / 3.0)
|
|
120
|
+
return Vector3(
|
|
121
|
+
r * ti.sin(phi) * ti.cos(theta),
|
|
122
|
+
r * ti.sin(phi) * ti.sin(theta),
|
|
123
|
+
r * ti.cos(phi)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@ti.func
|
|
128
|
+
def random_in_hemisphere(normal: Vector3) -> Vector3:
|
|
129
|
+
"""Generate random vector in hemisphere around normal."""
|
|
130
|
+
vec = random_in_unit_sphere()
|
|
131
|
+
if vec.dot(normal) < 0.0:
|
|
132
|
+
vec = -vec
|
|
133
|
+
return vec
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@ti.func
|
|
137
|
+
def random_cosine_hemisphere(normal: Vector3) -> Vector3:
|
|
138
|
+
"""
|
|
139
|
+
Generate random vector with cosine-weighted distribution in hemisphere.
|
|
140
|
+
Used for diffuse radiation sampling.
|
|
141
|
+
"""
|
|
142
|
+
u1 = ti.random()
|
|
143
|
+
u2 = ti.random()
|
|
144
|
+
r = ti.sqrt(u1)
|
|
145
|
+
theta = TWO_PI * u2
|
|
146
|
+
|
|
147
|
+
x = r * ti.cos(theta)
|
|
148
|
+
y = r * ti.sin(theta)
|
|
149
|
+
z = ti.sqrt(1.0 - u1)
|
|
150
|
+
|
|
151
|
+
# Create orthonormal basis around normal
|
|
152
|
+
up = Vector3(0.0, 1.0, 0.0)
|
|
153
|
+
if ti.abs(normal.y) > 0.999:
|
|
154
|
+
up = Vector3(1.0, 0.0, 0.0)
|
|
155
|
+
|
|
156
|
+
tangent = normalize(cross(up, normal))
|
|
157
|
+
bitangent = cross(normal, tangent)
|
|
158
|
+
|
|
159
|
+
return normalize(x * tangent + y * bitangent + z * normal)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@ti.func
|
|
163
|
+
def spherical_to_cartesian(azimuth: ti.f32, elevation: ti.f32) -> Vector3:
|
|
164
|
+
"""
|
|
165
|
+
Convert spherical coordinates to Cartesian unit vector.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
azimuth: Angle from north (y-axis), clockwise, in radians
|
|
169
|
+
elevation: Angle from horizontal, in radians (0 = horizontal, pi/2 = zenith)
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Unit vector (x, y, z) where z is vertical (up)
|
|
173
|
+
"""
|
|
174
|
+
cos_elev = ti.cos(elevation)
|
|
175
|
+
sin_elev = ti.sin(elevation)
|
|
176
|
+
cos_azim = ti.cos(azimuth)
|
|
177
|
+
sin_azim = ti.sin(azimuth)
|
|
178
|
+
|
|
179
|
+
# x = east, y = north, z = up
|
|
180
|
+
x = cos_elev * sin_azim
|
|
181
|
+
y = cos_elev * cos_azim
|
|
182
|
+
z = sin_elev
|
|
183
|
+
|
|
184
|
+
return Vector3(x, y, z)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@ti.func
|
|
188
|
+
def cartesian_to_spherical(v: Vector3) -> ti.math.vec2:
|
|
189
|
+
"""
|
|
190
|
+
Convert Cartesian unit vector to spherical coordinates.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
vec2(azimuth, elevation) in radians
|
|
194
|
+
"""
|
|
195
|
+
elevation = ti.asin(ti.math.clamp(v.z, -1.0, 1.0))
|
|
196
|
+
azimuth = ti.atan2(v.x, v.y)
|
|
197
|
+
if azimuth < 0.0:
|
|
198
|
+
azimuth += TWO_PI
|
|
199
|
+
return ti.math.vec2(azimuth, elevation)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@ti.func
|
|
203
|
+
def rotate_vector_axis_angle(vec: Vector3, axis: Vector3, angle: ti.f32) -> Vector3:
|
|
204
|
+
"""
|
|
205
|
+
Rotate vector around an axis by a given angle using Rodrigues' rotation formula.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
vec: Vector to rotate
|
|
209
|
+
axis: Rotation axis (will be normalized)
|
|
210
|
+
angle: Rotation angle in radians
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Rotated vector
|
|
214
|
+
"""
|
|
215
|
+
axis_len = axis.norm()
|
|
216
|
+
if axis_len < 1e-12:
|
|
217
|
+
return vec
|
|
218
|
+
|
|
219
|
+
k = axis / axis_len
|
|
220
|
+
c = ti.cos(angle)
|
|
221
|
+
s = ti.sin(angle)
|
|
222
|
+
|
|
223
|
+
# Rodrigues' rotation formula: v_rot = v*cos(θ) + (k×v)*sin(θ) + k*(k·v)*(1-cos(θ))
|
|
224
|
+
v_rot = vec * c + cross(k, vec) * s + k * dot(k, vec) * (1.0 - c)
|
|
225
|
+
|
|
226
|
+
return v_rot
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@ti.func
|
|
230
|
+
def build_face_basis(normal: Vector3):
|
|
231
|
+
"""
|
|
232
|
+
Build orthonormal basis for a face with given normal.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
Tuple of (tangent, bitangent, normal) vectors
|
|
236
|
+
"""
|
|
237
|
+
n_len = normal.norm()
|
|
238
|
+
if n_len < 1e-12:
|
|
239
|
+
return Vector3(1.0, 0.0, 0.0), Vector3(0.0, 1.0, 0.0), Vector3(0.0, 0.0, 1.0)
|
|
240
|
+
|
|
241
|
+
n = normal / n_len
|
|
242
|
+
|
|
243
|
+
# Choose helper vector not parallel to normal
|
|
244
|
+
helper = Vector3(0.0, 0.0, 1.0)
|
|
245
|
+
if ti.abs(n.z) > 0.999:
|
|
246
|
+
helper = Vector3(1.0, 0.0, 0.0)
|
|
247
|
+
|
|
248
|
+
# Compute tangent via cross product
|
|
249
|
+
u = cross(helper, n)
|
|
250
|
+
u_len = u.norm()
|
|
251
|
+
if u_len < 1e-12:
|
|
252
|
+
u = Vector3(1.0, 0.0, 0.0)
|
|
253
|
+
else:
|
|
254
|
+
u = u / u_len
|
|
255
|
+
|
|
256
|
+
# Bitangent
|
|
257
|
+
v = cross(n, u)
|
|
258
|
+
|
|
259
|
+
return u, v, n
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@ti.data_oriented
|
|
263
|
+
class Rays:
|
|
264
|
+
"""
|
|
265
|
+
Array of rays for batch processing.
|
|
266
|
+
Similar to ray-tracing-one-weekend-taichi but adapted for view/solar tracing.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(self, n_rays: int):
|
|
270
|
+
self.n_rays = n_rays
|
|
271
|
+
self.origin = ti.Vector.field(3, dtype=ti.f32, shape=(n_rays,))
|
|
272
|
+
self.direction = ti.Vector.field(3, dtype=ti.f32, shape=(n_rays,))
|
|
273
|
+
self.transparency = ti.field(dtype=ti.f32, shape=(n_rays,))
|
|
274
|
+
self.active = ti.field(dtype=ti.i32, shape=(n_rays,))
|
|
275
|
+
|
|
276
|
+
@ti.func
|
|
277
|
+
def set(self, idx: ti.i32, origin: Point3, direction: Vector3, transp: ti.f32):
|
|
278
|
+
self.origin[idx] = origin
|
|
279
|
+
self.direction[idx] = direction
|
|
280
|
+
self.transparency[idx] = transp
|
|
281
|
+
self.active[idx] = 1
|
|
282
|
+
|
|
283
|
+
@ti.func
|
|
284
|
+
def get(self, idx: ti.i32):
|
|
285
|
+
return self.origin[idx], self.direction[idx], self.transparency[idx]
|
|
286
|
+
|
|
287
|
+
@ti.func
|
|
288
|
+
def deactivate(self, idx: ti.i32):
|
|
289
|
+
self.active[idx] = 0
|
|
290
|
+
|
|
291
|
+
@ti.func
|
|
292
|
+
def is_active(self, idx: ti.i32) -> ti.i32:
|
|
293
|
+
return self.active[idx]
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@ti.data_oriented
|
|
297
|
+
class HitRecord:
|
|
298
|
+
"""
|
|
299
|
+
Store ray-surface intersection results.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
def __init__(self, n_rays: int):
|
|
303
|
+
self.n_rays = n_rays
|
|
304
|
+
self.hit = ti.field(dtype=ti.i32, shape=(n_rays,))
|
|
305
|
+
self.t = ti.field(dtype=ti.f32, shape=(n_rays,))
|
|
306
|
+
self.point = ti.Vector.field(3, dtype=ti.f32, shape=(n_rays,))
|
|
307
|
+
self.normal = ti.Vector.field(3, dtype=ti.f32, shape=(n_rays,))
|
|
308
|
+
self.surface_id = ti.field(dtype=ti.i32, shape=(n_rays,))
|
|
309
|
+
|
|
310
|
+
@ti.func
|
|
311
|
+
def set(self, idx: ti.i32, hit: ti.i32, t: ti.f32, point: Point3,
|
|
312
|
+
normal: Vector3, surface_id: ti.i32):
|
|
313
|
+
self.hit[idx] = hit
|
|
314
|
+
self.t[idx] = t
|
|
315
|
+
self.point[idx] = point
|
|
316
|
+
self.normal[idx] = normal
|
|
317
|
+
self.surface_id[idx] = surface_id
|
|
318
|
+
|
|
319
|
+
@ti.func
|
|
320
|
+
def get(self, idx: ti.i32):
|
|
321
|
+
return (self.hit[idx], self.t[idx], self.point[idx],
|
|
322
|
+
self.normal[idx], self.surface_id[idx])
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared domain definition for simulator_gpu.
|
|
3
|
+
|
|
4
|
+
Represents the 3D computational domain with:
|
|
5
|
+
- Grid cells (dx, dy, dz spacing)
|
|
6
|
+
- Topography (terrain height)
|
|
7
|
+
- Building geometry (3D obstacles)
|
|
8
|
+
- Plant canopy (Leaf Area Density - LAD)
|
|
9
|
+
- Tree mask for view analysis
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import taichi as ti
|
|
13
|
+
import numpy as np
|
|
14
|
+
from typing import Tuple, Optional, Union
|
|
15
|
+
from .core import Vector3, Point3, EXT_COEF
|
|
16
|
+
from .init_taichi import ensure_initialized
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Surface direction indices (matching PALM convention)
|
|
20
|
+
IUP = 0 # Upward facing (horizontal roof/ground)
|
|
21
|
+
IDOWN = 1 # Downward facing
|
|
22
|
+
INORTH = 2 # North facing (positive y)
|
|
23
|
+
ISOUTH = 3 # South facing (negative y)
|
|
24
|
+
IEAST = 4 # East facing (positive x)
|
|
25
|
+
IWEST = 5 # West facing (negative x)
|
|
26
|
+
|
|
27
|
+
# Direction normal vectors (x, y, z)
|
|
28
|
+
DIR_NORMALS = {
|
|
29
|
+
IUP: (0.0, 0.0, 1.0),
|
|
30
|
+
IDOWN: (0.0, 0.0, -1.0),
|
|
31
|
+
INORTH: (0.0, 1.0, 0.0),
|
|
32
|
+
ISOUTH: (0.0, -1.0, 0.0),
|
|
33
|
+
IEAST: (1.0, 0.0, 0.0),
|
|
34
|
+
IWEST: (-1.0, 0.0, 0.0),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@ti.data_oriented
|
|
39
|
+
class Domain:
|
|
40
|
+
"""
|
|
41
|
+
3D computational domain for simulation.
|
|
42
|
+
|
|
43
|
+
The domain uses a regular grid with:
|
|
44
|
+
- x: West to East
|
|
45
|
+
- y: South to North
|
|
46
|
+
- z: Ground to Sky
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
nx, ny, nz: Number of grid cells in each direction
|
|
50
|
+
dx, dy, dz: Grid spacing in meters
|
|
51
|
+
origin: (x, y, z) coordinates of domain origin
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
nx: int,
|
|
57
|
+
ny: int,
|
|
58
|
+
nz: int,
|
|
59
|
+
dx: float = 1.0,
|
|
60
|
+
dy: float = 1.0,
|
|
61
|
+
dz: float = 1.0,
|
|
62
|
+
origin: Tuple[float, float, float] = (0.0, 0.0, 0.0),
|
|
63
|
+
origin_lat: Optional[float] = None,
|
|
64
|
+
origin_lon: Optional[float] = None
|
|
65
|
+
):
|
|
66
|
+
"""
|
|
67
|
+
Initialize the domain.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
nx, ny, nz: Grid dimensions
|
|
71
|
+
dx, dy, dz: Grid spacing (m)
|
|
72
|
+
origin: Domain origin coordinates
|
|
73
|
+
origin_lat: Latitude for solar calculations (degrees)
|
|
74
|
+
origin_lon: Longitude for solar calculations (degrees)
|
|
75
|
+
"""
|
|
76
|
+
# Ensure Taichi is initialized before creating any fields
|
|
77
|
+
ensure_initialized()
|
|
78
|
+
|
|
79
|
+
self.nx = nx
|
|
80
|
+
self.ny = ny
|
|
81
|
+
self.nz = nz
|
|
82
|
+
self.dx = dx
|
|
83
|
+
self.dy = dy
|
|
84
|
+
self.dz = dz
|
|
85
|
+
self.origin = origin
|
|
86
|
+
self.origin_lat = origin_lat if origin_lat is not None else 0.0
|
|
87
|
+
self.origin_lon = origin_lon if origin_lon is not None else 0.0
|
|
88
|
+
|
|
89
|
+
# Domain bounds
|
|
90
|
+
self.x_min = origin[0]
|
|
91
|
+
self.x_max = origin[0] + nx * dx
|
|
92
|
+
self.y_min = origin[1]
|
|
93
|
+
self.y_max = origin[1] + ny * dy
|
|
94
|
+
self.z_min = origin[2]
|
|
95
|
+
self.z_max = origin[2] + nz * dz
|
|
96
|
+
|
|
97
|
+
# Grid cell volume
|
|
98
|
+
self.cell_volume = dx * dy * dz
|
|
99
|
+
|
|
100
|
+
# Topography: terrain height at each (i, j) column
|
|
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
|
|
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
|
+
def initialize_terrain(self, height: float = 0.0):
|
|
139
|
+
"""Alias for set_flat_terrain."""
|
|
140
|
+
self.set_flat_terrain(height)
|
|
141
|
+
|
|
142
|
+
@ti.kernel
|
|
143
|
+
def _set_flat_terrain_kernel(self, k_top: ti.i32):
|
|
144
|
+
for i, j in self.topo_top:
|
|
145
|
+
self.topo_top[i, j] = k_top
|
|
146
|
+
for k in range(k_top + 1):
|
|
147
|
+
self.is_solid[i, j, k] = 1
|
|
148
|
+
|
|
149
|
+
def set_terrain_from_array(self, terrain_height: np.ndarray):
|
|
150
|
+
"""
|
|
151
|
+
Set terrain from 2D numpy array of heights.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
terrain_height: 2D array (nx, ny) of terrain heights in meters
|
|
155
|
+
"""
|
|
156
|
+
terrain_k = (terrain_height / self.dz).astype(np.int32)
|
|
157
|
+
self._set_terrain_kernel(terrain_k)
|
|
158
|
+
|
|
159
|
+
@ti.kernel
|
|
160
|
+
def _set_terrain_kernel(self, terrain_k: ti.types.ndarray()):
|
|
161
|
+
for i, j in self.topo_top:
|
|
162
|
+
k_top = terrain_k[i, j]
|
|
163
|
+
self.topo_top[i, j] = k_top
|
|
164
|
+
for k in range(self.nz):
|
|
165
|
+
if k <= k_top:
|
|
166
|
+
self.is_solid[i, j, k] = 1
|
|
167
|
+
else:
|
|
168
|
+
self.is_solid[i, j, k] = 0
|
|
169
|
+
|
|
170
|
+
def add_building(
|
|
171
|
+
self,
|
|
172
|
+
x_range: Optional[Tuple[int, int]] = None,
|
|
173
|
+
y_range: Optional[Tuple[int, int]] = None,
|
|
174
|
+
z_range: Optional[Tuple[int, int]] = None,
|
|
175
|
+
*,
|
|
176
|
+
x_start: Optional[int] = None,
|
|
177
|
+
x_end: Optional[int] = None,
|
|
178
|
+
y_start: Optional[int] = None,
|
|
179
|
+
y_end: Optional[int] = None,
|
|
180
|
+
height: Optional[float] = None
|
|
181
|
+
):
|
|
182
|
+
"""
|
|
183
|
+
Add a rectangular building to the domain.
|
|
184
|
+
"""
|
|
185
|
+
# Handle convenience parameters
|
|
186
|
+
if x_start is not None and x_end is not None:
|
|
187
|
+
x_range = (x_start, x_end)
|
|
188
|
+
if y_start is not None and y_end is not None:
|
|
189
|
+
y_range = (y_start, y_end)
|
|
190
|
+
if height is not None and z_range is None:
|
|
191
|
+
k_top = int(height / self.dz) + 1
|
|
192
|
+
z_range = (0, k_top)
|
|
193
|
+
|
|
194
|
+
if x_range is None or y_range is None or z_range is None:
|
|
195
|
+
raise ValueError("Must provide either range tuples or individual parameters")
|
|
196
|
+
|
|
197
|
+
self._add_building_kernel(x_range[0], x_range[1], y_range[0], y_range[1], z_range[0], z_range[1])
|
|
198
|
+
|
|
199
|
+
@ti.kernel
|
|
200
|
+
def _add_building_kernel(self, i_min: ti.i32, i_max: ti.i32, j_min: ti.i32, j_max: ti.i32, k_min: ti.i32, k_max: ti.i32):
|
|
201
|
+
for i, j, k in ti.ndrange((i_min, i_max), (j_min, j_max), (k_min, k_max)):
|
|
202
|
+
self.is_solid[i, j, k] = 1
|
|
203
|
+
|
|
204
|
+
def add_tree(
|
|
205
|
+
self,
|
|
206
|
+
x_range: Tuple[int, int],
|
|
207
|
+
y_range: Tuple[int, int],
|
|
208
|
+
z_range: Tuple[int, int],
|
|
209
|
+
lad_value: float = 1.0
|
|
210
|
+
):
|
|
211
|
+
"""
|
|
212
|
+
Add a tree canopy region to the domain.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
x_range, y_range, z_range: Grid index ranges
|
|
216
|
+
lad_value: Leaf Area Density value
|
|
217
|
+
"""
|
|
218
|
+
self._add_tree_kernel(x_range[0], x_range[1], y_range[0], y_range[1], z_range[0], z_range[1], lad_value)
|
|
219
|
+
|
|
220
|
+
@ti.kernel
|
|
221
|
+
def _add_tree_kernel(self, i_min: ti.i32, i_max: ti.i32, j_min: ti.i32, j_max: ti.i32, k_min: ti.i32, k_max: ti.i32, lad: ti.f32):
|
|
222
|
+
for i, j, k in ti.ndrange((i_min, i_max), (j_min, j_max), (k_min, k_max)):
|
|
223
|
+
self.is_tree[i, j, k] = 1
|
|
224
|
+
self.lad[i, j, k] = lad
|
|
225
|
+
|
|
226
|
+
def set_from_voxel_data(self, voxel_data: np.ndarray, tree_code: int = -2, solid_codes: Optional[list] = None):
|
|
227
|
+
"""
|
|
228
|
+
Set domain from a 3D voxel data array.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
voxel_data: 3D numpy array with voxel class codes
|
|
232
|
+
tree_code: Class code for trees (default -2)
|
|
233
|
+
solid_codes: List of codes that are solid (default: all non-zero except tree_code)
|
|
234
|
+
"""
|
|
235
|
+
if solid_codes is None:
|
|
236
|
+
# All non-zero codes except tree are solid
|
|
237
|
+
solid_codes = []
|
|
238
|
+
|
|
239
|
+
self._set_from_voxel_data_kernel(voxel_data, tree_code)
|
|
240
|
+
|
|
241
|
+
@ti.kernel
|
|
242
|
+
def _set_from_voxel_data_kernel(self, voxel_data: ti.types.ndarray(), tree_code: ti.i32):
|
|
243
|
+
for i, j, k in ti.ndrange(self.nx, self.ny, self.nz):
|
|
244
|
+
val = voxel_data[i, j, k]
|
|
245
|
+
if val == tree_code:
|
|
246
|
+
self.is_tree[i, j, k] = 1
|
|
247
|
+
self.is_solid[i, j, k] = 0
|
|
248
|
+
elif val != 0:
|
|
249
|
+
self.is_solid[i, j, k] = 1
|
|
250
|
+
self.is_tree[i, j, k] = 0
|
|
251
|
+
else:
|
|
252
|
+
self.is_solid[i, j, k] = 0
|
|
253
|
+
self.is_tree[i, j, k] = 0
|
|
254
|
+
|
|
255
|
+
def get_max_dist(self) -> float:
|
|
256
|
+
"""Get maximum ray distance (domain diagonal)."""
|
|
257
|
+
import math
|
|
258
|
+
return math.sqrt(
|
|
259
|
+
(self.nx * self.dx)**2 +
|
|
260
|
+
(self.ny * self.dy)**2 +
|
|
261
|
+
(self.nz * self.dz)**2
|
|
262
|
+
)
|