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.
@@ -0,0 +1,181 @@
1
+ """Matplotlib helpers for the bare field figures the drivers produce
2
+
3
+ `matplotlib` is imported here and nowhere else in the package, the way torch is
4
+ confined to `nn.py`, so the solver keeps its cupy-only dependency. Everything works in
5
+ node index coordinates: an axes is the grid at one pixel per node, and a marker is
6
+ sized in nodes, so the same call gives the same figure at any resolution and any dpi
7
+ """
8
+
9
+ import io
10
+
11
+ import cupy.typing as cpt
12
+ import matplotlib.pyplot as plt
13
+ import numpy as np
14
+ import numpy.typing as npt
15
+ from matplotlib.axes import Axes
16
+ from matplotlib.figure import Figure
17
+ from PIL import Image
18
+
19
+
20
+ # -------------------------------------- helpers --------------------------------------
21
+ def _host(field: cpt.NDArray | npt.NDArray) -> npt.NDArray:
22
+ """`field` on the host, whichever array module it came from"""
23
+ return np.asarray(field.get() if hasattr(field, "get") else field)
24
+
25
+
26
+ def _panel_width(ax: Axes) -> float:
27
+ """Width of `ax` in inches, without asking for a renderer"""
28
+ return ax.get_position().width * ax.figure.get_figwidth()
29
+
30
+
31
+ # -------------------------------------- figures --------------------------------------
32
+ def field_axes(
33
+ resolution: tuple[int, ...],
34
+ dpi: int = 100,
35
+ pad: float = 0.05,
36
+ panels: int = 1,
37
+ gap: float = 0.15,
38
+ ) -> tuple[Figure, Axes]:
39
+ """Borderless axes the size of the grid, padded so a marker on the edge survives.
40
+
41
+ Args:
42
+ resolution: (nx, ny) nodes of the plotted array, which sets the figure size.
43
+ dpi: dots per inch, the figure being `resolution` pixels wide at 100.
44
+ pad: margin around the grid, as a fraction of the longest axis.
45
+ panels: side-by-side panels, each one `resolution` wide.
46
+ gap: spacing between panels, as a fraction of one panel's width.
47
+
48
+ Returns:
49
+ (fig, ax), `ax` a single axes for one panel and a `panels`-long array beyond.
50
+ """
51
+ width = resolution[0] / 100 * (panels + gap * (panels - 1))
52
+ fig, axes = plt.subplots(1, panels, figsize=(width, resolution[1] / 100), dpi=dpi)
53
+ margin = pad * max(resolution)
54
+ for ax in np.atleast_1d(axes):
55
+ ax.set_xlim(-margin, resolution[0] + margin)
56
+ ax.set_ylim(-margin, resolution[1] + margin)
57
+ ax.set_aspect("equal")
58
+ ax.axis("off")
59
+ fig.subplots_adjust(left=0, right=1, top=1, bottom=0, wspace=gap)
60
+ return fig, axes
61
+
62
+
63
+ def show(
64
+ ax: Axes,
65
+ field: cpt.NDArray | npt.NDArray | None = None,
66
+ indicator: cpt.NDArray | npt.NDArray | None = None,
67
+ cmap: str = "seismic",
68
+ saturation: float = 1.0,
69
+ scale: float | None = None,
70
+ indicator_cmap: str = "binary",
71
+ gray: float = 1.0,
72
+ ) -> Axes:
73
+ """Draw a wave field, a design, or the design over the field.
74
+
75
+ Args:
76
+ ax: the axes from `field_axes`, which fixes the node index coordinates.
77
+ field: (nx, ny) wave field, colored symmetrically about zero.
78
+ indicator: (nx, ny) design, drawn over `field` where it is solid and left
79
+ transparent elsewhere, or alone as a [0, 1] field in `cmap`.
80
+ cmap: colormap of `field`, or of `indicator` when that is drawn alone.
81
+ saturation: fraction of `scale` the colormap runs to, below 1 to clip the peak.
82
+ scale: the amplitude `field` saturates at, defaulting to its own peak.
83
+ indicator_cmap: colormap of the overlay, only reached together with `field`.
84
+ gray: the constant the overlay is drawn at, 1 black and 0 white in `binary`.
85
+ """
86
+ if field is not None:
87
+ values = _host(field)
88
+ scale = float(np.max(np.abs(values))) if scale is None else scale
89
+ limit = scale * saturation
90
+ ax.pcolormesh(values.T, cmap=cmap, vmin=-limit, vmax=limit)
91
+ if indicator is not None and field is None:
92
+ ax.pcolormesh(_host(indicator).T, cmap=cmap, vmin=0, vmax=1)
93
+ elif indicator is not None:
94
+ solid = np.where(_host(indicator), gray, np.nan)
95
+ ax.pcolormesh(solid.T, cmap=indicator_cmap, vmin=0, vmax=1)
96
+ return ax
97
+
98
+
99
+ def save(fig: Figure, path: str) -> None:
100
+ """Write `fig` to `path` on a transparent background, palettized where it can be.
101
+
102
+ A colormapped field holds a few hundred distinct colors, so 32 bits per pixel is
103
+ mostly waste: a figure that came out fully opaque is written as an 8-bit palette
104
+ instead. One carrying transparency keeps RGBA, since a palette entry has a single
105
+ alpha and cannot hold the intermediate ones of an antialiased marker.
106
+ """
107
+ buffer = io.BytesIO()
108
+ fig.savefig(buffer, format="png", transparent=True)
109
+ image = Image.open(buffer)
110
+ if image.mode != "RGBA" or image.getchannel("A").getextrema()[0] == 255:
111
+ image = image.convert("RGB").quantize(
112
+ colors=256, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE
113
+ )
114
+ image.save(path, optimize=True)
115
+
116
+
117
+ # ------------------------------------- annotation ------------------------------------
118
+ def marker_points(ax: Axes, nodes: float = 6.0) -> float:
119
+ """Marker size in points spanning `nodes` grid nodes, which points alone do not"""
120
+ low, high = ax.get_xlim()
121
+ return nodes * 80.0 * _panel_width(ax) / (high - low)
122
+
123
+
124
+ def markers(
125
+ ax: Axes,
126
+ positions: cpt.NDArray | npt.ArrayLike,
127
+ dx: tuple[float, ...] | None = None,
128
+ origin: int = 0,
129
+ nodes: float = 6.0,
130
+ color: str = "silver",
131
+ **style,
132
+ ) -> None:
133
+ """Dots at grid `positions`, sized in nodes so they match across figures.
134
+
135
+ Args:
136
+ ax: the axes from `field_axes`, whose limits fix the points-per-node scale.
137
+ positions: (ndim, count) grid node indices, or (count, ndim) physical
138
+ coordinates when `dx` is given.
139
+ dx: grid spacing, which turns physical coordinates into node indices.
140
+ origin: node index of the plotted array's first cell, 1 for an interior slice.
141
+ nodes: diameter of a dot in grid nodes, held the same figure to figure.
142
+ color: fill and edge color, gray reading over both a light and a dark field.
143
+ **style: passed to `plot`, e.g. `zorder` or `alpha`.
144
+ """
145
+ index = np.atleast_2d(_host(positions)).astype(float)
146
+ if dx is not None:
147
+ index = (index / np.asarray(dx)).T + 1.0
148
+ # the pcolormesh cell of node i is centered half a node past its corner
149
+ ax.plot(
150
+ index[0] - origin + 0.5,
151
+ index[1] - origin + 0.5,
152
+ "o",
153
+ markersize=marker_points(ax, nodes),
154
+ markerfacecolor=color,
155
+ markeredgecolor=color,
156
+ linestyle="none",
157
+ clip_on=False,
158
+ zorder=3,
159
+ **style,
160
+ )
161
+
162
+
163
+ def outline(
164
+ ax: Axes,
165
+ low: tuple[int, ...],
166
+ high: tuple[int, ...],
167
+ origin: int = 0,
168
+ color: str = "silver",
169
+ linewidth: float = 1.5,
170
+ ) -> None:
171
+ """Rectangle around the nodes `low` to `high`, marking a target or design region"""
172
+ ax.add_patch(
173
+ plt.Rectangle(
174
+ (low[0] - origin, low[1] - origin),
175
+ high[0] - low[0] + 1,
176
+ high[1] - low[1] + 1,
177
+ fill=False,
178
+ edgecolor=color,
179
+ linewidth=linewidth,
180
+ )
181
+ )
@@ -0,0 +1,243 @@
1
+ """Regularization and design-map penalties for gradient-based optimization.
2
+
3
+ A `Regularization` is both a differentiable map (`__call__`) and its own adjoint
4
+ (`grad`), so filters, projections, and penalties compose the same way the simulation's
5
+ forward/adjoint pair does: chain the calls forward, chain `grad` backward. `set` lets a
6
+ `continuation` schedule mutate a live instance's parameters between iterations without
7
+ rebuilding the pipeline.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import abc
13
+ import math
14
+ from collections.abc import Sequence
15
+
16
+ import cupy as cp
17
+ import cupy.typing as cpt
18
+ import cupyx.scipy.ndimage as ndi
19
+ import numpy.typing as npt
20
+
21
+
22
+ # -------------------------------------- helpers --------------------------------------
23
+ def _axis_slice(ndim: int, axis: int, cut: int | slice) -> tuple:
24
+ """Build an ndim-length index tuple selecting `cut` along `axis`, `slice(None)` elsewhere."""
25
+ index = [slice(None)] * ndim
26
+ index[axis] = cut
27
+ return tuple(index)
28
+
29
+
30
+ def _spatial_grad(x: cpt.NDArray, axis: int) -> cpt.NDArray:
31
+ """forward difference along `axis`, zero-padded at the far boundary"""
32
+ head = _axis_slice(x.ndim, axis, slice(None, -1))
33
+ tail = _axis_slice(x.ndim, axis, slice(1, None))
34
+ d = cp.zeros_like(x)
35
+ d[head] = x[tail] - x[head]
36
+ return d
37
+
38
+
39
+ def _spatial_grad_adjoint(u: cpt.NDArray, axis: int) -> cpt.NDArray:
40
+ """transpose of `_spatial_grad`: the negative divergence of `u`"""
41
+ head = _axis_slice(u.ndim, axis, slice(None, -1))
42
+ tail = _axis_slice(u.ndim, axis, slice(1, None))
43
+ inner = u[head]
44
+ out = cp.zeros_like(u)
45
+ out[head] -= inner
46
+ out[tail] += inner
47
+ return out
48
+
49
+
50
+ # ------------------------------- regularization classes ------------------------------
51
+ class Regularization(abc.ABC):
52
+ """Forward pass in `__call__(x)`, backward pass in `grad(x, dy)`, re-tuned by `set`."""
53
+
54
+ @abc.abstractmethod
55
+ def __call__(self, x: cpt.NDArray) -> cpt.NDArray:
56
+ """Apply the map, or evaluate the penalty."""
57
+
58
+ @abc.abstractmethod
59
+ def grad(self, x: cpt.NDArray, dy: cpt.NDArray | float = 1.0) -> cpt.NDArray:
60
+ """Gradient with respect to `x`, given the gradient `dy` towards the output."""
61
+
62
+ def set(self, **params) -> Regularization:
63
+ """Update settings of the regularizer: useful in continuation schemes."""
64
+ for name, value in params.items():
65
+ if not hasattr(self, name):
66
+ raise AttributeError(f"{type(self).__name__} has no parameter {name!r}")
67
+ setattr(self, name, value)
68
+ return self
69
+
70
+
71
+ # ------------------------------ design-map modification ------------------------------
72
+ class DensityFilter(Regularization):
73
+ """Conic density filter on a structured grid of any dimension, with an OC variant.
74
+
75
+ Args:
76
+ rmin: filter radius in nodes, which sets the conic kernel's support.
77
+ shape: the design field's shape, which fixes the dimension of the kernel and
78
+ the normalization `Hs` it is built for.
79
+ dtype: kernel dtype, matched to the design field to keep the convolution
80
+ in one precision.
81
+ """
82
+
83
+ def __init__(
84
+ self, rmin: float, shape: tuple[int, ...], dtype: npt.DTypeLike | None = None
85
+ ) -> None:
86
+ ceil_r = int(math.ceil(rmin))
87
+ taps = cp.arange(-ceil_r, ceil_r + 1)
88
+ offsets = cp.meshgrid(*(taps,) * len(shape), indexing="ij")
89
+ self.kernel = cp.maximum(0.0, rmin - cp.sqrt(sum(k**2 for k in offsets)))
90
+ if dtype is not None:
91
+ self.kernel = self.kernel.astype(dtype)
92
+ self.Hs = ndi.convolve(
93
+ cp.ones(shape, self.kernel.dtype), self.kernel, mode="constant", cval=0.0
94
+ )
95
+
96
+ def __call__(self, x: cpt.NDArray) -> cpt.NDArray:
97
+ """Filtered design: convolve `x` with the conic kernel, normalized by `Hs`."""
98
+ return ndi.convolve(x, self.kernel, mode="constant", cval=0.0) / self.Hs
99
+
100
+ def grad(self, x: cpt.NDArray, dy: cpt.NDArray | float = 1.0) -> cpt.NDArray:
101
+ """Adjoint of the filter applied to `dy`; `x` is unused since the filter is linear."""
102
+ return ndi.convolve(dy / self.Hs, self.kernel, mode="constant", cval=0.0)
103
+
104
+ def sensitivity(self, rho: cpt.NDArray, dc: cpt.NDArray) -> cpt.NDArray:
105
+ """Filter sensitivities `dc` at density `rho`, the OC scheme in place of `grad`."""
106
+ num = ndi.convolve(rho * dc, self.kernel, mode="constant", cval=0.0)
107
+ return num / (cp.maximum(rho, 1e-3) * self.Hs)
108
+
109
+
110
+ class Projection(Regularization):
111
+ """Smoothed Heaviside about threshold `eta`, with sharpness `beta`."""
112
+
113
+ def __init__(self, beta: float, eta: float = 0.5) -> None:
114
+ self.beta, self.eta = beta, eta
115
+
116
+ def _ends(self) -> tuple[float, float]:
117
+ """tanh at the two ends of [0, 1], used to rescale `__call__` and `grad`."""
118
+ return math.tanh(self.beta * self.eta), math.tanh(self.beta * (1.0 - self.eta))
119
+
120
+ def __call__(self, x: cpt.NDArray) -> cpt.NDArray:
121
+ """Projected design: rescaled `tanh(beta * (x - eta))`, mapped onto [0, 1]."""
122
+ a, b = self._ends()
123
+ return (a + cp.tanh(self.beta * (x - self.eta))) / (a + b)
124
+
125
+ def grad(self, x: cpt.NDArray, dy: cpt.NDArray | float = 1.0) -> cpt.NDArray:
126
+ """Adjoint of the projection: `dy` scaled by the local tanh derivative."""
127
+ a, b = self._ends()
128
+ t = cp.tanh(self.beta * (x - self.eta))
129
+ return dy * self.beta * (1.0 - t * t) / (a + b)
130
+
131
+
132
+ class SIMP(Regularization):
133
+ """Power-law penalization making intermediate designs uneconomical (Bendsoe 1989).
134
+
135
+ See https://doi.org/10.1007/BF01650949
136
+ """
137
+
138
+ def __init__(self, p: float = 3.0, x_min: float = 0.0) -> None:
139
+ self.p, self.x_min = p, x_min
140
+
141
+ def __call__(self, x: cpt.NDArray) -> cpt.NDArray:
142
+ """Penalized design: `x_min + (1 - x_min) * x**p`."""
143
+ # x in [0, 1] is assumed, as a fractional p needs x >= 0
144
+ return self.x_min + (1.0 - self.x_min) * x**self.p
145
+
146
+ def grad(self, x: cpt.NDArray, dy: cpt.NDArray | float = 1.0) -> cpt.NDArray:
147
+ """Adjoint of the penalization: `dy` scaled by the power-law derivative."""
148
+ return dy * (1.0 - self.x_min) * self.p * x ** (self.p - 1.0)
149
+
150
+
151
+ # ----------------------------- penalization in objective -----------------------------
152
+ class Tikhonov(Regularization):
153
+ """L2 penalty, damping toward a reference or smoothing by penalizing the gradient.
154
+
155
+ Args:
156
+ alpha: penalty weight.
157
+ order: 0 penalizes deviation from `x_ref`, 1 penalizes the spatial gradient.
158
+ x_ref: the reference `order` 0 damps toward, or None for zero.
159
+ """
160
+
161
+ def __init__(
162
+ self, alpha: float, order: int = 0, x_ref: cpt.NDArray | None = None
163
+ ) -> None:
164
+ if order not in (0, 1):
165
+ raise ValueError("order must be 0 (damping) or 1 (smoothing)")
166
+ self.alpha, self.order, self.x_ref = alpha, order, x_ref
167
+
168
+ def _residual(self, x: cpt.NDArray) -> cpt.NDArray:
169
+ """`x` relative to `x_ref`, or `x` itself when no reference is set."""
170
+ return x if self.x_ref is None else x - self.x_ref
171
+
172
+ def __call__(self, x: cpt.NDArray) -> cpt.NDArray:
173
+ """Penalty value: `0.5 * alpha` times the squared residual, or its squared gradient."""
174
+ r = self._residual(x)
175
+ if self.order == 0:
176
+ return 0.5 * self.alpha * cp.sum(r * r)
177
+ squares = (cp.sum(_spatial_grad(r, axis) ** 2) for axis in range(r.ndim))
178
+ return 0.5 * self.alpha * sum(squares)
179
+
180
+ def grad(self, x: cpt.NDArray, dy: cpt.NDArray | float = 1.0) -> cpt.NDArray:
181
+ """Adjoint of the penalty: `dy * alpha` times the residual, or its Laplacian."""
182
+ r = self._residual(x)
183
+ if self.order == 0:
184
+ return (dy * self.alpha) * r
185
+ out = cp.zeros_like(r)
186
+ for axis in range(r.ndim):
187
+ out += _spatial_grad_adjoint(_spatial_grad(r, axis), axis)
188
+ return (dy * self.alpha) * out
189
+
190
+
191
+ class TotalVariation(Regularization):
192
+ """Smoothed isotropic total variation, `alpha * sum sqrt(|D x|**2 + eps**2)`"""
193
+
194
+ def __init__(self, alpha: float, eps: float = 1e-3) -> None:
195
+ self.alpha, self.eps = alpha, eps
196
+
197
+ def _magnitude(self, diffs: Sequence[cpt.NDArray]) -> cpt.NDArray:
198
+ """Smoothed gradient magnitude `sqrt(sum(d**2) + eps**2)` over the differences `diffs`."""
199
+ total = self.eps**2
200
+ for d in diffs:
201
+ total = total + d * d
202
+ return cp.sqrt(total)
203
+
204
+ def __call__(self, x: cpt.NDArray) -> cpt.NDArray:
205
+ """Penalty value: `alpha` times the summed smoothed gradient magnitude of `x`."""
206
+ diffs = [_spatial_grad(x, axis) for axis in range(x.ndim)]
207
+ return self.alpha * cp.sum(self._magnitude(diffs))
208
+
209
+ def grad(self, x: cpt.NDArray, dy: cpt.NDArray | float = 1.0) -> cpt.NDArray:
210
+ """Adjoint of the penalty: divergence of the normalized gradient, scaled by `dy * alpha`."""
211
+ diffs = [_spatial_grad(x, axis) for axis in range(x.ndim)]
212
+ magnitude = self._magnitude(diffs)
213
+ out = cp.zeros_like(x)
214
+ for axis, d in enumerate(diffs):
215
+ out += _spatial_grad_adjoint(d / magnitude, axis)
216
+ return (dy * self.alpha) * out
217
+
218
+
219
+ # ------------------------------ continuation schedules -------------------------------
220
+ def continuation(
221
+ scheme: str, iters: int, start: float, stop: float, stages: int = 4
222
+ ) -> list[float]:
223
+ """`iters` values ramping `start` -> `stop`, a `Projection` sharpness schedule
224
+
225
+ * `"constant"`: `start` throughout, the reference
226
+ * `"linear"`: equal increments, so most of the run is already sharp
227
+ * `"exponential"`: equal factors, spending the early iterations near `start`
228
+ * `"staircase"`: `stages` levels held for `iters // stages` iterations
229
+ each, the classic continuation (Wang, Lazarov & Sigmund 2011,
230
+ https://doi.org/10.1007/s00158-010-0602-y)
231
+ """
232
+ last = max(iters - 1, 1)
233
+ if scheme == "constant":
234
+ return [start] * iters
235
+ if scheme == "linear":
236
+ return [start + (stop - start) * i / last for i in range(iters)]
237
+ if scheme == "exponential":
238
+ return [start * (stop / start) ** (i / last) for i in range(iters)]
239
+ if scheme == "staircase": # the exponential ramp, held over `stages` levels
240
+ every, levels = max(iters // stages, 1), max(stages - 1, 1)
241
+ factors = (min(i // every, levels) / levels for i in range(iters))
242
+ return [start * (stop / start) ** f for f in factors]
243
+ raise ValueError(f"unknown continuation scheme {scheme!r}")
cuwave/scalar.py ADDED
@@ -0,0 +1,224 @@
1
+ """Scalar pressure wave equations, the nodal sibling of the vector elastic schemes.
2
+
3
+ One unknown per node and a per-axis flux, so the operator is the cheapest of the three
4
+ families and needs no component offsets. `PressureWave` holds the nodal `stiff`/`minv`
5
+ fields and the adjoint hooks; `ScalarWave` scales both with one indicator gamma, which
6
+ is why `minv` is never formed (`derive_inertia`), and `AcousticWave` interpolates
7
+ inverse density and inverse bulk modulus between two phases for topology optimization.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Callable
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ import cupy as cp
17
+ import cupy.typing as cpt
18
+ import numpy as np
19
+
20
+ from .boundary import Neumann
21
+ from .wave import (
22
+ Simulation,
23
+ apply_cell_weights,
24
+ grid_block,
25
+ mirror_ghosts,
26
+ sensor_cell_weights,
27
+ )
28
+
29
+ KERNEL_PATH = Path(__file__).parent / "kernels" / "scalar.cu"
30
+ SENSITIVITY_PATH = Path(__file__).parent / "kernels" / "scalar_sensitivity.cu"
31
+
32
+
33
+ # -------------------------------- discretization setup -------------------------------
34
+ @dataclass
35
+ class PressureWave(Simulation):
36
+ """Scalar wave equation base: nodal `stiff`/`minv` material fields, optional damping."""
37
+
38
+ kernel_path = KERNEL_PATH
39
+ sensitivity_path = SENSITIVITY_PATH
40
+ default_boundary = Neumann
41
+
42
+ derive_inertia = False # set where m == k (rho scaling): minv derived from stiff
43
+
44
+ def build_materials(self, indicator: cpt.NDArray) -> dict:
45
+ """Turn `indicator` into the kernel's material dict, `damping` included."""
46
+ stiff, minv = self.parametrization(indicator)
47
+ # mirrored in place, so a caller's ghost ring is normalised to Neumann
48
+ mat = {"stiff": mirror_ghosts(self, stiff)}
49
+ mat["minv"] = None if minv is None else mirror_ghosts(self, minv)
50
+ if self.damping is not None:
51
+ mat["damping"] = self.damping
52
+ return mat
53
+
54
+ def inverse_inertia(self, indicator: cpt.NDArray) -> cpt.NDArray:
55
+ """Nodal `1 / m` for `indicator`, derived from the stiffness where `m == k`."""
56
+ stiff, minv = self.parametrization(indicator)
57
+ return 1.0 / stiff if minv is None else minv
58
+
59
+ def step_kernel_args(self, mat: dict) -> tuple:
60
+ """Material and damping arguments for the finite-difference step kernel."""
61
+ minv = mat["stiff"] if self.derive_inertia else mat["minv"]
62
+ args = (mat["stiff"], minv, np.int32(self.derive_inertia))
63
+ if self.damping is not None:
64
+ args += (mat["damping"], self.dtype(self.dt))
65
+ return args
66
+
67
+ gradient_names = ("mass", "stiff") # the fields the adjoint differentiates
68
+
69
+ def gradient_fields(self, mat: dict) -> dict[str, cpt.NDArray]:
70
+ """Zeroed accumulators the adjoint kernels add into, one per material field."""
71
+ return {
72
+ name: cp.zeros(self.Nx_padded, dtype=self.dtype)
73
+ for name in self.gradient_names
74
+ }
75
+
76
+ def adjoint_weights(self, sensors: cpt.NDArray[cp.int32]) -> cpt.NDArray:
77
+ """Divisor the adjoint source carries: the cell weights W over the source factor."""
78
+ return sensor_cell_weights(self, sensors) * self.source_factor()
79
+
80
+ def finalize_gradients(self, grads: dict, kernels: cp.RawModule) -> dict:
81
+ """Weight the accumulators by W once the time loop is done."""
82
+ for field in grads.values():
83
+ apply_cell_weights(self, field)
84
+ return grads
85
+
86
+ def define_gradient(
87
+ self, kernels: cp.RawModule, mat: dict, grads: dict
88
+ ) -> Callable:
89
+ """Closure accumulating both gradient densities from a forward triplet and `l1`."""
90
+ # one kernel for both gradients: a launch costs more host time than either body
91
+ gradient_kernel = kernels.get_function("gradient_kernel")
92
+ grid, block = grid_block(self)
93
+ # the operator without the dt^2 the step folds into it: L, not dt^2 L
94
+ factors = [self.dtype(float(f) / self.dt**2) for f in self.step_factors()]
95
+ geom = [factors[0], self.Nx[0]]
96
+ for d in range(1, self.ndim):
97
+ geom += [factors[d], self.Nx[d], self.strides[d - 1]]
98
+ args = [grads["mass"], grads["stiff"], None, None, None, None] + [
99
+ mat["stiff"],
100
+ self.dtype(1.0 / self.dt**2),
101
+ *geom,
102
+ ]
103
+
104
+ def gradient_step(u0, u1, u2, l1):
105
+ args[2], args[3], args[4], args[5] = u0, u1, u2, l1
106
+ gradient_kernel(grid, block, args)
107
+
108
+ return gradient_step
109
+
110
+ def define_frechet(
111
+ self, kernels: cp.RawModule, accs: dict, sign: float
112
+ ) -> Callable:
113
+ """Closure accumulating both Frechet densities of one field triplet, times `sign`."""
114
+ # sign is fixed per pass, so it is folded into the factors, not recomputed
115
+ frechet_kernel = kernels.get_function("frechet_kernel")
116
+ grid, block = grid_block(self)
117
+ # the stiffness density enters negated, so the epilogue scales both alike
118
+ geom = [self.dtype(sign / (2.0 * self.dt) ** 2)]
119
+ for d in range(self.ndim):
120
+ geom.append(self.dtype(-sign / (2.0 * self.dx[d]) ** 2))
121
+ geom.append(self.Nx[d])
122
+ if d:
123
+ geom.append(self.strides[d - 1])
124
+ args = [accs["mass"], accs["stiff"], None, None, None] + geom
125
+
126
+ def frechet_step(u0, u1, u2):
127
+ args[2], args[3], args[4] = u0, u1, u2
128
+ frechet_kernel(grid, block, args)
129
+
130
+ return frechet_step
131
+
132
+ def excitation_weights(
133
+ self, mat: dict, lin_index: cpt.NDArray[cp.int32]
134
+ ) -> cpt.NDArray:
135
+ """Source weights `dt**2 / inertia` at `lin_index`, never forming inertia on the grid."""
136
+ field = mat["stiff"] if self.derive_inertia else mat["minv"]
137
+ weight = field.ravel()[lin_index]
138
+ if self.derive_inertia:
139
+ weight = 1.0 / weight
140
+ if self.damping is not None:
141
+ # the damped update divides by 1 + beta, so the source has to share it
142
+ beta = 0.5 * weight * mat["damping"].ravel()[lin_index] * self.dt
143
+ weight = weight / (1.0 + beta)
144
+ return (self.dtype(self.dt**2 * self.source_factor()) * weight).astype(
145
+ self.dtype
146
+ )
147
+
148
+
149
+ @dataclass
150
+ class ScalarWave(PressureWave):
151
+ """Constant-speed scalar wave equation, parametrized by a density-scaling indicator gamma."""
152
+
153
+ wavespeed: float = None # background wave speed c0
154
+ density: float = None # background density rho0
155
+
156
+ derive_inertia = True # gamma scales inertia and stiffness alike
157
+
158
+ def __post_init__(self) -> None:
159
+ """Validate `wavespeed` and `density` are set, on top of `Simulation.__post_init__`."""
160
+ super().__post_init__()
161
+ if self.wavespeed is None or self.density is None:
162
+ raise ValueError("ScalarWave requires wavespeed and density")
163
+
164
+ def parametrization(
165
+ self, indicator: cpt.NDArray
166
+ ) -> tuple[cpt.NDArray, cpt.NDArray | None]:
167
+ """`indicator` is the density-scaling field gamma; `minv` is left to be derived from it."""
168
+ return indicator, None
169
+
170
+ def parametrization_jacobian(self, indicator: cpt.NDArray) -> tuple:
171
+ """Both mass and stiffness coefficients are gamma itself, so both derivatives are 1."""
172
+ return 1.0, 1.0
173
+
174
+ def step_factors(self) -> list:
175
+ """Per-axis finite-difference step factors `2 * c0**2 * dt**2 / dx**2`."""
176
+ return [
177
+ self.dtype(2.0 * self.wavespeed**2 * self.dt**2 / dxk**2) for dxk in self.dx
178
+ ]
179
+
180
+ def source_factor(self) -> float:
181
+ """Source scaling `1 / rho0`."""
182
+ return 1.0 / self.density
183
+
184
+
185
+ @dataclass
186
+ class AcousticWave(PressureWave):
187
+ """Two-phase acoustic wave equation (TATO), gamma interpolating between air and solid."""
188
+
189
+ # TATO material constants (gamma = 0 -> air, gamma = 1 -> solid)
190
+ rho1: float = None
191
+ rho2: float = None
192
+ kappa1: float = None
193
+ kappa2: float = None
194
+
195
+ def __post_init__(self) -> None:
196
+ """Validate the four material constants are set, on top of `Simulation.__post_init__`."""
197
+ super().__post_init__()
198
+ if None in (self.rho1, self.rho2, self.kappa1, self.kappa2):
199
+ raise ValueError("AcousticWave requires rho1, rho2, kappa1, kappa2")
200
+
201
+ def parametrization(
202
+ self, indicator: cpt.NDArray
203
+ ) -> tuple[cpt.NDArray, cpt.NDArray]:
204
+ """`indicator` interpolates inverse density and inverse bulk modulus between the phases."""
205
+ # the kernel wants 1 / rho, so rho is never formed
206
+ rho_inv = 1 / self.rho1 + indicator * (1 / self.rho2 - 1 / self.rho1)
207
+ kappa_inv = 1 / self.kappa1 + indicator * (1 / self.kappa2 - 1 / self.kappa1)
208
+ return rho_inv, 1 / kappa_inv
209
+
210
+ def parametrization_jacobian(self, indicator: cpt.NDArray) -> tuple:
211
+ """Derivatives of (mass, stiff) with respect to gamma; both coefficients are affine in it."""
212
+ # affine in gamma as (mass, stiff) = (1 / kappa, 1 / rho), hence constant
213
+ return (
214
+ 1 / self.kappa2 - 1 / self.kappa1,
215
+ 1 / self.rho2 - 1 / self.rho1,
216
+ )
217
+
218
+ def step_factors(self) -> list:
219
+ """Per-axis finite-difference step factors `2 * dt**2 / dx**2`."""
220
+ return [self.dtype(2.0 * self.dt**2 / dxk**2) for dxk in self.dx]
221
+
222
+ def source_factor(self) -> float:
223
+ """Source scaling, unscaled since rho is already folded into `parametrization`."""
224
+ return 1.0