cuwave 0.2.0__tar.gz → 0.6.0__tar.gz

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 (46) hide show
  1. {cuwave-0.2.0 → cuwave-0.6.0}/PKG-INFO +13 -7
  2. {cuwave-0.2.0 → cuwave-0.6.0}/README.md +12 -6
  3. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/anisotropic.py +103 -9
  4. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/boundary.py +110 -2
  5. cuwave-0.6.0/cuwave/decomposition.py +412 -0
  6. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/elastic.py +98 -52
  7. cuwave-0.6.0/cuwave/kernels/anisotropic.cu +503 -0
  8. cuwave-0.6.0/cuwave/kernels/common.cuh +264 -0
  9. cuwave-0.6.0/cuwave/kernels/elastic.cu +667 -0
  10. cuwave-0.6.0/cuwave/kernels/maxwell.cu +367 -0
  11. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/kernels/scalar.cu +138 -0
  12. cuwave-0.6.0/cuwave/kernels/scalar_sensitivity.cu +632 -0
  13. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/maxwell.py +47 -16
  14. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/optimization.py +9 -7
  15. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/regularization.py +100 -0
  16. cuwave-0.6.0/cuwave/scalar.py +428 -0
  17. cuwave-0.6.0/cuwave/sensitivity.py +1072 -0
  18. cuwave-0.6.0/cuwave/stencils.py +80 -0
  19. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/utils.py +173 -5
  20. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/wave.py +280 -27
  21. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave.egg-info/PKG-INFO +13 -7
  22. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave.egg-info/SOURCES.txt +1 -0
  23. {cuwave-0.2.0 → cuwave-0.6.0}/pyproject.toml +1 -1
  24. cuwave-0.2.0/cuwave/kernels/anisotropic.cu +0 -174
  25. cuwave-0.2.0/cuwave/kernels/common.cuh +0 -95
  26. cuwave-0.2.0/cuwave/kernels/elastic.cu +0 -225
  27. cuwave-0.2.0/cuwave/kernels/maxwell.cu +0 -154
  28. cuwave-0.2.0/cuwave/kernels/scalar_sensitivity.cu +0 -140
  29. cuwave-0.2.0/cuwave/scalar.py +0 -216
  30. cuwave-0.2.0/cuwave/sensitivity.py +0 -550
  31. cuwave-0.2.0/cuwave/stencils.py +0 -48
  32. {cuwave-0.2.0 → cuwave-0.6.0}/LICENSE +0 -0
  33. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/__init__.py +0 -0
  34. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/evals.py +0 -0
  35. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/geometry.py +0 -0
  36. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/kernels/__init__.py +0 -0
  37. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/kernels/anisotropic_sensitivity.cu +0 -0
  38. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/kernels/elastic_sensitivity.cu +0 -0
  39. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/kernels/maxwell_sensitivity.cu +0 -0
  40. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/nn.py +0 -0
  41. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/postprocessing.py +0 -0
  42. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave/signals.py +0 -0
  43. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave.egg-info/dependency_links.txt +0 -0
  44. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave.egg-info/requires.txt +0 -0
  45. {cuwave-0.2.0 → cuwave-0.6.0}/cuwave.egg-info/top_level.txt +0 -0
  46. {cuwave-0.2.0 → cuwave-0.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cuwave
3
- Version: 0.2.0
3
+ Version: 0.6.0
4
4
  Summary: GPU finite-difference wave solver with differentiable adjoints
5
5
  Author-email: Leon Herrmann <herrmann.leon@pm.me>
6
6
  License-Expression: MIT
@@ -31,7 +31,7 @@ Dynamic: license-file
31
31
  <img src="https://raw.githubusercontent.com/cmpmech/cuwave/main/.assets/readme-light.webp" width="100%">
32
32
  </picture>
33
33
 
34
- **CuWave** is a single-GPU, differentiable finite difference wave propagation code.
34
+ **CuWave** is a GPU-accelerated (single or multi), differentiable finite difference wave propagation code.
35
35
  Possible applications include
36
36
 
37
37
  <table width="100%">
@@ -67,13 +67,16 @@ Possible applications include
67
67
  - see the [documentation](https://github.com/cmpmech/cuwave/blob/main/docs/Home.md) for how the code works (AI-assisted; verify with sources for critical details)
68
68
  - see [examples](https://github.com/cmpmech/cuwave/tree/main/examples) for how to apply the code
69
69
 
70
- ## Development status
71
-
72
- **Scalar** & **acoustic wave** equations have been developed over the last 2 years and are thoroughly validated.
70
+ ## Multiple GPUs
73
71
 
74
- > [!IMPORTANT]
75
- > **Elastic** & **electromagnetic wave** equations were developed with AI assistance (Claude) and have undergone less validation. The elastic wave equation is currently being validated against experimental results.
72
+ Several GPUs take one argument, and the driver otherwise stays as it is:
73
+ - `Simulation(..., devices=(0, 1))` splits **one grid** along its first axis over the listed GPUs, for grids too large or too slow for one. `simulate`, `sensitivity`, `reconstruction_sensitivity` and `superposition_sensitivity` then run split and return the same result as on one GPU, bitwise. Try it with the `DEVICES` setting of
74
+ - [examples/forward/scalarND.py](https://github.com/cmpmech/cuwave/blob/main/examples/forward/scalarND.py), the forward problem in 2D or 3D
75
+ - [examples/sensitivity/scalar2D_sensitivity.py](https://github.com/cmpmech/cuwave/blob/main/examples/sensitivity/scalar2D_sensitivity.py), the gradient
76
+ - `misfit_gradient(..., devices=[0, 1])` (and `measure`, `misfit`) deals the **shots** of an inversion out over the GPUs, which needs no communication and is the faster choice whenever one shot fits one GPU.
77
+ ## Development status
76
78
 
79
+ **Scalar** & **acoustic wave** equations have been developed over the last 2 years and are thoroughly validated. The **elastic wave** and **electromagnetic wave** equations were developed with AI assistance (Claude). The first is validated on experimental data, while the latter are cross-validated against the independent FDTD code [FDTDX](https://github.com/ymahlau/fdtdx).
77
80
  ## Performance
78
81
 
79
82
  CuWave's runtime for identical discretizations is comparable to that of other established wave propagation finite difference codes. Speedups in 2D with reflecting boundaries (**a ratio above 1 means CuWave is that many times faster**) compared to the following frameworks:
@@ -105,6 +108,9 @@ If you use our code for your scientific research, please acknowledge this by ref
105
108
 
106
109
  _Herrmann, L., Bürchner, T., Kudela, L., Kollmannsberger, S., 2026, **A memory-efficient adjoint method to enable billion parameter optimization on a single GPU in dynamic problems**, Structural and Multidisciplinary Optimization, Volume 69, 52 (2026), DOI: [10.1007/s00158-025-04237-y](https://doi.org/10.1007/s00158-025-04237-y)_
107
110
 
111
+ ## Acknowledgements
112
+
113
+ Ideas adapted from [FDTDX](https://github.com/ymahlau/fdtdx) ([Mahlau, Schubert, Berg & Rosenhahn 2026](https://doi.org/10.21105/joss.08912)), which also serves as the reference for validating the electromagnetic solvers.
108
114
  ## Contact
109
115
 
110
116
  For questions, bug reports, or collaboration inquiries, please don't hesitate to contact Leon Herrmann at [herrmann.leon@pm.me](mailto:herrmann.leon@pm.me).
@@ -5,7 +5,7 @@
5
5
  <img src="https://raw.githubusercontent.com/cmpmech/cuwave/main/.assets/readme-light.webp" width="100%">
6
6
  </picture>
7
7
 
8
- **CuWave** is a single-GPU, differentiable finite difference wave propagation code.
8
+ **CuWave** is a GPU-accelerated (single or multi), differentiable finite difference wave propagation code.
9
9
  Possible applications include
10
10
 
11
11
  <table width="100%">
@@ -41,13 +41,16 @@ Possible applications include
41
41
  - see the [documentation](https://github.com/cmpmech/cuwave/blob/main/docs/Home.md) for how the code works (AI-assisted; verify with sources for critical details)
42
42
  - see [examples](https://github.com/cmpmech/cuwave/tree/main/examples) for how to apply the code
43
43
 
44
- ## Development status
45
-
46
- **Scalar** & **acoustic wave** equations have been developed over the last 2 years and are thoroughly validated.
44
+ ## Multiple GPUs
47
45
 
48
- > [!IMPORTANT]
49
- > **Elastic** & **electromagnetic wave** equations were developed with AI assistance (Claude) and have undergone less validation. The elastic wave equation is currently being validated against experimental results.
46
+ Several GPUs take one argument, and the driver otherwise stays as it is:
47
+ - `Simulation(..., devices=(0, 1))` splits **one grid** along its first axis over the listed GPUs, for grids too large or too slow for one. `simulate`, `sensitivity`, `reconstruction_sensitivity` and `superposition_sensitivity` then run split and return the same result as on one GPU, bitwise. Try it with the `DEVICES` setting of
48
+ - [examples/forward/scalarND.py](https://github.com/cmpmech/cuwave/blob/main/examples/forward/scalarND.py), the forward problem in 2D or 3D
49
+ - [examples/sensitivity/scalar2D_sensitivity.py](https://github.com/cmpmech/cuwave/blob/main/examples/sensitivity/scalar2D_sensitivity.py), the gradient
50
+ - `misfit_gradient(..., devices=[0, 1])` (and `measure`, `misfit`) deals the **shots** of an inversion out over the GPUs, which needs no communication and is the faster choice whenever one shot fits one GPU.
51
+ ## Development status
50
52
 
53
+ **Scalar** & **acoustic wave** equations have been developed over the last 2 years and are thoroughly validated. The **elastic wave** and **electromagnetic wave** equations were developed with AI assistance (Claude). The first is validated on experimental data, while the latter are cross-validated against the independent FDTD code [FDTDX](https://github.com/ymahlau/fdtdx).
51
54
  ## Performance
52
55
 
53
56
  CuWave's runtime for identical discretizations is comparable to that of other established wave propagation finite difference codes. Speedups in 2D with reflecting boundaries (**a ratio above 1 means CuWave is that many times faster**) compared to the following frameworks:
@@ -79,6 +82,9 @@ If you use our code for your scientific research, please acknowledge this by ref
79
82
 
80
83
  _Herrmann, L., Bürchner, T., Kudela, L., Kollmannsberger, S., 2026, **A memory-efficient adjoint method to enable billion parameter optimization on a single GPU in dynamic problems**, Structural and Multidisciplinary Optimization, Volume 69, 52 (2026), DOI: [10.1007/s00158-025-04237-y](https://doi.org/10.1007/s00158-025-04237-y)_
81
84
 
85
+ ## Acknowledgements
86
+
87
+ Ideas adapted from [FDTDX](https://github.com/ymahlau/fdtdx) ([Mahlau, Schubert, Berg & Rosenhahn 2026](https://doi.org/10.21105/joss.08912)), which also serves as the reference for validating the electromagnetic solvers.
82
88
  ## Contact
83
89
 
84
90
  For questions, bug reports, or collaboration inquiries, please don't hesitate to contact Leon Herrmann at [herrmann.leon@pm.me](mailto:herrmann.leon@pm.me).
@@ -23,9 +23,17 @@ import cupy.typing as cpt
23
23
  import numpy as np
24
24
  import numpy.typing as npt
25
25
 
26
- from .boundary import Clamped, Traction, faces_with
26
+ from .boundary import Clamped, Traction, faces_with, pml_widths
27
27
  from .elastic import voigt
28
- from .wave import PAIRS, Simulation, apply_cell_weights, axis_geometry, grid_block
28
+ from .wave import (
29
+ PAIRS,
30
+ PML_THREADS,
31
+ Simulation,
32
+ apply_cell_weights,
33
+ axis_geometry,
34
+ grid_block,
35
+ slab_grid,
36
+ )
29
37
 
30
38
  KERNEL_PATH = Path(__file__).parent / "kernels" / "anisotropic.cu"
31
39
  SENSITIVITY_PATH = Path(__file__).parent / "kernels" / "anisotropic_sensitivity.cu"
@@ -96,6 +104,21 @@ def cell_stencil(
96
104
  return K
97
105
 
98
106
 
107
+ def backward_axes(C: npt.NDArray, ndim: int, samples: int = 4096) -> list[int]:
108
+ """Axes along which a wave of the Voigt `C` runs against its phase, sampled over directions."""
109
+ row = np.zeros((ndim, ndim), dtype=int)
110
+ for r, (k, m) in enumerate(PAIRS[ndim]):
111
+ row[k, m] = row[m, k] = r
112
+ T = C[row[:, :, None, None], row[None, None]]
113
+ n = np.random.default_rng(0).standard_normal((samples, ndim))
114
+ n /= np.linalg.norm(n, axis=1, keepdims=True)
115
+ _, v = np.linalg.eigh(np.einsum("ikjl,nk,nl->nij", T, n, n))
116
+ # the group velocity of each mode, up to its positive 1 / (2 omega)
117
+ group = np.einsum("nip,imjl,nl,njp->npm", v, T + T.transpose(0, 3, 2, 1), n, v)
118
+ group /= np.linalg.norm(group, axis=2, keepdims=True)
119
+ return [d for d in range(ndim) if (n[:, None, d] * group[..., d]).min() < -1e-6]
120
+
121
+
99
122
  def cell_average(sim: Simulation, field: cpt.NDArray) -> cpt.NDArray:
100
123
  """Harmonic mean of `field` over the `2**ndim` corners of a cell, at its low corner.
101
124
 
@@ -137,6 +160,9 @@ class AnisotropicElasticWave(Simulation):
137
160
  sensitivity_path = SENSITIVITY_PATH
138
161
  default_boundary = Traction
139
162
  gradient_names = ("mass", "stiff", "cell")
163
+ accepts_pml = True
164
+ pml_shifted = True
165
+ pml_walls = (Clamped,)
140
166
 
141
167
  @property
142
168
  def ncomp(self) -> int:
@@ -172,6 +198,14 @@ class AnisotropicElasticWave(Simulation):
172
198
  raise ValueError(f"C must be ({nvoigt}, {nvoigt}): {self.C.shape}")
173
199
  if not np.allclose(self.C, self.C.T):
174
200
  raise ValueError("C must be symmetric")
201
+ widths = pml_widths(self) if self.pml else []
202
+ lined = [d for d, w in enumerate(widths) if sum(w)]
203
+ backward = [d for d in backward_axes(self.C, self.ndim) if d in lined]
204
+ if backward:
205
+ raise ValueError(
206
+ f"C carries a wave against its phase along axes {backward}, which "
207
+ f"a pml grows (Becache et al. 2003): use sponge"
208
+ )
175
209
 
176
210
  @property
177
211
  def lame(self) -> float:
@@ -183,14 +217,17 @@ class AnisotropicElasticWave(Simulation):
183
217
  """Second Lame parameter `rho0 * c_s**2`."""
184
218
  return self.density * self.wavespeed_s**2
185
219
 
186
- def stencil(self) -> cpt.NDArray:
220
+ def stiffness_matrix(self) -> npt.NDArray:
221
+ """The Voigt stiffness at `gamma = 1`: `C`, or the isotropic one."""
222
+ if self.C is not None:
223
+ return self.C
224
+ return voigt(self.ndim, self.lame, self.shear, self.plane)
225
+
226
+ def stencil_table(self) -> cpt.NDArray:
187
227
  """Stencil table at `gamma = 1`: one cell's `-B^T C B`, flattened for the kernel."""
188
228
  if self._stencil is not None:
189
229
  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)
230
+ K = cell_stencil(self.ndim, self.dx, self.stiffness_matrix())
194
231
  self._stencil = cp.asarray(K.ravel(), dtype=self.dtype)
195
232
  return self._stencil
196
233
 
@@ -215,7 +252,7 @@ class AnisotropicElasticWave(Simulation):
215
252
  "minv": cp.ascontiguousarray(minv, dtype=self.dtype),
216
253
  "gamma": cp.ascontiguousarray(indicator, dtype=self.dtype),
217
254
  "cell": cell_average(self, indicator),
218
- "stencil": self.stencil(),
255
+ "stencil": self.stencil_table(),
219
256
  }
220
257
  if self.damping is not None:
221
258
  mat["damping"] = self.damping
@@ -245,6 +282,63 @@ class AnisotropicElasticWave(Simulation):
245
282
  """Gamma scales inertia and stiffness alike, so both derivatives are 1."""
246
283
  return 1.0, 1.0
247
284
 
285
+ def define_step(self, kernels: cp.RawModule, mat: dict) -> Callable:
286
+ """The cell step, followed by its pml terms if there are any."""
287
+ passes = ("pml_cell", "pml_force")
288
+ return self._define_pml(kernels, mat, passes)
289
+
290
+ def define_adjoint_step(
291
+ self, kernels: cp.RawModule, sens_kernels: cp.RawModule, mat: dict
292
+ ) -> Callable:
293
+ """The same step with the pml terms transposed, which breaks its symmetry."""
294
+ passes = ("pml_adjoint_memory", "pml_adjoint_cell", "pml_adjoint_force")
295
+ return self._define_pml(kernels, mat, passes)
296
+
297
+ def _define_pml(self, kernels: cp.RawModule, mat: dict, passes: tuple) -> Callable:
298
+ """The cell step, then each pass over every lined axis, all axes in one memory."""
299
+ fd_step = super().define_step(kernels, mat)
300
+ if self.pml is None:
301
+ return fd_step
302
+ slabs = self.pml_slabs()
303
+ sizes = [2 * self.ndim * (lo + hi) * plane for _, lo, hi, _, plane in slabs]
304
+ starts = np.cumsum([0, *sizes])
305
+ offsets = np.cumsum([0, *(slab[3].size for slab in slabs)])
306
+ # per axis: rows lo and hi, then where its memory and profile start
307
+ table = np.zeros((self.ndim, 4), dtype=np.int32)
308
+ for k, (d, lo, hi, _, _) in enumerate(slabs):
309
+ table[d] = lo, hi, starts[k], offsets[k]
310
+ cv = float(np.prod(self.dx)) * self.stiffness_matrix().ravel()
311
+ common = [
312
+ cp.zeros(int(starts[-1]), dtype=self.dtype),
313
+ mat["minv"],
314
+ mat["cell"],
315
+ cp.asarray(cv, dtype=self.dtype),
316
+ self.dtype(self.dt**2),
317
+ np.int32(self.comp_stride),
318
+ cp.concatenate([slab[3].ravel() for slab in slabs]),
319
+ cp.asarray(table.ravel()),
320
+ ]
321
+ geometry = axis_geometry(self, [self.dtype(1.0 / d) for d in self.dx])
322
+ launches = [
323
+ (
324
+ kernels.get_function(f"{name}_kernel"),
325
+ slab_grid(lo, hi, plane),
326
+ [None, None, *common, np.int32(d), *geometry],
327
+ )
328
+ for name in passes
329
+ for d, lo, hi, _, plane in slabs
330
+ ]
331
+
332
+ def pml_step(u0, u1, u2):
333
+ fd_step(u0, u1, u2)
334
+ for kernel, grid, args in launches:
335
+ args[0], args[1] = u1, u2
336
+ kernel(grid, (PML_THREADS,), args)
337
+ return u2
338
+
339
+ pml_step.memory = [common[0]]
340
+ return pml_step
341
+
248
342
  def step_factors(self) -> list:
249
343
  """`dt**2`; the grid spacing already sits in the stencil table."""
250
344
  return [self.dtype(self.dt**2)] * self.ndim
@@ -312,7 +406,7 @@ class AnisotropicElasticWave(Simulation):
312
406
  grid, block = grid_block(self)
313
407
  volume = float(np.prod(self.dx))
314
408
  args = [accs["mass"], accs["cell"], None, None, None] + [
315
- self.stencil(),
409
+ self.stencil_table(),
316
410
  self.dtype(sign * self.density * volume / (2.0 * self.dt) ** 2),
317
411
  self.dtype(-sign),
318
412
  np.int32(self.comp_stride),
@@ -5,8 +5,9 @@ setup can place one per face in `Simulation.boundary` long before the module is
5
5
  compiled. `define_boundary` turns the markers into the launch closures once
6
6
  `compile_kernels` has run, which is why `simulate` needs no separate preparation phase.
7
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.
8
+ `sponge` and `Pml` open the domain from the other side: they absorb in the material
9
+ behind a face rather than acting on the ghost ring. The sponge is a damping field; the
10
+ PML is declarative like a condition, its profiles built once the step is defined.
10
11
  """
11
12
 
12
13
  from __future__ import annotations
@@ -253,3 +254,110 @@ def sponge(
253
254
  return cp.ascontiguousarray(
254
255
  (2.0 * beta * taper**2 / (sim.dt * minv)).astype(sim.dtype)
255
256
  )
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class Pml:
261
+ """A convolutional perfectly matched layer behind `faces`, for `Simulation.pml`.
262
+
263
+ Every derivative along an axis carries a memory variable inside the `width` nodes
264
+ behind that axis' lined faces, its graded profile set by the target `reflection`
265
+ at the fastest `wavespeed` (Komatitsch & Martin 2007,
266
+ https://doi.org/10.1190/1.2757586).
267
+ """
268
+
269
+ width: int # layer depth in nodes, the wall node included
270
+ wavespeed: float # fastest wave speed in the layer, which sizes the damping
271
+ reflection: float = 1e-6 # target reflection at normal incidence
272
+ frequency: float = 0.0 # CFS shift pi * frequency through the layer, 0 for none
273
+ faces: tuple[int, ...] | None = None # the `2 * axis + side` codes, None for all
274
+
275
+
276
+ def validate_pml(sim: Simulation) -> None:
277
+ """Raise if `sim.pml` cannot be stepped: unsupported, combined, or too wide."""
278
+ layer = sim.pml
279
+ if not sim.accepts_pml:
280
+ raise NotImplementedError(f"{type(sim).__name__} has no pml; use sponge")
281
+ if sim.damping is not None or sim.masked:
282
+ raise ValueError("a pml takes neither a damping field nor a domain mask")
283
+ least = 2 + sim.pml_margin
284
+ if layer.width < least or layer.wavespeed <= 0.0 or not 0.0 < layer.reflection < 1:
285
+ raise ValueError(
286
+ f"a pml needs width >= {least}, a speed and 0 < reflection < 1: {layer}"
287
+ )
288
+ if sim.pml_shifted and layer.frequency <= 0.0:
289
+ raise ValueError(
290
+ f"{type(sim).__name__} keeps a static mode in an unshifted pml: set "
291
+ f"frequency, near the low end of the band"
292
+ )
293
+ # the transposed taps reach `reach` nodes past the layer, which must not meet
294
+ faces = _validate_layer(sim, layer.width + sim.reach, layer.faces)
295
+ if sim.pml_walls is not None:
296
+ for face in faces:
297
+ wall = sim.boundary[face // 2][face % 2]
298
+ if wall not in sim.pml_walls:
299
+ raise ValueError(
300
+ f"face {face} ends the pml on {wall}, which traps a growing wave: "
301
+ f"make it {' or '.join(map(repr, sim.pml_walls))}"
302
+ )
303
+
304
+
305
+ def pml_widths(sim: Simulation) -> list[tuple[int, int]]:
306
+ """Per axis, the (low, high) layer depths in nodes, 0 for a face left unlined."""
307
+ faces = _canonical_faces(sim.ndim, sim.pml.faces)
308
+ return [
309
+ tuple(sim.pml.width if 2 * d + side in faces else 0 for side in (0, 1))
310
+ for d in range(sim.ndim)
311
+ ]
312
+
313
+
314
+ def pml_profiles(sim: Simulation) -> list[cpt.NDArray | None]:
315
+ """Per lined axis, the (4, Nx[d]) recursion coefficients `a, b` at nodes and half points.
316
+
317
+ Row 0 and 1 are the node values, rows 2 and 3 those of the half point `a + 1/2`,
318
+ graded from 0 at the front to the wall and mirrored across it, so the ghost cell
319
+ sees what its mirror does. An axis with no lined face gets None.
320
+ """
321
+ layer = sim.pml
322
+ # the front node is undamped, and so are the margin nodes in front of it
323
+ L = layer.width - 1 - sim.pml_margin
324
+ profiles = []
325
+ for d, (lo, hi) in enumerate(pml_widths(sim)):
326
+ if not lo + hi:
327
+ profiles.append(None)
328
+ continue
329
+ n = sim.Nx[d]
330
+ damping = (
331
+ -3.0 * layer.wavespeed * np.log(layer.reflection) / (2.0 * L * sim.dx[d])
332
+ )
333
+ rows = []
334
+ for position in (np.arange(n) + shift for shift in (0.0, 0.5)):
335
+ distance = np.full(n, np.inf)
336
+ if lo:
337
+ distance = np.minimum(distance, np.abs(position - 1))
338
+ if hi:
339
+ distance = np.minimum(distance, np.abs(n - 2 - position))
340
+ depth = np.clip((L - distance) / L, 0.0, 1.0)
341
+ d0 = damping * depth**2
342
+ alpha = np.pi * layer.frequency
343
+ inside = depth > 0.0
344
+ b = np.where(inside, np.exp(-(d0 + alpha) * sim.dt), 1.0)
345
+ a = np.where(inside, d0 / np.maximum(d0 + alpha, 1e-300) * (b - 1.0), 0.0)
346
+ rows += [a, b]
347
+ profiles.append(cp.asarray(np.stack(rows), dtype=sim.dtype))
348
+ return profiles
349
+
350
+
351
+ def pml_nodes(sim: Simulation) -> cpt.NDArray:
352
+ """Interior nodes inside the layer, where the step carries memory terms."""
353
+ inside = cp.zeros(sim.Nx_padded, dtype=bool)
354
+ if sim.pml is None:
355
+ return inside
356
+ for d, (lo, hi) in enumerate(pml_widths(sim)):
357
+ index = [slice(None)] * sim.ndim
358
+ for rows in (slice(1, 1 + lo), slice(sim.Nx[d] - 1 - hi, sim.Nx[d] - 1)):
359
+ index[d] = rows
360
+ inside[tuple(index)] = True
361
+ interior = cp.zeros_like(inside)
362
+ interior[tuple(slice(1, n - 1) for n in sim.Nx)] = True
363
+ return inside & interior