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/postprocessing.py
ADDED
|
@@ -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
|
+
)
|
cuwave/regularization.py
ADDED
|
@@ -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
|