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 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
+ )