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/elastic.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Isotropic elasticity on a staggered grid, a sibling of `PressureWave` on the same march.
|
|
2
|
+
|
|
3
|
+
Component `c` lives half a node up its own axis, so every strain lands on a natural
|
|
4
|
+
point: the normal strains on the nodes, the shear `(k, l)` half a node up both of its
|
|
5
|
+
axes. Each is a pure per-axis staggered difference, which is what carries the cross
|
|
6
|
+
terms a per-axis flux cannot and keeps the cost linear in the stencil radius, where the
|
|
7
|
+
cell gather of `AnisotropicElasticWave` pays `(2r)**(2 ndim)`. The operator is
|
|
8
|
+
`-B^T C B` with the material sampled on the stress points, so it stays the exact
|
|
9
|
+
transpose at every order.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import cupy as cp
|
|
19
|
+
import cupy.typing as cpt
|
|
20
|
+
import numpy as np
|
|
21
|
+
import numpy.typing as npt
|
|
22
|
+
|
|
23
|
+
from .boundary import Clamped, Traction, face_mask, faces_with
|
|
24
|
+
from .wave import (
|
|
25
|
+
PAIRS,
|
|
26
|
+
Simulation,
|
|
27
|
+
apply_cell_weights,
|
|
28
|
+
axis_geometry,
|
|
29
|
+
component_weights,
|
|
30
|
+
grid_block,
|
|
31
|
+
pair_average,
|
|
32
|
+
pair_average_adjoint,
|
|
33
|
+
pair_weights,
|
|
34
|
+
point_average,
|
|
35
|
+
point_average_adjoint,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
KERNEL_PATH = Path(__file__).parent / "kernels" / "elastic.cu"
|
|
39
|
+
SENSITIVITY_PATH = Path(__file__).parent / "kernels" / "elastic_sensitivity.cu"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# -------------------------------------- helpers --------------------------------------
|
|
43
|
+
def voigt(ndim: int, lame: float, shear: float, plane: str = "strain") -> npt.NDArray:
|
|
44
|
+
"""Isotropic Voigt stiffness matrix, `plane` selecting strain or stress in 2D."""
|
|
45
|
+
if ndim == 1:
|
|
46
|
+
return np.array([[lame + 2.0 * shear]])
|
|
47
|
+
if ndim == 2:
|
|
48
|
+
lam = lame if plane == "strain" else 2.0 * lame * shear / (lame + 2.0 * shear)
|
|
49
|
+
return np.array(
|
|
50
|
+
[
|
|
51
|
+
[lam + 2.0 * shear, lam, 0.0],
|
|
52
|
+
[lam, lam + 2.0 * shear, 0.0],
|
|
53
|
+
[0.0, 0.0, shear],
|
|
54
|
+
]
|
|
55
|
+
)
|
|
56
|
+
C = np.full((6, 6), 0.0)
|
|
57
|
+
C[:3, :3] = lame
|
|
58
|
+
for i in range(3):
|
|
59
|
+
C[i, i] = lame + 2.0 * shear
|
|
60
|
+
C[3 + i, 3 + i] = shear
|
|
61
|
+
return C
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ------------------------------- discretization setup --------------------------------
|
|
65
|
+
@dataclass
|
|
66
|
+
class ElasticWave(Simulation):
|
|
67
|
+
"""Staggered isotropic elasticity, parametrized by a density-scaling indicator gamma.
|
|
68
|
+
|
|
69
|
+
Both wave speeds are held fixed and gamma scales the density, so
|
|
70
|
+
`C = gamma * rho0 * C` scales inertia and stiffness alike and the stable timestep
|
|
71
|
+
does not move with the design. This is the parametrization ultrasonic full waveform
|
|
72
|
+
inversion reaches for, since a void is a density contrast at unchanged speeds.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
density: float = None # background density rho0
|
|
76
|
+
wavespeed_p: float = None # pressure wave speed
|
|
77
|
+
wavespeed_s: float = None # shear wave speed
|
|
78
|
+
plane: str = "strain" # "strain" or "stress", 2D only
|
|
79
|
+
|
|
80
|
+
kernel_path = KERNEL_PATH
|
|
81
|
+
sensitivity_path = SENSITIVITY_PATH
|
|
82
|
+
default_boundary = Traction
|
|
83
|
+
gradient_names = ("mass", "stiff")
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def ncomp(self) -> int:
|
|
87
|
+
"""One displacement component per axis."""
|
|
88
|
+
return self.ndim
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def component_offsets(self) -> npt.NDArray[np.float64]:
|
|
92
|
+
"""Component `c` sits half a node up axis `c`: the staggering itself."""
|
|
93
|
+
return 0.5 * np.eye(self.ndim)
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def nvoigt(self) -> int:
|
|
97
|
+
"""Stress components: `ndim` normal ones plus one per axis pair."""
|
|
98
|
+
return len(PAIRS[self.ndim])
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def npairs(self) -> int:
|
|
102
|
+
"""Shear components, one per axis pair."""
|
|
103
|
+
return self.nvoigt - self.ndim
|
|
104
|
+
|
|
105
|
+
def __post_init__(self) -> None:
|
|
106
|
+
"""Validate the material, the 2D plane assumption and the faces, then derive the grid."""
|
|
107
|
+
super().__post_init__()
|
|
108
|
+
if None in (self.density, self.wavespeed_p, self.wavespeed_s):
|
|
109
|
+
raise ValueError("ElasticWave requires density, wavespeed_p, wavespeed_s")
|
|
110
|
+
if self.plane not in ("strain", "stress"):
|
|
111
|
+
raise ValueError(f"plane must be strain or stress: {self.plane}")
|
|
112
|
+
if self.ndim == 3 and self.plane != "strain":
|
|
113
|
+
raise ValueError("plane stress is a 2D reduction, not a 3D one")
|
|
114
|
+
if self.wavespeed_s >= self.wavespeed_p:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
f"wavespeed_s must be below wavespeed_p: "
|
|
117
|
+
f"{self.wavespeed_s} >= {self.wavespeed_p}"
|
|
118
|
+
)
|
|
119
|
+
for pair in self.boundary:
|
|
120
|
+
for condition in pair:
|
|
121
|
+
if condition not in (Traction, Clamped):
|
|
122
|
+
raise ValueError(f"elastic faces are Traction or Clamped: {pair}")
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def radius(self) -> int:
|
|
126
|
+
"""Half the stencil width, so `space_order` is twice it."""
|
|
127
|
+
return self.space_order // 2
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def reach(self) -> int:
|
|
131
|
+
"""The strain reach compounds with the divergence one: `2 radius - 1` nodes."""
|
|
132
|
+
return 2 * self.radius - 1
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def lame(self) -> float:
|
|
136
|
+
"""First Lame parameter `rho0 * (c_p**2 - 2 c_s**2)`, reduced under plane stress."""
|
|
137
|
+
lam = self.density * (self.wavespeed_p**2 - 2.0 * self.wavespeed_s**2)
|
|
138
|
+
if self.plane == "stress":
|
|
139
|
+
return 2.0 * lam * self.shear / (lam + 2.0 * self.shear)
|
|
140
|
+
return lam
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def shear(self) -> float:
|
|
144
|
+
"""Second Lame parameter `rho0 * c_s**2`."""
|
|
145
|
+
return self.density * self.wavespeed_s**2
|
|
146
|
+
|
|
147
|
+
def inverse_inertia(self, indicator: cpt.NDArray) -> cpt.NDArray:
|
|
148
|
+
"""Nodal `1 / (gamma rho0 W)`, what a sponge scales its damping by."""
|
|
149
|
+
mass = (
|
|
150
|
+
indicator
|
|
151
|
+
* self.density
|
|
152
|
+
* apply_cell_weights(self, cp.ones(self.Nx_padded, dtype=self.dtype))
|
|
153
|
+
)
|
|
154
|
+
return 1.0 / cp.maximum(mass, cp.finfo(self.dtype).tiny)
|
|
155
|
+
|
|
156
|
+
def build_materials(self, indicator: cpt.NDArray) -> dict:
|
|
157
|
+
"""Point inverse inertia per component and the design field on the stress points."""
|
|
158
|
+
gamma = cp.ascontiguousarray(indicator, dtype=self.dtype)
|
|
159
|
+
minv = cp.zeros((self.ncomp, *self.Nx_padded), dtype=self.dtype)
|
|
160
|
+
for c in range(self.ncomp):
|
|
161
|
+
mass = (
|
|
162
|
+
self.density
|
|
163
|
+
* component_weights(self, c)
|
|
164
|
+
* point_average(self, gamma, c)
|
|
165
|
+
)
|
|
166
|
+
minv[c] = 1.0 / cp.maximum(mass, cp.finfo(self.dtype).tiny)
|
|
167
|
+
for face in faces_with(self, Clamped):
|
|
168
|
+
wall = [slice(None)] * self.ndim
|
|
169
|
+
wall[face // 2] = 1 if face % 2 == 0 else self.Nx[face // 2] - 2
|
|
170
|
+
for c in range(self.ncomp):
|
|
171
|
+
if c != face // 2:
|
|
172
|
+
minv[(c, *wall)] = 0.0
|
|
173
|
+
mat = {
|
|
174
|
+
"minv": cp.ascontiguousarray(minv),
|
|
175
|
+
"gnode": apply_cell_weights(self, gamma.copy()),
|
|
176
|
+
"gamma": gamma,
|
|
177
|
+
}
|
|
178
|
+
if self.ndim > 1:
|
|
179
|
+
gshear = cp.zeros((self.npairs, *self.Nx_padded), dtype=self.dtype)
|
|
180
|
+
for p, axes in enumerate(PAIRS[self.ndim][self.ndim :]):
|
|
181
|
+
gshear[p] = pair_weights(self, axes) * pair_average(self, gamma, axes)
|
|
182
|
+
mat["gshear"] = cp.ascontiguousarray(gshear)
|
|
183
|
+
if self.damping is not None:
|
|
184
|
+
mat["damping"] = self.damping
|
|
185
|
+
return mat
|
|
186
|
+
|
|
187
|
+
def define_step(self, kernels: cp.RawModule, mat: dict) -> Callable:
|
|
188
|
+
"""Closure launching the stress kernel and then the update over (u0, u1, u2)."""
|
|
189
|
+
stress_kernel = kernels.get_function("stress_kernel")
|
|
190
|
+
fd_kernel = kernels.get_function("fd_kernel")
|
|
191
|
+
grid, block = grid_block(self)
|
|
192
|
+
# the stress scratch outlives the closure, its ghost region never written
|
|
193
|
+
sigma = cp.zeros((self.nvoigt, *self.Nx_padded), dtype=self.dtype)
|
|
194
|
+
clamped = face_mask(self, Clamped)
|
|
195
|
+
material = [self.dtype(self.lame), self.dtype(self.shear)]
|
|
196
|
+
inv_dx = [self.dtype(1.0 / d) for d in self.dx]
|
|
197
|
+
sargs = [None, sigma, mat["gnode"]]
|
|
198
|
+
if self.ndim > 1:
|
|
199
|
+
sargs.append(mat["gshear"])
|
|
200
|
+
sargs += [
|
|
201
|
+
*material,
|
|
202
|
+
clamped,
|
|
203
|
+
np.int32(self.comp_stride),
|
|
204
|
+
*axis_geometry(self, inv_dx),
|
|
205
|
+
]
|
|
206
|
+
uargs = [None, None, None, sigma, mat["minv"]]
|
|
207
|
+
if self.damping is not None:
|
|
208
|
+
uargs += [mat["damping"], self.dtype(self.dt)]
|
|
209
|
+
uargs += [
|
|
210
|
+
clamped,
|
|
211
|
+
np.int32(self.comp_stride),
|
|
212
|
+
*axis_geometry(self, self.step_factors()),
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
def fd_step(u0, u1, u2):
|
|
216
|
+
sargs[0] = u1
|
|
217
|
+
stress_kernel(grid, block, sargs)
|
|
218
|
+
uargs[0], uargs[1], uargs[2] = u0, u1, u2
|
|
219
|
+
fd_kernel(grid, block, uargs)
|
|
220
|
+
return u2
|
|
221
|
+
|
|
222
|
+
return fd_step
|
|
223
|
+
|
|
224
|
+
def excitation_weights(
|
|
225
|
+
self, mat: dict, lin_index: cpt.NDArray[cp.int32]
|
|
226
|
+
) -> cpt.NDArray:
|
|
227
|
+
"""Source weights `dt**2 / (inertia V)`: the kernel inertia leaves the volume out."""
|
|
228
|
+
volume = self.dtype(1.0 / float(np.prod(self.dx)))
|
|
229
|
+
weight = mat["minv"].ravel()[lin_index] * volume
|
|
230
|
+
if self.damping is not None:
|
|
231
|
+
node = lin_index % np.int32(self.comp_stride)
|
|
232
|
+
beta = 0.5 * weight * mat["damping"].ravel()[node] * self.dt
|
|
233
|
+
weight = weight / (1.0 + beta)
|
|
234
|
+
return (self.dtype(self.dt**2 * self.source_factor()) * weight).astype(
|
|
235
|
+
self.dtype
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def parametrization_jacobian(self, indicator: cpt.NDArray) -> tuple:
|
|
239
|
+
"""Gamma scales inertia and stiffness alike, so both derivatives are 1."""
|
|
240
|
+
return 1.0, 1.0
|
|
241
|
+
|
|
242
|
+
def step_factors(self) -> list:
|
|
243
|
+
"""Per-axis update factors `dt**2 / dx`, the strain carrying the other `1 / dx`."""
|
|
244
|
+
return [self.dtype(self.dt**2 / d) for d in self.dx]
|
|
245
|
+
|
|
246
|
+
def source_factor(self) -> float:
|
|
247
|
+
"""Source scaling, unscaled since rho0 is already folded into the point inertia."""
|
|
248
|
+
return 1.0
|
|
249
|
+
|
|
250
|
+
def adjoint_weights(self, sensors: cpt.NDArray[cp.int32]) -> cpt.NDArray:
|
|
251
|
+
"""`1 / V`: dJ/du is nodal, so it undoes the volume the source weights divide by."""
|
|
252
|
+
volume = self.dtype(1.0 / float(np.prod(self.dx)))
|
|
253
|
+
return cp.full(sensors.shape[1], volume, dtype=self.dtype)
|
|
254
|
+
|
|
255
|
+
def gradient_fields(self, mat: dict) -> dict[str, cpt.NDArray]:
|
|
256
|
+
"""Accumulators on the component and stress points, plus the design they chain to."""
|
|
257
|
+
grads = {
|
|
258
|
+
"mass": cp.zeros((self.ncomp, *self.Nx_padded), dtype=self.dtype),
|
|
259
|
+
"normal": cp.zeros(self.Nx_padded, dtype=self.dtype),
|
|
260
|
+
}
|
|
261
|
+
if self.ndim > 1:
|
|
262
|
+
grads["shear"] = cp.zeros((self.npairs, *self.Nx_padded), dtype=self.dtype)
|
|
263
|
+
# the point averages have a design dependent chain rule, so keep the field
|
|
264
|
+
grads["design"] = mat["gamma"]
|
|
265
|
+
return grads
|
|
266
|
+
|
|
267
|
+
def finalize_gradients(self, grads: dict, kernels: cp.RawModule) -> dict:
|
|
268
|
+
"""Chain the point densities through the averages onto the nodal design field."""
|
|
269
|
+
gamma = grads["design"]
|
|
270
|
+
g_mass = cp.zeros(self.Nx_padded, dtype=self.dtype)
|
|
271
|
+
for c in range(self.ncomp):
|
|
272
|
+
density = grads["mass"][c] * component_weights(self, c) * self.density
|
|
273
|
+
g_mass += point_average_adjoint(self, density, c)
|
|
274
|
+
# the normal density sits on the nodes, so its chain rule is the weight alone
|
|
275
|
+
g_stiff = apply_cell_weights(self, grads["normal"].copy())
|
|
276
|
+
for p, axes in enumerate(PAIRS[self.ndim][self.ndim :]):
|
|
277
|
+
density = grads["shear"][p] * pair_weights(self, axes)
|
|
278
|
+
g_stiff += pair_average_adjoint(self, density, gamma, axes)
|
|
279
|
+
return {"mass": g_mass, "stiff": g_stiff}
|
|
280
|
+
|
|
281
|
+
def _density_args(self, grads: dict) -> list:
|
|
282
|
+
"""The accumulator and material head shared by the gradient and Frechet closures."""
|
|
283
|
+
args = [grads["mass"], grads["normal"]]
|
|
284
|
+
if self.ndim > 1:
|
|
285
|
+
args.append(grads["shear"])
|
|
286
|
+
return args
|
|
287
|
+
|
|
288
|
+
def define_gradient(
|
|
289
|
+
self, kernels: cp.RawModule, mat: dict, grads: dict
|
|
290
|
+
) -> Callable:
|
|
291
|
+
"""Closure accumulating the point mass and stress-point stiffness densities."""
|
|
292
|
+
gradient_kernel = kernels.get_function("gradient_kernel")
|
|
293
|
+
grid, block = grid_block(self)
|
|
294
|
+
inv_dx = [self.dtype(1.0 / d) for d in self.dx]
|
|
295
|
+
args = (
|
|
296
|
+
self._density_args(grads)
|
|
297
|
+
+ [None, None, None, None]
|
|
298
|
+
+ [
|
|
299
|
+
self.dtype(self.lame),
|
|
300
|
+
self.dtype(self.shear),
|
|
301
|
+
self.dtype(1.0 / self.dt**2),
|
|
302
|
+
face_mask(self, Clamped),
|
|
303
|
+
np.int32(self.comp_stride),
|
|
304
|
+
*axis_geometry(self, inv_dx),
|
|
305
|
+
]
|
|
306
|
+
)
|
|
307
|
+
head = len(self._density_args(grads))
|
|
308
|
+
|
|
309
|
+
def gradient_step(u0, u1, u2, l1):
|
|
310
|
+
args[head], args[head + 1] = u0, u1
|
|
311
|
+
args[head + 2], args[head + 3] = u2, l1
|
|
312
|
+
gradient_kernel(grid, block, args)
|
|
313
|
+
|
|
314
|
+
return gradient_step
|
|
315
|
+
|
|
316
|
+
def define_frechet(
|
|
317
|
+
self, kernels: cp.RawModule, accs: dict, sign: float
|
|
318
|
+
) -> Callable:
|
|
319
|
+
"""Closure accumulating both quadratic densities of one field triplet, times `sign`."""
|
|
320
|
+
frechet_kernel = kernels.get_function("frechet_kernel")
|
|
321
|
+
grid, block = grid_block(self)
|
|
322
|
+
inv_dx = [self.dtype(1.0 / d) for d in self.dx]
|
|
323
|
+
args = (
|
|
324
|
+
self._density_args(accs)
|
|
325
|
+
+ [None, None, None]
|
|
326
|
+
+ [
|
|
327
|
+
self.dtype(self.lame),
|
|
328
|
+
self.dtype(self.shear),
|
|
329
|
+
self.dtype(sign / (2.0 * self.dt) ** 2),
|
|
330
|
+
self.dtype(-sign),
|
|
331
|
+
face_mask(self, Clamped),
|
|
332
|
+
np.int32(self.comp_stride),
|
|
333
|
+
*axis_geometry(self, inv_dx),
|
|
334
|
+
]
|
|
335
|
+
)
|
|
336
|
+
head = len(self._density_args(accs))
|
|
337
|
+
|
|
338
|
+
def frechet_step(u0, u1, u2):
|
|
339
|
+
args[head], args[head + 1], args[head + 2] = u0, u1, u2
|
|
340
|
+
frechet_kernel(grid, block, args)
|
|
341
|
+
|
|
342
|
+
return frechet_step
|
cuwave/evals.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import cupy as cp
|
|
2
|
+
import cupy.typing as cpt
|
|
3
|
+
|
|
4
|
+
NAN = float("nan")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# -------------------------------------- helpers --------------------------------------
|
|
8
|
+
def _flatten_pair(
|
|
9
|
+
field: cpt.NDArray, truth: cpt.NDArray
|
|
10
|
+
) -> tuple[cpt.NDArray, cpt.NDArray]:
|
|
11
|
+
"""Ravel field and truth to 1D, checking shapes match."""
|
|
12
|
+
field, truth = cp.asarray(field).ravel(), cp.asarray(truth).ravel()
|
|
13
|
+
if field.shape != truth.shape:
|
|
14
|
+
raise ValueError(f"shape mismatch: {field.shape} against {truth.shape}")
|
|
15
|
+
return field, truth
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _resolve_threshold(truth: cpt.NDArray, threshold: float | None = None) -> float:
|
|
19
|
+
"""Return threshold, defaulting to the midpoint of truth's range.
|
|
20
|
+
|
|
21
|
+
The truth is always split at that midpoint, which is the natural cut for a
|
|
22
|
+
two-material indicator, so no separate truth threshold is ever passed in.
|
|
23
|
+
"""
|
|
24
|
+
return 0.5 * float(truth.min() + truth.max()) if threshold is None else threshold
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ranked(
|
|
28
|
+
field: cpt.NDArray, truth: cpt.NDArray
|
|
29
|
+
) -> tuple[cpt.NDArray, cpt.NDArray, int]:
|
|
30
|
+
"""Field and labels ordered by descending damage score. Also returns the positive count."""
|
|
31
|
+
field, truth = _flatten_pair(field, truth)
|
|
32
|
+
label = truth < _resolve_threshold(truth)
|
|
33
|
+
order = cp.argsort(field) # descending damage score (ascending indicator)
|
|
34
|
+
return field[order], label[order], int(label.sum())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ------------------------------ binary indicator fields ------------------------------
|
|
38
|
+
def confusion(
|
|
39
|
+
field: cpt.NDArray, truth: cpt.NDArray, threshold: float | None = None
|
|
40
|
+
) -> tuple[int, int, int, int]:
|
|
41
|
+
"""(tp, fp, fn, tn) counts, `field` split at `threshold` and `truth` at its midpoint."""
|
|
42
|
+
field, truth = _flatten_pair(field, truth)
|
|
43
|
+
label = truth < _resolve_threshold(truth)
|
|
44
|
+
predicted = field < _resolve_threshold(truth, threshold)
|
|
45
|
+
tp = int(cp.count_nonzero(predicted & label))
|
|
46
|
+
fp = int(cp.count_nonzero(predicted)) - tp
|
|
47
|
+
fn = int(cp.count_nonzero(label)) - tp
|
|
48
|
+
return tp, fp, fn, predicted.size - tp - fp - fn
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def precision(
|
|
52
|
+
field: cpt.NDArray, truth: cpt.NDArray, threshold: float | None = None
|
|
53
|
+
) -> float:
|
|
54
|
+
"""Share of the detected damage that is true, `tp / (tp + fp)`"""
|
|
55
|
+
tp, fp, _, _ = confusion(field, truth, threshold)
|
|
56
|
+
return tp / (tp + fp) if tp + fp else NAN
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def recall(
|
|
60
|
+
field: cpt.NDArray, truth: cpt.NDArray, threshold: float | None = None
|
|
61
|
+
) -> float:
|
|
62
|
+
"""Share of the true damage that is detected, `tp / (tp + fn)` (true positive rate)"""
|
|
63
|
+
tp, _, fn, _ = confusion(field, truth, threshold)
|
|
64
|
+
return tp / (tp + fn) if tp + fn else NAN
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def false_positive_rate(
|
|
68
|
+
field: cpt.NDArray, truth: cpt.NDArray, threshold: float | None = None
|
|
69
|
+
) -> float:
|
|
70
|
+
"""Share of the intact material flagged as damage, `fp / (fp + tn)`"""
|
|
71
|
+
_, fp, _, tn = confusion(field, truth, threshold)
|
|
72
|
+
return fp / (fp + tn) if fp + tn else NAN
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def f1_score(
|
|
76
|
+
field: cpt.NDArray, truth: cpt.NDArray, threshold: float | None = None
|
|
77
|
+
) -> float:
|
|
78
|
+
"""Harmonic mean of `precision` and `recall`, `2 tp / (2 tp + fp + fn)`"""
|
|
79
|
+
tp, fp, fn, _ = confusion(field, truth, threshold)
|
|
80
|
+
return 2 * tp / (2 * tp + fp + fn) if tp else 0.0 if fp or fn else NAN
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pr_auc(field: cpt.NDArray, truth: cpt.NDArray) -> float:
|
|
84
|
+
"""Area under the precision-recall curve, as the average precision."""
|
|
85
|
+
score, label, positives = _ranked(field, truth)
|
|
86
|
+
if positives == 0:
|
|
87
|
+
return NAN
|
|
88
|
+
tp = cp.cumsum(label, dtype=cp.float64)
|
|
89
|
+
counted = cp.arange(1, label.size + 1, dtype=cp.float64)
|
|
90
|
+
# only the last entry of a run of equal scores is a threshold of its own
|
|
91
|
+
ends = cp.append(cp.diff(score) != 0, True)
|
|
92
|
+
tp, counted = tp[ends], counted[ends]
|
|
93
|
+
recalled = tp / positives
|
|
94
|
+
previous = cp.concatenate((cp.zeros(1, dtype=cp.float64), recalled[:-1]))
|
|
95
|
+
return float(cp.sum((recalled - previous) * (tp / counted)))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def roc_auc(field: cpt.NDArray, truth: cpt.NDArray) -> float:
|
|
99
|
+
"""Area under the receiver operating characteristic, `recall` against `false_positive_rate`."""
|
|
100
|
+
field, truth = _flatten_pair(field, truth)
|
|
101
|
+
label = truth < _resolve_threshold(truth)
|
|
102
|
+
positives = int(cp.count_nonzero(label))
|
|
103
|
+
negatives = label.size - positives
|
|
104
|
+
if positives == 0 or negatives == 0:
|
|
105
|
+
return NAN
|
|
106
|
+
ordered = cp.sort(-field)
|
|
107
|
+
damaged = -field[label]
|
|
108
|
+
left = cp.searchsorted(ordered, damaged, side="left")
|
|
109
|
+
right = cp.searchsorted(ordered, damaged, side="right")
|
|
110
|
+
ranks = 0.5 * float(cp.sum(left + right + 1)) # 1-based mid-ranks
|
|
111
|
+
return (ranks - 0.5 * positives * (positives + 1)) / (positives * negatives)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def non_discreteness(field: cpt.NDArray, region: cpt.NDArray | None = None) -> float:
|
|
115
|
+
"""Greyness measure `mean(4 x (1 - x))` (Sigmund 2007), 0 for a design already 0/1.
|
|
116
|
+
|
|
117
|
+
Takes no truth, so it also scores a topology optimization result, where the number
|
|
118
|
+
that matters is how far the grey design the optimizer saw is from the thresholded
|
|
119
|
+
one that gets built. See https://doi.org/10.1007/s00158-006-0087-x
|
|
120
|
+
"""
|
|
121
|
+
x = cp.asarray(field)
|
|
122
|
+
x = x if region is None else x[region]
|
|
123
|
+
return float(cp.mean(4.0 * x * (1.0 - x))) if x.size else NAN
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def l2_error(field: cpt.NDArray, truth: cpt.NDArray, relative: bool = True) -> float:
|
|
127
|
+
"""L2 norm of the reconstruction error, normalized by the norm of `truth`."""
|
|
128
|
+
field, truth = _flatten_pair(field, truth)
|
|
129
|
+
error = float(cp.linalg.norm(field - truth))
|
|
130
|
+
return error / float(cp.linalg.norm(truth)) if relative else error
|
cuwave/geometry.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Boolean region masks on the padded simulation grid
|
|
2
|
+
|
|
3
|
+
Every helper takes the `coords` returned by `grid_coords` and an optional `out` mask to
|
|
4
|
+
accumulate into: it is created when omitted
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
|
|
10
|
+
import cupy as cp
|
|
11
|
+
import cupy.typing as cpt
|
|
12
|
+
import numpy as np
|
|
13
|
+
import numpy.typing as npt
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# -------------------------------------- helpers --------------------------------------
|
|
17
|
+
def _accumulate(
|
|
18
|
+
mask: cpt.NDArray[cp.bool_], out: cpt.NDArray[cp.bool_] | None
|
|
19
|
+
) -> cpt.NDArray[cp.bool_]:
|
|
20
|
+
if out is None:
|
|
21
|
+
return mask
|
|
22
|
+
out |= mask
|
|
23
|
+
return out
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _empty(
|
|
27
|
+
coords: Sequence[cpt.NDArray], out: cpt.NDArray[cp.bool_] | None
|
|
28
|
+
) -> cpt.NDArray[cp.bool_]:
|
|
29
|
+
return cp.zeros(coords[0].shape, dtype=bool) if out is None else out
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ellipse(
|
|
33
|
+
coords: Sequence[cpt.NDArray],
|
|
34
|
+
center: Sequence[float],
|
|
35
|
+
radii: Sequence[float],
|
|
36
|
+
angle: float = 0.0,
|
|
37
|
+
out: cpt.NDArray[cp.bool_] | None = None,
|
|
38
|
+
) -> cpt.NDArray[cp.bool_]:
|
|
39
|
+
"""Interior of the ellipse with semi-axes `radii`, rotated by `angle` radians"""
|
|
40
|
+
dx = coords[0] - center[0]
|
|
41
|
+
dy = coords[1] - center[1]
|
|
42
|
+
if angle:
|
|
43
|
+
cos, sin = math.cos(angle), math.sin(angle)
|
|
44
|
+
dx, dy = cos * dx + sin * dy, cos * dy - sin * dx
|
|
45
|
+
return _accumulate((dx / radii[0]) ** 2 + (dy / radii[1]) ** 2 < 1.0, out)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def circle(
|
|
49
|
+
coords: Sequence[cpt.NDArray],
|
|
50
|
+
center: Sequence[float],
|
|
51
|
+
radius: float,
|
|
52
|
+
out: cpt.NDArray[cp.bool_] | None = None,
|
|
53
|
+
) -> cpt.NDArray[cp.bool_]:
|
|
54
|
+
"""Interior of the circle of radius `radius`"""
|
|
55
|
+
return ellipse(coords, center, (radius, radius), out=out)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def circles(
|
|
59
|
+
coords: Sequence[cpt.NDArray],
|
|
60
|
+
centers: npt.ArrayLike,
|
|
61
|
+
radius: float,
|
|
62
|
+
out: cpt.NDArray[cp.bool_] | None = None,
|
|
63
|
+
) -> cpt.NDArray[cp.bool_]:
|
|
64
|
+
"""Union of equal-radius circles, one per row of `centers`, in any dimension.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
coords: the grid the mask is built on.
|
|
68
|
+
centers: (num, ndim) physical centers, as `line` returns them.
|
|
69
|
+
radius: the radius shared by all of them.
|
|
70
|
+
out: mask to accumulate into, created when omitted.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
the accumulated mask, true within `radius` of any center.
|
|
74
|
+
"""
|
|
75
|
+
centers = np.atleast_2d(centers)
|
|
76
|
+
if centers.shape[1] != len(coords):
|
|
77
|
+
raise ValueError(f"centers need {len(coords)} coordinates, not {centers.shape}")
|
|
78
|
+
out = _empty(coords, out)
|
|
79
|
+
for center in centers:
|
|
80
|
+
distance = sum((x - c) ** 2 for x, c in zip(coords, center))
|
|
81
|
+
out |= distance < radius**2
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def box(
|
|
86
|
+
coords: Sequence[cpt.NDArray],
|
|
87
|
+
center: Sequence[float],
|
|
88
|
+
sizes: Sequence[float],
|
|
89
|
+
out: cpt.NDArray[cp.bool_] | None = None,
|
|
90
|
+
) -> cpt.NDArray[cp.bool_]:
|
|
91
|
+
"""Interior of the axis-aligned box with side lengths `sizes`, boundary included"""
|
|
92
|
+
inside = None
|
|
93
|
+
for x, c, size in zip(coords, center, sizes):
|
|
94
|
+
slab = cp.abs(x - c) <= 0.5 * size
|
|
95
|
+
inside = slab if inside is None else inside & slab
|
|
96
|
+
return _accumulate(inside, out)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def rectangle(
|
|
100
|
+
coords: Sequence[cpt.NDArray],
|
|
101
|
+
center: Sequence[float],
|
|
102
|
+
sizes: Sequence[float],
|
|
103
|
+
angle: float = 0.0,
|
|
104
|
+
out: cpt.NDArray[cp.bool_] | None = None,
|
|
105
|
+
) -> cpt.NDArray[cp.bool_]:
|
|
106
|
+
"""Interior of the rectangle with side lengths `sizes`, rotated by `angle` radians"""
|
|
107
|
+
dx = coords[0] - center[0]
|
|
108
|
+
dy = coords[1] - center[1]
|
|
109
|
+
if angle:
|
|
110
|
+
cos, sin = math.cos(angle), math.sin(angle)
|
|
111
|
+
dx, dy = cos * dx + sin * dy, cos * dy - sin * dx
|
|
112
|
+
inside = (cp.abs(dx) <= 0.5 * sizes[0]) & (cp.abs(dy) <= 0.5 * sizes[1])
|
|
113
|
+
return _accumulate(inside, out)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def random_ellipses(
|
|
117
|
+
coords: Sequence[cpt.NDArray],
|
|
118
|
+
count: int,
|
|
119
|
+
radii: tuple[float, float],
|
|
120
|
+
bounds: Sequence[tuple[float, float]],
|
|
121
|
+
angle: tuple[float, float] = (0.0, math.pi),
|
|
122
|
+
overlap: bool = True,
|
|
123
|
+
rng: int | np.random.Generator | None = None,
|
|
124
|
+
attempts: int = 100,
|
|
125
|
+
out: cpt.NDArray[cp.bool_] | None = None,
|
|
126
|
+
) -> cpt.NDArray[cp.bool_]:
|
|
127
|
+
"""`count` ellipses at uniformly random centers, semi-axes, and orientations.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
coords: the grid the mask is built on.
|
|
131
|
+
count: how many ellipses to place.
|
|
132
|
+
radii: (min, max) a semi-axis is drawn from, independently per axis.
|
|
133
|
+
bounds: one (low, high) per axis, the box centers are drawn from.
|
|
134
|
+
angle: (min, max) rotation in radians.
|
|
135
|
+
overlap: when false, reject a center whose circumscribed circle meets an
|
|
136
|
+
earlier one, and raise once `attempts` draws in a row are rejected.
|
|
137
|
+
rng: seed or generator, so a driver reproduces its geometry.
|
|
138
|
+
out: mask to accumulate into, created when omitted.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
the accumulated mask, true inside the ellipses.
|
|
142
|
+
"""
|
|
143
|
+
rng = np.random.default_rng(rng)
|
|
144
|
+
out = _empty(coords, out)
|
|
145
|
+
placed = [] # (center, circumscribed radius) of the ellipses accepted so far
|
|
146
|
+
for i in range(count):
|
|
147
|
+
for _ in range(attempts):
|
|
148
|
+
center = tuple(rng.uniform(low, high) for low, high in bounds)
|
|
149
|
+
semi = tuple(rng.uniform(*radii) for _ in bounds)
|
|
150
|
+
bound = max(semi)
|
|
151
|
+
if overlap or all(
|
|
152
|
+
math.dist(center, other) >= bound + radius for other, radius in placed
|
|
153
|
+
):
|
|
154
|
+
break
|
|
155
|
+
else:
|
|
156
|
+
raise RuntimeError(
|
|
157
|
+
f"placed only {i} of {count} ellipses without overlap "
|
|
158
|
+
f"({attempts} attempts for the next one)"
|
|
159
|
+
)
|
|
160
|
+
placed.append((center, bound))
|
|
161
|
+
ellipse(coords, center, semi, rng.uniform(*angle), out)
|
|
162
|
+
return out
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def stacked_circles(
|
|
166
|
+
coords: Sequence[cpt.NDArray],
|
|
167
|
+
count: int,
|
|
168
|
+
radius: float,
|
|
169
|
+
span: tuple[float, float],
|
|
170
|
+
center: float | Sequence[float],
|
|
171
|
+
axis: int = 0,
|
|
172
|
+
ratio: float = 0.5,
|
|
173
|
+
order: str | None = "descending",
|
|
174
|
+
out: cpt.NDArray[cp.bool_] | None = None,
|
|
175
|
+
) -> cpt.NDArray[cp.bool_]:
|
|
176
|
+
"""A row of geometrically shrinking circles, evenly gapped along one axis.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
coords: the grid the mask is built on.
|
|
180
|
+
count: how many circles to place.
|
|
181
|
+
radius: the largest radius, which `ratio` shrinks from.
|
|
182
|
+
span: (start, end) along `axis` the row is fitted into, tangent to both ends.
|
|
183
|
+
center: the coordinate on each of the other axes.
|
|
184
|
+
axis: the axis the circles are stacked along.
|
|
185
|
+
ratio: factor between consecutive radii.
|
|
186
|
+
order: `descending` or `ascending` along the axis, or None for equal radii.
|
|
187
|
+
out: mask to accumulate into, created when omitted.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
the accumulated mask, true inside the circles.
|
|
191
|
+
"""
|
|
192
|
+
if order not in (None, "ascending", "descending"):
|
|
193
|
+
raise ValueError("order must be 'descending', 'ascending' or None")
|
|
194
|
+
if order is None:
|
|
195
|
+
radii = [radius] * count
|
|
196
|
+
else:
|
|
197
|
+
radii = [radius * ratio**i for i in range(count)]
|
|
198
|
+
if order == "ascending":
|
|
199
|
+
radii.reverse()
|
|
200
|
+
|
|
201
|
+
length = span[1] - span[0]
|
|
202
|
+
gap = (length - 2.0 * sum(radii)) / (count - 1) if count > 1 else 0.0
|
|
203
|
+
if gap < 0.0:
|
|
204
|
+
raise ValueError(
|
|
205
|
+
f"{count} circles of radius {radius} and ratio {ratio} do not fit "
|
|
206
|
+
f"into a span of {length}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
others = np.atleast_1d(center).tolist() # the nonstacking axes, in order
|
|
210
|
+
if len(others) != len(coords) - 1:
|
|
211
|
+
raise ValueError(f"center needs {len(coords) - 1} coordinate(s), not {others}")
|
|
212
|
+
|
|
213
|
+
out = _empty(coords, out)
|
|
214
|
+
position = span[0] + radii[0]
|
|
215
|
+
for i, r in enumerate(radii):
|
|
216
|
+
if i:
|
|
217
|
+
position += radii[i - 1] + gap + r
|
|
218
|
+
origin = others.copy()
|
|
219
|
+
origin.insert(axis % len(coords), position)
|
|
220
|
+
circle(coords, origin, r, out)
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def nodes(mask: cpt.NDArray[cp.bool_]) -> cpt.NDArray[cp.int32]:
|
|
225
|
+
"""The (ndim, count) grid indices `mask` selects, as the kernels take them"""
|
|
226
|
+
return cp.stack(cp.nonzero(mask)).astype(cp.int32)
|