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