cuwave 0.1.0__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.
- cuwave/__init__.py +8 -0
- cuwave/anisotropic.py +337 -0
- cuwave/boundary.py +255 -0
- cuwave/elastic.py +342 -0
- cuwave/evals.py +130 -0
- cuwave/geometry.py +226 -0
- cuwave/kernels/__init__.py +0 -0
- cuwave/kernels/anisotropic.cu +174 -0
- cuwave/kernels/anisotropic_sensitivity.cu +226 -0
- cuwave/kernels/common.cuh +95 -0
- cuwave/kernels/elastic.cu +225 -0
- cuwave/kernels/elastic_sensitivity.cu +217 -0
- cuwave/kernels/maxwell.cu +154 -0
- cuwave/kernels/maxwell_sensitivity.cu +139 -0
- cuwave/kernels/scalar.cu +164 -0
- cuwave/kernels/scalar_sensitivity.cu +140 -0
- cuwave/maxwell.py +416 -0
- cuwave/nn.py +99 -0
- cuwave/optimization.py +123 -0
- cuwave/postprocessing.py +181 -0
- cuwave/regularization.py +243 -0
- cuwave/scalar.py +224 -0
- cuwave/sensitivity.py +535 -0
- cuwave/signals.py +71 -0
- cuwave/stencils.py +48 -0
- cuwave/utils.py +472 -0
- cuwave/wave.py +518 -0
- cuwave-0.1.0.dist-info/METADATA +134 -0
- cuwave-0.1.0.dist-info/RECORD +32 -0
- cuwave-0.1.0.dist-info/WHEEL +5 -0
- cuwave-0.1.0.dist-info/licenses/LICENSE +21 -0
- cuwave-0.1.0.dist-info/top_level.txt +1 -0
cuwave/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""CuWave: a single-GPU, differentiable finite-difference wave solver"""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("cuwave")
|
|
7
|
+
except PackageNotFoundError: # a checkout on sys.path without an install
|
|
8
|
+
__version__ = "0.0.0"
|
cuwave/anisotropic.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Cell-assembled elasticity, the collocated sibling of the staggered `ElasticWave`.
|
|
2
|
+
|
|
3
|
+
The stencil is a 9-point one in 2D and 27-point in 3D, gathered cell by cell rather
|
|
4
|
+
than axis by axis, since a variable-coefficient elastic operator couples the components
|
|
5
|
+
through mixed derivatives that a per-axis flux cannot carry. Its coefficients are
|
|
6
|
+
derived as `-B^T C B` with `C` on the cell, which is what makes the operator exactly
|
|
7
|
+
symmetric for a varying material, never differentiates the material, leaves the
|
|
8
|
+
leapfrog reversible, and makes a traction-free surface the natural condition of the
|
|
9
|
+
interior-cell sum. `C` is any symmetric Voigt matrix, which is what earns this scheme
|
|
10
|
+
its keep next to the staggered one: order 2 only, but collocated components and a
|
|
11
|
+
general anisotropy.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import itertools
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import cupy as cp
|
|
22
|
+
import cupy.typing as cpt
|
|
23
|
+
import numpy as np
|
|
24
|
+
import numpy.typing as npt
|
|
25
|
+
|
|
26
|
+
from .boundary import Clamped, Traction, faces_with
|
|
27
|
+
from .elastic import voigt
|
|
28
|
+
from .wave import PAIRS, Simulation, apply_cell_weights, grid_block
|
|
29
|
+
|
|
30
|
+
KERNEL_PATH = Path(__file__).parent / "kernels" / "anisotropic.cu"
|
|
31
|
+
SENSITIVITY_PATH = Path(__file__).parent / "kernels" / "anisotropic_sensitivity.cu"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# -------------------------------------- helpers --------------------------------------
|
|
35
|
+
def corner_bits(ndim: int) -> list[tuple[int, ...]]:
|
|
36
|
+
"""The `2**ndim` nodes of a cell, node `c` having bit `d` set on the plus side of axis `d`."""
|
|
37
|
+
return [tuple((c >> d) & 1 for d in range(ndim)) for c in range(2**ndim)]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cell_stencil(
|
|
41
|
+
ndim: int, dx: tuple[float, ...], C: npt.NDArray
|
|
42
|
+
) -> npt.NDArray[np.float64]:
|
|
43
|
+
"""One cell's contribution to the nodal stencil, ordered (node, component).
|
|
44
|
+
|
|
45
|
+
Built as `sum_q w_q B_q^T C B_q` over the cell, `B` the discrete symmetric gradient
|
|
46
|
+
on the `2**ndim` corners, so the assembled operator is symmetric by construction
|
|
47
|
+
rather than by inspection. The entries collapse to the 9-point (27-point in 3D)
|
|
48
|
+
finite difference stencil
|
|
49
|
+
|
|
50
|
+
f_x = (lame + 2 mu) [M_y * D2_x] u_x + mu [M_x * D2_y] u_x
|
|
51
|
+
+ (lame + mu) [D_x * D_y] u_y
|
|
52
|
+
|
|
53
|
+
with the second difference `D2` = (1, -2, 1), the centred first difference `D` and
|
|
54
|
+
the transverse average `M` = (1, 4, 1) / 6. That average is the only departure from
|
|
55
|
+
the textbook elastic stencil, and it is what removes the checkerboard from the null
|
|
56
|
+
space: one quadrature point per axis would give (1, 2, 1) / 4 and leave it there as
|
|
57
|
+
an undamped hourglass mode.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
ndim: dimensionality of the cell.
|
|
61
|
+
dx: cell side per axis.
|
|
62
|
+
C: (voigt, voigt) symmetric stiffness matrix, from `elastic.voigt` or the
|
|
63
|
+
caller.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
the (n * ndim, n * ndim) matrix for `n = 2**ndim`, node `c` and component `i`
|
|
67
|
+
occupying row `c * ndim + i`, `c` running over the corners in `corner_bits`
|
|
68
|
+
order.
|
|
69
|
+
"""
|
|
70
|
+
corners = corner_bits(ndim)
|
|
71
|
+
nloc = len(corners) * ndim
|
|
72
|
+
nodes, weights = np.polynomial.legendre.leggauss(2)
|
|
73
|
+
nodes = 0.5 * (nodes + 1.0)
|
|
74
|
+
weights = 0.5 * weights * float(np.prod(dx)) ** (1.0 / ndim)
|
|
75
|
+
K = np.zeros((nloc, nloc))
|
|
76
|
+
for point in itertools.product(range(2), repeat=ndim):
|
|
77
|
+
weight = float(np.prod([weights[q] for q in point]))
|
|
78
|
+
value = [(1.0 - nodes[q], nodes[q]) for q in point]
|
|
79
|
+
dN = np.zeros((len(corners), ndim))
|
|
80
|
+
for m, entry in enumerate(corners):
|
|
81
|
+
for k in range(ndim):
|
|
82
|
+
term = (-1.0, 1.0)[entry[k]] / dx[k]
|
|
83
|
+
for axis in range(ndim):
|
|
84
|
+
if axis != k:
|
|
85
|
+
term *= value[axis][entry[axis]]
|
|
86
|
+
dN[m, k] = term
|
|
87
|
+
B = np.zeros((len(PAIRS[ndim]), nloc))
|
|
88
|
+
for r, (axis_k, axis_l) in enumerate(PAIRS[ndim]):
|
|
89
|
+
for m in range(len(corners)):
|
|
90
|
+
if axis_k == axis_l:
|
|
91
|
+
B[r, m * ndim + axis_k] += dN[m, axis_k]
|
|
92
|
+
else:
|
|
93
|
+
B[r, m * ndim + axis_l] += dN[m, axis_k] # engineering shear
|
|
94
|
+
B[r, m * ndim + axis_k] += dN[m, axis_l]
|
|
95
|
+
K += weight * (B.T @ C @ B)
|
|
96
|
+
return K
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def cell_average(sim: Simulation, field: cpt.NDArray) -> cpt.NDArray:
|
|
100
|
+
"""Harmonic mean of `field` over the `2**ndim` corners of a cell, at its low corner.
|
|
101
|
+
|
|
102
|
+
Harmonic and not arithmetic for the reason the scalar flux uses it: it keeps the
|
|
103
|
+
cell stiffness single valued across a material jump, and it is what holds the
|
|
104
|
+
stable timestep together at a high contrast, where a light node borders a stiff
|
|
105
|
+
cell.
|
|
106
|
+
"""
|
|
107
|
+
out = cp.zeros(sim.Nx_padded, dtype=sim.dtype)
|
|
108
|
+
safe = cp.maximum(field, cp.finfo(sim.dtype).tiny)
|
|
109
|
+
inner = tuple(slice(0, n - 1) for n in sim.Nx)
|
|
110
|
+
for corner in corner_bits(sim.ndim):
|
|
111
|
+
shifted = tuple(slice(b, n - 1 + b) for b, n in zip(corner, sim.Nx))
|
|
112
|
+
out[inner] += 1.0 / safe[shifted]
|
|
113
|
+
out[inner] = 2.0**sim.ndim / out[inner]
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ------------------------------- discretization setup --------------------------------
|
|
118
|
+
@dataclass
|
|
119
|
+
class AnisotropicElasticWave(Simulation):
|
|
120
|
+
"""Cell-assembled elasticity, parametrized by a density-scaling indicator gamma.
|
|
121
|
+
|
|
122
|
+
Both wave speeds are held fixed and gamma scales the density, so
|
|
123
|
+
`C = gamma * rho0 * C` scales inertia and stiffness alike and the stable timestep
|
|
124
|
+
does not move with the design. `C` overrides the isotropic Voigt matrix with an
|
|
125
|
+
anisotropic one at gamma = 1, which the cell assembly carries where the staggered
|
|
126
|
+
scheme cannot. Order 2 only: the cell gather costs `(2r)**(2 ndim)` per node, so a
|
|
127
|
+
wide stencil belongs to `ElasticWave`.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
density: float = None # background density rho0
|
|
131
|
+
wavespeed_p: float = None # pressure wave speed
|
|
132
|
+
wavespeed_s: float = None # shear wave speed
|
|
133
|
+
plane: str = "strain" # "strain" or "stress", 2D only
|
|
134
|
+
C: npt.NDArray | None = None # (voigt, voigt) stiffness at gamma = 1, or isotropic
|
|
135
|
+
|
|
136
|
+
kernel_path = KERNEL_PATH
|
|
137
|
+
sensitivity_path = SENSITIVITY_PATH
|
|
138
|
+
default_boundary = Traction
|
|
139
|
+
gradient_names = ("mass", "stiff", "cell")
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def ncomp(self) -> int:
|
|
143
|
+
"""One displacement component per axis."""
|
|
144
|
+
return self.ndim
|
|
145
|
+
|
|
146
|
+
def __post_init__(self) -> None:
|
|
147
|
+
"""Validate the material, the 2D plane assumption and the order, then derive the grid."""
|
|
148
|
+
super().__post_init__()
|
|
149
|
+
if None in (self.density, self.wavespeed_p, self.wavespeed_s):
|
|
150
|
+
raise ValueError(
|
|
151
|
+
"AnisotropicElasticWave requires density, wavespeed_p, wavespeed_s"
|
|
152
|
+
)
|
|
153
|
+
if self.plane not in ("strain", "stress"):
|
|
154
|
+
raise ValueError(f"plane must be strain or stress: {self.plane}")
|
|
155
|
+
if self.ndim == 3 and self.plane != "strain":
|
|
156
|
+
raise ValueError("plane stress is a 2D reduction, not a 3D one")
|
|
157
|
+
if self.space_order != 2:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"the cell gather costs (2r)**(2 ndim) per node, so order "
|
|
160
|
+
f"{self.space_order} belongs to the staggered ElasticWave"
|
|
161
|
+
)
|
|
162
|
+
self._stencil = None # built once and reused across material rebuilds
|
|
163
|
+
if self.wavespeed_s >= self.wavespeed_p:
|
|
164
|
+
raise ValueError(
|
|
165
|
+
f"wavespeed_s must be below wavespeed_p: "
|
|
166
|
+
f"{self.wavespeed_s} >= {self.wavespeed_p}"
|
|
167
|
+
)
|
|
168
|
+
nvoigt = len(PAIRS[self.ndim])
|
|
169
|
+
if self.C is not None:
|
|
170
|
+
self.C = np.asarray(self.C, dtype=float)
|
|
171
|
+
if self.C.shape != (nvoigt, nvoigt):
|
|
172
|
+
raise ValueError(f"C must be ({nvoigt}, {nvoigt}): {self.C.shape}")
|
|
173
|
+
if not np.allclose(self.C, self.C.T):
|
|
174
|
+
raise ValueError("C must be symmetric")
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def lame(self) -> float:
|
|
178
|
+
"""First Lame parameter `rho0 * (c_p**2 - 2 c_s**2)`."""
|
|
179
|
+
return self.density * (self.wavespeed_p**2 - 2.0 * self.wavespeed_s**2)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def shear(self) -> float:
|
|
183
|
+
"""Second Lame parameter `rho0 * c_s**2`."""
|
|
184
|
+
return self.density * self.wavespeed_s**2
|
|
185
|
+
|
|
186
|
+
def stencil(self) -> cpt.NDArray:
|
|
187
|
+
"""Stencil table at `gamma = 1`: one cell's `-B^T C B`, flattened for the kernel."""
|
|
188
|
+
if self._stencil is not None:
|
|
189
|
+
return self._stencil
|
|
190
|
+
C = self.C
|
|
191
|
+
if C is None:
|
|
192
|
+
C = voigt(self.ndim, self.lame, self.shear, self.plane)
|
|
193
|
+
K = cell_stencil(self.ndim, self.dx, C)
|
|
194
|
+
self._stencil = cp.asarray(K.ravel(), dtype=self.dtype)
|
|
195
|
+
return self._stencil
|
|
196
|
+
|
|
197
|
+
def cell_weights(self) -> cpt.NDArray:
|
|
198
|
+
"""Nodal cell weights W, halved once per wall the node sits on."""
|
|
199
|
+
return apply_cell_weights(self, cp.ones(self.Nx_padded, dtype=self.dtype))
|
|
200
|
+
|
|
201
|
+
def inverse_inertia(self, indicator: cpt.NDArray) -> cpt.NDArray:
|
|
202
|
+
"""Lumped `1 / (gamma rho0 V W)`, the mass the interior-cell assembly implies."""
|
|
203
|
+
volume = float(np.prod(self.dx))
|
|
204
|
+
mass = indicator * (self.density * volume) * self.cell_weights()
|
|
205
|
+
return 1.0 / cp.maximum(mass, cp.finfo(self.dtype).tiny)
|
|
206
|
+
|
|
207
|
+
def build_materials(self, indicator: cpt.NDArray) -> dict:
|
|
208
|
+
"""Lumped inverse inertia, the cell design field, and the stencil table."""
|
|
209
|
+
minv = self.inverse_inertia(indicator)
|
|
210
|
+
for face in faces_with(self, Clamped):
|
|
211
|
+
wall = [slice(None)] * self.ndim
|
|
212
|
+
wall[face // 2] = 1 if face % 2 == 0 else self.Nx[face // 2] - 2
|
|
213
|
+
minv[tuple(wall)] = 0.0
|
|
214
|
+
mat = {
|
|
215
|
+
"minv": cp.ascontiguousarray(minv, dtype=self.dtype),
|
|
216
|
+
"gamma": cp.ascontiguousarray(indicator, dtype=self.dtype),
|
|
217
|
+
"cell": cell_average(self, indicator),
|
|
218
|
+
"stencil": self.stencil(),
|
|
219
|
+
}
|
|
220
|
+
if self.damping is not None:
|
|
221
|
+
mat["damping"] = self.damping
|
|
222
|
+
return mat
|
|
223
|
+
|
|
224
|
+
def step_kernel_args(self, mat: dict) -> tuple:
|
|
225
|
+
"""Material, stencil table and component stride for the step kernel."""
|
|
226
|
+
args = (mat["minv"], mat["cell"], mat["stencil"])
|
|
227
|
+
if self.damping is not None:
|
|
228
|
+
args += (mat["damping"], self.dtype(self.dt))
|
|
229
|
+
return args + (np.int32(self.comp_stride),)
|
|
230
|
+
|
|
231
|
+
def excitation_weights(
|
|
232
|
+
self, mat: dict, lin_index: cpt.NDArray[cp.int32]
|
|
233
|
+
) -> cpt.NDArray:
|
|
234
|
+
"""Source weights `dt**2 / inertia`, the spatial node read off the folded index."""
|
|
235
|
+
node = lin_index % np.int32(self.comp_stride)
|
|
236
|
+
weight = mat["minv"].ravel()[node]
|
|
237
|
+
if self.damping is not None:
|
|
238
|
+
beta = 0.5 * weight * mat["damping"].ravel()[node] * self.dt
|
|
239
|
+
weight = weight / (1.0 + beta)
|
|
240
|
+
return (self.dtype(self.dt**2 * self.source_factor()) * weight).astype(
|
|
241
|
+
self.dtype
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def parametrization_jacobian(self, indicator: cpt.NDArray) -> tuple:
|
|
245
|
+
"""Gamma scales inertia and stiffness alike, so both derivatives are 1."""
|
|
246
|
+
return 1.0, 1.0
|
|
247
|
+
|
|
248
|
+
def step_factors(self) -> list:
|
|
249
|
+
"""`dt**2`; the grid spacing already sits in the stencil table."""
|
|
250
|
+
return [self.dtype(self.dt**2)] * self.ndim
|
|
251
|
+
|
|
252
|
+
def source_factor(self) -> float:
|
|
253
|
+
"""Source scaling, unscaled since rho0 is already folded into the lumped inertia."""
|
|
254
|
+
return 1.0
|
|
255
|
+
|
|
256
|
+
def axis_geometry(self) -> list:
|
|
257
|
+
"""Extents and previous-axis strides, the step kernel's tail without the factors."""
|
|
258
|
+
geom = [self.Nx[0]]
|
|
259
|
+
for d in range(1, self.ndim):
|
|
260
|
+
geom += [self.Nx[d], self.strides[d - 1]]
|
|
261
|
+
return geom
|
|
262
|
+
|
|
263
|
+
def gradient_fields(self, mat: dict) -> dict[str, cpt.NDArray]:
|
|
264
|
+
"""Nodal accumulators plus the cell one the stiffness density lands in first."""
|
|
265
|
+
grads = {
|
|
266
|
+
name: cp.zeros(self.Nx_padded, dtype=self.dtype)
|
|
267
|
+
for name in self.gradient_names
|
|
268
|
+
}
|
|
269
|
+
# the harmonic cell mean has a design dependent chain rule, so keep both fields
|
|
270
|
+
grads["material"] = mat["cell"]
|
|
271
|
+
grads["design"] = mat["gamma"]
|
|
272
|
+
return grads
|
|
273
|
+
|
|
274
|
+
def finalize_gradients(self, grads: dict, kernels: cp.RawModule) -> dict:
|
|
275
|
+
"""Weight the mass density by W and spread the cell density onto its corners."""
|
|
276
|
+
apply_cell_weights(self, grads["mass"])
|
|
277
|
+
cell_to_node = kernels.get_function("cell_to_node_kernel")
|
|
278
|
+
grid, block = grid_block(self)
|
|
279
|
+
cell_to_node(
|
|
280
|
+
grid,
|
|
281
|
+
block,
|
|
282
|
+
[
|
|
283
|
+
grads["stiff"],
|
|
284
|
+
grads["cell"],
|
|
285
|
+
grads["material"],
|
|
286
|
+
grads["design"],
|
|
287
|
+
self.dtype(1.0 / 2.0**self.ndim),
|
|
288
|
+
*self.axis_geometry(),
|
|
289
|
+
],
|
|
290
|
+
)
|
|
291
|
+
return {"mass": grads["mass"], "stiff": grads["stiff"]}
|
|
292
|
+
|
|
293
|
+
def define_gradient(
|
|
294
|
+
self, kernels: cp.RawModule, mat: dict, grads: dict
|
|
295
|
+
) -> Callable:
|
|
296
|
+
"""Closure accumulating the nodal mass and cell stiffness densities."""
|
|
297
|
+
gradient_kernel = kernels.get_function("gradient_kernel")
|
|
298
|
+
grid, block = grid_block(self)
|
|
299
|
+
# d(mass)/d(gamma) is rho0 V, the W of the lumping supplied by the epilogue
|
|
300
|
+
mass_factor = self.dtype(self.density * float(np.prod(self.dx)) / self.dt**2)
|
|
301
|
+
args = [grads["mass"], grads["cell"], None, None, None, None] + [
|
|
302
|
+
mat["stencil"],
|
|
303
|
+
mass_factor,
|
|
304
|
+
np.int32(self.comp_stride),
|
|
305
|
+
*self.axis_geometry(),
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
def gradient_step(u0, u1, u2, l1):
|
|
309
|
+
args[2], args[3], args[4], args[5] = u0, u1, u2, l1
|
|
310
|
+
gradient_kernel(grid, block, args)
|
|
311
|
+
|
|
312
|
+
return gradient_step
|
|
313
|
+
|
|
314
|
+
def define_frechet(
|
|
315
|
+
self, kernels: cp.RawModule, accs: dict, sign: float
|
|
316
|
+
) -> Callable:
|
|
317
|
+
"""Closure accumulating both quadratic densities of one field triplet, times `sign`."""
|
|
318
|
+
frechet_kernel = kernels.get_function("frechet_kernel")
|
|
319
|
+
grid, block = grid_block(self)
|
|
320
|
+
volume = float(np.prod(self.dx))
|
|
321
|
+
args = [accs["mass"], accs["cell"], None, None, None] + [
|
|
322
|
+
self.stencil(),
|
|
323
|
+
self.dtype(sign * self.density * volume / (2.0 * self.dt) ** 2),
|
|
324
|
+
self.dtype(-sign),
|
|
325
|
+
np.int32(self.comp_stride),
|
|
326
|
+
*self.axis_geometry(),
|
|
327
|
+
]
|
|
328
|
+
|
|
329
|
+
def frechet_step(u0, u1, u2):
|
|
330
|
+
args[2], args[3], args[4] = u0, u1, u2
|
|
331
|
+
frechet_kernel(grid, block, args)
|
|
332
|
+
|
|
333
|
+
return frechet_step
|
|
334
|
+
|
|
335
|
+
def adjoint_weights(self, sensors: cpt.NDArray[cp.int32]) -> cpt.NDArray:
|
|
336
|
+
"""Ones: the lumped inertia already carries W, and dJ/du is nodal, not a density."""
|
|
337
|
+
return cp.ones(sensors.shape[1], dtype=self.dtype)
|
cuwave/boundary.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Boundary conditions, one per (axis, side).
|
|
2
|
+
|
|
3
|
+
A `BoundaryCondition` is a declarative marker: it names a kernel and nothing more, so a
|
|
4
|
+
setup can place one per face in `Simulation.boundary` long before the module is
|
|
5
|
+
compiled. `define_boundary` turns the markers into the launch closures once
|
|
6
|
+
`compile_kernels` has run, which is why `simulate` needs no separate preparation phase.
|
|
7
|
+
|
|
8
|
+
`sponge` opens the domain from the other side: it dissipates in the material behind a
|
|
9
|
+
face rather than acting on the ghost ring, so it needs no kernel and no marker of its own.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import cupy as cp
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import Callable, Sequence
|
|
22
|
+
|
|
23
|
+
import cupy.typing as cpt
|
|
24
|
+
|
|
25
|
+
from .wave import Simulation
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, repr=False)
|
|
29
|
+
class BoundaryCondition:
|
|
30
|
+
"""Names a kernel in the equation's .cu, or nothing. Frozen, so it can key the grouping."""
|
|
31
|
+
|
|
32
|
+
kernel: str | None
|
|
33
|
+
name: str = ""
|
|
34
|
+
|
|
35
|
+
def __repr__(self) -> str:
|
|
36
|
+
# so that printing a Simulation shows the condition, not the kernel name
|
|
37
|
+
return self.name or self.kernel.removesuffix("_kernel")
|
|
38
|
+
|
|
39
|
+
def define(
|
|
40
|
+
self, sim: Simulation, kernels: cp.RawModule, faces: Sequence[int]
|
|
41
|
+
) -> Callable[[cpt.NDArray], None]:
|
|
42
|
+
"""closure applying this condition on `faces`, the codes 2 * axis + side"""
|
|
43
|
+
kernel = kernels.get_function(self.kernel)
|
|
44
|
+
threads = 256
|
|
45
|
+
|
|
46
|
+
# one thread per boundary node, edges and corners excluded
|
|
47
|
+
inner = [n - 2 for n in sim.Nx]
|
|
48
|
+
num_boundary = sum(
|
|
49
|
+
int(np.prod(inner[:d] + inner[d + 1 :])) for d in (f // 2 for f in faces)
|
|
50
|
+
)
|
|
51
|
+
grid = ((num_boundary + threads - 1) // threads,)
|
|
52
|
+
|
|
53
|
+
# a bitmask, so the kernel keeps its axis loop at compile time
|
|
54
|
+
mask = np.int32(sum(1 << f for f in faces))
|
|
55
|
+
|
|
56
|
+
geom = [sim.Nx[0]]
|
|
57
|
+
for d in range(1, sim.ndim):
|
|
58
|
+
geom += [sim.Nx[d], sim.strides[d - 1]]
|
|
59
|
+
|
|
60
|
+
args = [None, mask, *geom]
|
|
61
|
+
|
|
62
|
+
def step(u):
|
|
63
|
+
args[0] = u
|
|
64
|
+
kernel(grid, (threads,), args)
|
|
65
|
+
|
|
66
|
+
return step
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
Neumann = BoundaryCondition("homogeneous_neumann_kernel")
|
|
70
|
+
Dirichlet = BoundaryCondition("homogeneous_dirichlet_kernel")
|
|
71
|
+
# an elastic wall is the interior-cell assembly or a zeroed inertia: no kernel needed
|
|
72
|
+
Traction = BoundaryCondition(None, "traction")
|
|
73
|
+
Clamped = BoundaryCondition(None, "clamped")
|
|
74
|
+
# the same walls read electromagnetically, the tangential field held or left natural
|
|
75
|
+
Conductor = BoundaryCondition(None, "conductor")
|
|
76
|
+
Magnetic = BoundaryCondition(None, "magnetic")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def faces_with(sim: Simulation, condition: BoundaryCondition) -> list[int]:
|
|
80
|
+
"""The `2 * axis + side` codes of the faces `sim.boundary` marks with `condition`."""
|
|
81
|
+
return [
|
|
82
|
+
2 * d + side
|
|
83
|
+
for d, pair in enumerate(sim.boundary)
|
|
84
|
+
for side, marker in enumerate(pair)
|
|
85
|
+
if marker is condition
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def face_mask(sim: Simulation, condition: BoundaryCondition) -> np.int32:
|
|
90
|
+
"""The faces carrying `condition`, as the bitmask the kernels take."""
|
|
91
|
+
return np.int32(sum(1 << f for f in faces_with(sim, condition)))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def canonical_boundary(
|
|
95
|
+
boundary: BoundaryCondition | Sequence | None,
|
|
96
|
+
ndim: int,
|
|
97
|
+
default: BoundaryCondition = Neumann,
|
|
98
|
+
) -> tuple[tuple[BoundaryCondition, BoundaryCondition], ...]:
|
|
99
|
+
"""makes the boundary canonical ((low, high),) * ndim
|
|
100
|
+
|
|
101
|
+
Accepts `None` for the equation's `default` on every face.
|
|
102
|
+
"""
|
|
103
|
+
if boundary is None:
|
|
104
|
+
boundary = default
|
|
105
|
+
if isinstance(boundary, BoundaryCondition):
|
|
106
|
+
boundary = (boundary,) * ndim
|
|
107
|
+
if len(boundary) != ndim:
|
|
108
|
+
raise ValueError(f"boundary needs one (low, high) pair per axis: {ndim}")
|
|
109
|
+
pairs = []
|
|
110
|
+
for pair in boundary:
|
|
111
|
+
if isinstance(pair, BoundaryCondition):
|
|
112
|
+
pair = (pair, pair)
|
|
113
|
+
if len(pair) != 2:
|
|
114
|
+
raise ValueError("a boundary axis is a (low, high) pair of conditions")
|
|
115
|
+
pairs.append(tuple(pair))
|
|
116
|
+
return tuple(pairs)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def define_boundary(
|
|
120
|
+
sim: Simulation, kernels: cp.RawModule
|
|
121
|
+
) -> Callable[[cpt.NDArray], cpt.NDArray]:
|
|
122
|
+
"""one launch per distinct condition, so the default stays one launch."""
|
|
123
|
+
groups = {}
|
|
124
|
+
for d, pair in enumerate(sim.boundary):
|
|
125
|
+
for side, condition in enumerate(pair):
|
|
126
|
+
if condition.kernel is not None:
|
|
127
|
+
groups.setdefault(condition, []).append(2 * d + side)
|
|
128
|
+
steps = [
|
|
129
|
+
condition.define(sim, kernels, faces) for condition, faces in groups.items()
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
def bc_step(u):
|
|
133
|
+
for step in steps:
|
|
134
|
+
step(u)
|
|
135
|
+
return u
|
|
136
|
+
|
|
137
|
+
return bc_step
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _canonical_faces(ndim: int, faces: Sequence[int] | None) -> tuple[int, ...]:
|
|
141
|
+
"""Expand `None` to every face and check the codes fit `ndim`."""
|
|
142
|
+
faces = tuple(range(2 * ndim) if faces is None else faces)
|
|
143
|
+
if any(f not in range(2 * ndim) for f in faces):
|
|
144
|
+
raise ValueError(f"face codes run to {2 * ndim - 1} in {ndim}D: {faces}")
|
|
145
|
+
return faces
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _validate_layer(sim: Simulation, width: int, faces: Sequence[int] | None) -> tuple:
|
|
149
|
+
"""Check a layer fits behind `faces` and expand `None` to every face."""
|
|
150
|
+
if width < 1:
|
|
151
|
+
raise ValueError(f"width must be at least one node: {width}")
|
|
152
|
+
faces = _canonical_faces(sim.ndim, faces)
|
|
153
|
+
for d in range(sim.ndim):
|
|
154
|
+
sides = sum(2 * d + side in faces for side in (0, 1))
|
|
155
|
+
if sides * width >= sim.Nx[d] - 2:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"{sides} layer(s) of {width} nodes leave no interior on axis {d}: "
|
|
158
|
+
f"Nx={sim.Nx[d]}"
|
|
159
|
+
)
|
|
160
|
+
return faces
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _layer_taper(sim: Simulation, width: int, faces: Sequence[int]) -> cpt.NDArray:
|
|
164
|
+
"""Linear ramp over the `width` nodes behind `faces`, 1 on the wall and 0 inside."""
|
|
165
|
+
taper = cp.zeros(sim.Nx_padded, dtype=sim.dtype)
|
|
166
|
+
interior = cp.ones(sim.Nx_padded, dtype=bool)
|
|
167
|
+
for d in range(sim.ndim):
|
|
168
|
+
shape = [1] * sim.ndim
|
|
169
|
+
shape[d] = sim.Nx_padded[d]
|
|
170
|
+
index = cp.arange(sim.Nx_padded[d], dtype=sim.dtype).reshape(shape)
|
|
171
|
+
interior &= (index >= 1) & (index <= sim.Nx[d] - 2)
|
|
172
|
+
for side in (0, 1):
|
|
173
|
+
if 2 * d + side in faces:
|
|
174
|
+
wall = 1 if side == 0 else sim.Nx[d] - 2
|
|
175
|
+
ramp = cp.clip(1.0 - cp.abs(index - wall) / width, 0.0, 1.0)
|
|
176
|
+
# max, so a corner is graded once rather than by each of its faces
|
|
177
|
+
taper = cp.maximum(taper, ramp)
|
|
178
|
+
# the ghost ring is slaved to its mirror and the padding tail is never read
|
|
179
|
+
return taper * interior
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def pad_for_sponge(
|
|
183
|
+
Nx: tuple[int, ...],
|
|
184
|
+
dx: tuple[float, ...],
|
|
185
|
+
thickness: float,
|
|
186
|
+
faces: Sequence[int] | None = None,
|
|
187
|
+
) -> tuple[tuple[int, ...], int, tuple[float, ...], tuple[slice, ...]]:
|
|
188
|
+
"""Grow a region of interest `Nx` by a sponge layer of physical `thickness`.
|
|
189
|
+
|
|
190
|
+
The layer is grid the simulation carries but the application does not own, so what
|
|
191
|
+
a driver needs back is where its region of interest ends up: `origin` shifts the
|
|
192
|
+
coordinates it places transducers and defects at, and `region` selects it out of
|
|
193
|
+
the grown grid for a design mask or a figure.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
Nx: logical grid points per axis of the region of interest, ghost nodes
|
|
197
|
+
included.
|
|
198
|
+
dx: grid spacing per axis.
|
|
199
|
+
thickness: layer depth in physical units, taken in nodes off the finest axis so
|
|
200
|
+
that no face comes out thinner than asked for.
|
|
201
|
+
faces: the `2 * axis + side` codes to line, or None for every face.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
(Nx, width, origin, region): the grown extent to build the simulation on, the
|
|
205
|
+
layer `width` in nodes to hand `sponge`, the physical origin of the region of
|
|
206
|
+
interest per axis, and the index tuple selecting its interior nodes.
|
|
207
|
+
"""
|
|
208
|
+
if thickness <= 0.0:
|
|
209
|
+
raise ValueError(f"thickness must be positive: {thickness}")
|
|
210
|
+
faces = _canonical_faces(len(Nx), faces)
|
|
211
|
+
width = round(thickness / min(dx))
|
|
212
|
+
if width < 1:
|
|
213
|
+
raise ValueError(f"thickness {thickness} is under one node at dx {min(dx)}")
|
|
214
|
+
pads = [
|
|
215
|
+
tuple(width if 2 * d + side in faces else 0 for side in (0, 1))
|
|
216
|
+
for d in range(len(Nx))
|
|
217
|
+
]
|
|
218
|
+
grown = tuple(n + lo + hi for n, (lo, hi) in zip(Nx, pads))
|
|
219
|
+
origin = tuple(lo * h for (lo, _), h in zip(pads, dx))
|
|
220
|
+
region = tuple(slice(lo + 1, n - 1 - hi) for (lo, hi), n in zip(pads, grown))
|
|
221
|
+
return grown, width, origin, region
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def sponge(
|
|
225
|
+
sim: Simulation,
|
|
226
|
+
indicator: cpt.NDArray,
|
|
227
|
+
width: int,
|
|
228
|
+
beta: float,
|
|
229
|
+
faces: Sequence[int] | None = None,
|
|
230
|
+
) -> cpt.NDArray:
|
|
231
|
+
"""Damping field ramping to `beta` over the `width` nodes behind a face, 0 elsewhere.
|
|
232
|
+
|
|
233
|
+
The price is losslessness: the field is rejected by `superposition_sensitivity`,
|
|
234
|
+
whose reverse-time reconstruction needs an operator it can run backwards, and it is
|
|
235
|
+
what `reconstruction_sensitivity` records a strip of in order to march past it.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
sim: the simulation the field is built for, whose `dt` and inertia scale it.
|
|
239
|
+
indicator: the design field, read for the inertia the damping is scaled by.
|
|
240
|
+
width: layer thickness in nodes, measured inward from the wall node.
|
|
241
|
+
beta: peak `d * dt / 2m` at the wall, the dimensionless decay per step.
|
|
242
|
+
faces: the `2 * axis + side` codes to line, or None for every face.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
the field to set as `Simulation.damping`, over the padded grid, ramped
|
|
246
|
+
quadratically so the layer front is not a coherent reflector.
|
|
247
|
+
"""
|
|
248
|
+
if beta < 0.0:
|
|
249
|
+
raise ValueError(f"beta must be non-negative: {beta}")
|
|
250
|
+
faces = _validate_layer(sim, width, faces)
|
|
251
|
+
minv = sim.inverse_inertia(indicator)
|
|
252
|
+
taper = _layer_taper(sim, width, faces)
|
|
253
|
+
return cp.ascontiguousarray(
|
|
254
|
+
(2.0 * beta * taper**2 / (sim.dt * minv)).astype(sim.dtype)
|
|
255
|
+
)
|