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/utils.py ADDED
@@ -0,0 +1,472 @@
1
+ import itertools
2
+ from collections.abc import Callable, Sequence
3
+
4
+ import cupy as cp
5
+ import cupy.typing as cpt
6
+ import numpy as np
7
+ import numpy.typing as npt
8
+
9
+ from .sensitivity import l2_misfit, sensitivity
10
+ from .wave import Simulation, Source, simulate
11
+
12
+ TOL = 1e-6 # for `distribute`, so exact-boundary coordinates survive rounding
13
+
14
+
15
+ # -------------------------------------- helpers --------------------------------------
16
+ def _reparametrize(
17
+ sim: Simulation, indicator: cpt.NDArray, grads: dict[str, cpt.NDArray]
18
+ ) -> cpt.NDArray:
19
+ """Chain d(cost)/d(mass, stiff) onto the indicator with `parametrization_jacobian`."""
20
+ d_mass, d_stiff = sim.parametrization_jacobian(indicator)
21
+ return d_mass * grads["mass"] + d_stiff * grads["stiff"]
22
+
23
+
24
+ # -------------------------------------- general --------------------------------------
25
+ def interior_slice(sim: Simulation) -> tuple[slice, ...]:
26
+ """Index tuple selecting the interior nodes, dropping the ghost ring and the padding."""
27
+ return tuple(slice(1, n - 1) for n in sim.Nx)
28
+
29
+
30
+ def threshold(
31
+ field: cpt.NDArray,
32
+ eta: float = 0.5,
33
+ low: float = 0.0,
34
+ high: float = 1.0,
35
+ dtype: npt.DTypeLike | None = None,
36
+ ) -> cpt.NDArray:
37
+ """Snap a grey design to `low` below the threshold `eta` and `high` above it.
38
+
39
+ `dtype` defaults to the dtype of `field`, since `cp.where` against python floats
40
+ otherwise promotes a float32 design to float64 and the kernels read it as garbage.
41
+ """
42
+ snapped = cp.where(field < eta, low, high)
43
+ if dtype is None:
44
+ dtype = field.dtype if field.dtype.kind == "f" else snapped.dtype
45
+ return snapped.astype(dtype)
46
+
47
+
48
+ def resample(
49
+ signal: cpt.NDArray | npt.NDArray,
50
+ dt: float,
51
+ dt_new: float,
52
+ N_new: int | None = None,
53
+ ) -> cpt.NDArray | npt.NDArray:
54
+ """Linearly resample `signal` from timestep `dt` to `dt_new`.
55
+
56
+ Args:
57
+ signal: samples along the leading axis, on either array module.
58
+ dt: the timestep `signal` is sampled at.
59
+ dt_new: the timestep to resample onto.
60
+ N_new: number of output samples, defaulting to the original span. Pass it
61
+ explicitly to force a common length across separately resampled signals.
62
+
63
+ Returns:
64
+ the resampled signal, zero past the end of the original span.
65
+ """
66
+ xp = cp.get_array_module(signal)
67
+ values = xp.asarray(signal)
68
+ steps = values.shape[0]
69
+ if steps < 2:
70
+ raise ValueError(f"resampling needs at least two samples, got {steps}")
71
+ if N_new is None:
72
+ span = (steps - 1) * dt / dt_new
73
+ N_new = int(span + 1e-9 * max(1.0, span)) + 1
74
+
75
+ dtype = xp.dtype(values.dtype if values.dtype.kind == "f" else xp.float64)
76
+ t = xp.arange(N_new, dtype=dtype) * dtype.type(dt_new / dt) # in input samples
77
+ left = xp.clip(xp.floor(t), 0, steps - 2)
78
+ weight = t - left
79
+ index = left.astype(xp.int32)
80
+ if values.ndim > 1:
81
+ weight = weight.reshape(N_new, *(1,) * (values.ndim - 1))
82
+ interpolated = (1 - weight) * values[index] + weight * values[index + 1]
83
+ return interpolated * (t.reshape(weight.shape) <= steps - 1)
84
+
85
+
86
+ def line(
87
+ start: npt.ArrayLike, stop: npt.ArrayLike, count: int
88
+ ) -> npt.NDArray[np.float64]:
89
+ """`count` coordinates evenly spaced from `start` to `stop`, endpoints included."""
90
+ return np.linspace(start, stop, count)
91
+
92
+
93
+ def _corner_weights(
94
+ sim: Simulation, coords: npt.NDArray, shift: npt.NDArray
95
+ ) -> tuple[npt.NDArray, npt.NDArray]:
96
+ """Cell corners and multilinear weights of `coords` on the grid shifted by `shift`.
97
+
98
+ Returns:
99
+ (nodes, weights) shaped (ndim, num, 2**ndim) and (num, 2**ndim), the nodes
100
+ array indices on the component's own grid, whose unknowns end half a cell
101
+ short of a wall along a shifted axis.
102
+ """
103
+ index = np.empty_like(coords)
104
+ for d in range(sim.ndim):
105
+ nodal = coords[:, d] / sim.dx[d] + 1.0
106
+ # checked in index units, where the interior runs from node 1 to Nx[d] - 2
107
+ if nodal.min() < 1.0 - TOL or nodal.max() > sim.Nx[d] - 2.0 + TOL:
108
+ length = (sim.Nx[d] - 3) * sim.dx[d] # spanned by the interior nodes
109
+ raise ValueError(
110
+ f"axis {d} coordinate outside the domain [0, {length:g}]: "
111
+ f"[{float(coords[:, d].min()):g}, {float(coords[:, d].max()):g}]"
112
+ )
113
+ # a wall coordinate on a shifted axis lands on the unknown half a cell inside
114
+ high = sim.Nx[d] - 2.0 - (shift[d] > 0.0)
115
+ index[:, d] = np.clip(nodal - shift[d], 1.0, high)
116
+ base = np.stack(
117
+ [
118
+ np.clip(np.floor(index[:, d]), 1, sim.Nx[d] - 3 - (shift[d] > 0.0)).astype(
119
+ np.int32
120
+ )
121
+ for d in range(sim.ndim)
122
+ ]
123
+ )
124
+ offset = index.T - base # (ndim, num) position inside that cell, in [0, 1]
125
+ corners = np.array(list(itertools.product((0, 1), repeat=sim.ndim))) # (K, ndim)
126
+ nodes = base[:, :, None] + corners.T[:, None, :] # (ndim, num, K)
127
+ weights = np.ones((len(coords), len(corners)))
128
+ for d in range(sim.ndim):
129
+ w = offset[d][:, None]
130
+ weights *= np.where(corners[None, :, d] == 1, w, 1.0 - w)
131
+ return nodes, weights
132
+
133
+
134
+ def distribute(
135
+ sim: Simulation, coords: npt.ArrayLike, direction: npt.ArrayLike | None = None
136
+ ) -> tuple[cpt.NDArray[cp.int32], cpt.NDArray]:
137
+ """Multilinear interpolation of `coords` onto the grid, along `direction`.
138
+
139
+ A vector unknown drives and reads along a direction, which enters as a factor on
140
+ the weights, so `traces` and `scatter` stay exact transposes of one another. A
141
+ staggered unknown declares `sim.component_offsets`, and each component is then
142
+ interpolated on its own shifted grid.
143
+
144
+ Args:
145
+ sim: the simulation whose grid the coordinates land on.
146
+ coords: (num, ndim) physical coordinates, inside the domain.
147
+ direction: (ncomp,) or (num, ncomp) components to act along, required where the
148
+ unknown has more than one.
149
+
150
+ Returns:
151
+ (nodes, weights) of the surrounding 2**ndim cell corners, shaped
152
+ (node_rows, num * 2**ndim * ncomp) and (num, 2**ndim * ncomp), the component
153
+ running fastest.
154
+ """
155
+ coords = np.atleast_2d(np.asarray(coords, dtype=float))
156
+ if coords.shape[1] != sim.ndim:
157
+ raise ValueError(
158
+ f"coordinates need {sim.ndim} components, not {coords.shape[1]}"
159
+ )
160
+ shifts = sim.component_offsets
161
+ if shifts is None:
162
+ shifts = np.zeros((sim.ncomp, sim.ndim))
163
+ if direction is None:
164
+ if sim.ncomp > 1:
165
+ raise ValueError(f"a {sim.ncomp}-component unknown needs a direction")
166
+ nodes, weights = _corner_weights(sim, coords, shifts[0])
167
+ return (
168
+ cp.asarray(nodes.reshape(sim.ndim, -1), dtype=cp.int32),
169
+ cp.asarray(weights, dtype=sim.dtype),
170
+ )
171
+ n = np.atleast_2d(np.asarray(direction, dtype=float))
172
+ if n.shape[1] != sim.ncomp:
173
+ raise ValueError(f"direction needs {sim.ncomp} components, not {n.shape[1]}")
174
+ if len(n) == 1:
175
+ n = np.repeat(n, len(coords), axis=0)
176
+ if len(n) != len(coords):
177
+ raise ValueError(f"{len(n)} directions for {len(coords)} coordinates")
178
+ per = [_corner_weights(sim, coords, shifts[c]) for c in range(sim.ncomp)]
179
+ weights = np.stack([w for _, w in per], axis=2) * n[:, None, :]
180
+ weights = weights.reshape(len(coords), -1)
181
+ nodes = np.stack([nodes for nodes, _ in per], axis=3) # (ndim, num, K, ncomp)
182
+ if sim.node_rows == sim.ndim:
183
+ # a single component needs no row of its own, so the direction is a scale
184
+ return (
185
+ cp.asarray(nodes.reshape(sim.ndim, -1), dtype=cp.int32),
186
+ cp.asarray(weights, dtype=sim.dtype),
187
+ )
188
+ # the component runs fastest, so one column per (coordinate, corner, component)
189
+ component = np.broadcast_to(
190
+ np.arange(sim.ncomp)[None, None, None, :], (1, *nodes.shape[1:])
191
+ )
192
+ rows = np.concatenate((component, nodes), axis=0)
193
+ return (
194
+ cp.asarray(rows.reshape(sim.node_rows, -1), dtype=cp.int32),
195
+ cp.asarray(weights, dtype=sim.dtype),
196
+ )
197
+
198
+
199
+ def point_source(
200
+ sim: Simulation,
201
+ coords: npt.ArrayLike,
202
+ signal: cpt.NDArray | npt.NDArray,
203
+ direction: npt.ArrayLike | None = None,
204
+ ) -> Source:
205
+ """Build a `Source` injecting `signal` at `coords`, distributed by `distribute`.
206
+
207
+ Args:
208
+ sim: the simulation the source fires into.
209
+ coords: (num, ndim) physical coordinates of the point sources.
210
+ signal: one (N,) trace broadcast to every coordinate, or (N, num) one each.
211
+
212
+ Returns:
213
+ the `Source`, its signal divided by the cell volume so the amplitude is
214
+ independent of the grid spacing.
215
+ """
216
+ nodes, weights = distribute(sim, coords, direction)
217
+ count = weights.shape[0]
218
+ signal = cp.asarray(signal, dtype=sim.dtype)
219
+ if signal.ndim == 1:
220
+ signal = cp.broadcast_to(signal[:, None], (signal.shape[0], count))
221
+ if signal.shape[1] != count:
222
+ raise ValueError(f"{signal.shape[1]} signal columns for {count} sources")
223
+ columns = (signal[:, :, None] * weights).reshape(signal.shape[0], -1)
224
+ return Source(
225
+ nodes, cp.ascontiguousarray(columns / np.prod(sim.dx), dtype=sim.dtype)
226
+ )
227
+
228
+
229
+ def collect_source(
230
+ sim: Simulation,
231
+ coords: npt.ArrayLike,
232
+ columns: cpt.NDArray,
233
+ direction: npt.ArrayLike | None = None,
234
+ ) -> cpt.NDArray:
235
+ """Transpose of `point_source`: an (N, num * 2**ndim) node gradient onto `coords`.
236
+
237
+ Args:
238
+ sim: the simulation the columns were injected into.
239
+ coords: (num, ndim) physical coordinates the source was built from.
240
+ columns: the derivative with respect to the node columns `point_source` made.
241
+
242
+ Returns:
243
+ the (N, num) derivative with respect to the signal of each coordinate.
244
+ """
245
+ _, weights = distribute(sim, coords, direction)
246
+ columns = columns.reshape(columns.shape[0], len(weights), -1)
247
+ # cast, since dividing a float32 record by a float64 cell volume would promote it
248
+ return (columns * weights).sum(2) / sim.dtype(np.prod(sim.dx))
249
+
250
+
251
+ def shots(
252
+ sim: Simulation,
253
+ coords: npt.ArrayLike,
254
+ signal: cpt.NDArray | npt.NDArray,
255
+ direction: npt.ArrayLike | None = None,
256
+ ) -> list[Source]:
257
+ """One single-coordinate `Source` per coordinate: the shot list of an inversion"""
258
+ return [point_source(sim, c, signal, direction) for c in np.atleast_2d(coords)]
259
+
260
+
261
+ def stack(sources: Sequence[Source]) -> Source:
262
+ """Fire several shots in a single simulation.
263
+
264
+ Positions and signal columns are concatenated, so a stacked shot costs one
265
+ simulation instead of `len(sources)` and returns one record.
266
+
267
+ Encode the shots (random signs, phase shifts) by scaling their signals first.
268
+ """
269
+ return Source(
270
+ cp.concatenate([s.position for s in sources], axis=1),
271
+ cp.ascontiguousarray(cp.concatenate([s.signal for s in sources], axis=1)),
272
+ )
273
+
274
+
275
+ class Sensors:
276
+ """Receivers at arbitrary coordinates, multilinearly interpolated onto the grid.
277
+
278
+ The interpolation `nodes` and `weights` are precomputed once, so `traces` and its
279
+ transpose `scatter` are the only per-record work.
280
+
281
+ Args:
282
+ sim: the simulation whose grid the receivers land on.
283
+ coords: (count, ndim) physical coordinates of the receivers.
284
+ direction: (ndim,) or (count, ndim) components each receiver measures along,
285
+ required where the unknown is a vector.
286
+ """
287
+
288
+ def __init__(
289
+ self,
290
+ sim: Simulation,
291
+ coords: npt.ArrayLike,
292
+ direction: npt.ArrayLike | None = None,
293
+ ) -> None:
294
+ self.sim = sim
295
+ self.coordinates = np.atleast_2d(np.asarray(coords, dtype=float))
296
+ self.count = len(self.coordinates)
297
+ self.nodes, self.weights = distribute(sim, self.coordinates, direction)
298
+
299
+ def traces(self, record: cpt.NDArray) -> cpt.NDArray:
300
+ """(N, count * 2**ndim) node record -> (N, count) receiver traces"""
301
+ return (record.reshape(record.shape[0], self.count, -1) * self.weights).sum(2)
302
+
303
+ def scatter(self, dphi: cpt.NDArray) -> cpt.NDArray:
304
+ """transpose of `traces`: (N, count) -> (N, count * 2**ndim)"""
305
+ return (dphi[:, :, None] * self.weights).reshape(dphi.shape[0], -1)
306
+
307
+ def objective(self, objective: Callable) -> Callable:
308
+ """Lift a receiver-space `objective` into the node space `sensitivity` wants"""
309
+
310
+ def wrapped(record):
311
+ cost, dphi = objective(self.traces(record))
312
+ return cost, self.scatter(dphi)
313
+
314
+ return wrapped
315
+
316
+
317
+ # ---------------------------------------- fwi ----------------------------------------
318
+ def measure(
319
+ sim: Simulation,
320
+ sources: Sequence[Source],
321
+ indicator: cpt.NDArray,
322
+ sensors: Sensors,
323
+ ) -> list[cpt.NDArray]:
324
+ """Receiver traces per shot: the synthetic experiment an inversion is fitted to"""
325
+ return [
326
+ sensors.traces(simulate(sim, source, indicator, sensors=sensors.nodes)[1])
327
+ for source in sources
328
+ ]
329
+
330
+
331
+ def misfit(
332
+ sim: Simulation,
333
+ sources: Sequence[Source],
334
+ indicator: cpt.NDArray,
335
+ sensors: Sensors,
336
+ observed: Sequence[cpt.NDArray],
337
+ objective: Callable = l2_misfit,
338
+ ) -> float:
339
+ """The cost `misfit_gradient` returns, without its gradient: forward passes only."""
340
+ return sum(
341
+ objective(data)(traces)[0]
342
+ for traces, data in zip(measure(sim, sources, indicator, sensors), observed)
343
+ )
344
+
345
+
346
+ def misfit_gradient(
347
+ sim: Simulation,
348
+ sources: Sequence[Source],
349
+ indicator: cpt.NDArray,
350
+ sensors: Sensors,
351
+ observed: Sequence[cpt.NDArray],
352
+ objective: Callable = l2_misfit,
353
+ adjoint: Callable = sensitivity,
354
+ ) -> tuple[float, cpt.NDArray]:
355
+ """Misfit summed over the shots and its derivative with respect to `indicator`.
356
+
357
+ Args:
358
+ sim: the simulation each shot is run in.
359
+ sources: the shot list, one adjoint solve each.
360
+ indicator: the design field the gradient is taken with respect to.
361
+ sensors: the receiver array the objective is evaluated on.
362
+ observed: the measured traces, one (N, count) record per shot.
363
+ objective: factory taking one record and returning `objective(traces)`.
364
+ adjoint: which variant computes each shot, `reconstruction_sensitivity`
365
+ where the stored history no longer fits.
366
+
367
+ Returns:
368
+ (cost, gradient), the gradient a field over the padded grid, already
369
+ reparametrized from (mass, stiff) onto `indicator`.
370
+ """
371
+ cost = 0.0
372
+ gradient = cp.zeros(sim.Nx_padded, dtype=sim.dtype)
373
+ for source, data in zip(sources, observed):
374
+ shot_cost, grads, _, _ = adjoint(
375
+ sim, source, indicator, sensors.nodes, sensors.objective(objective(data))
376
+ )
377
+ cost += shot_cost
378
+ gradient += _reparametrize(sim, indicator, grads)
379
+ return cost, gradient
380
+
381
+
382
+ # ---------------------------------------- tato ---------------------------------------
383
+ def energy(sim: Simulation) -> Callable:
384
+ """Objective factory: J = 1/2 int_region int_t p^2, the energy reaching the sensors."""
385
+ scale = float(np.prod(sim.dx)) * sim.dt
386
+
387
+ def objective(traces):
388
+ return 0.5 * scale * float(cp.sum(traces**2)), scale * traces
389
+
390
+ return objective
391
+
392
+
393
+ def response(
394
+ sim: Simulation,
395
+ source: Source,
396
+ indicator: cpt.NDArray,
397
+ sensors: cpt.NDArray[cp.int32],
398
+ objective: Callable,
399
+ ) -> tuple[float, cpt.NDArray]:
400
+ """The cost `response_gradient` returns, without its gradient: one forward pass.
401
+
402
+ Returns:
403
+ (cost, wavefield), the field at the last step over the logical grid, so the
404
+ design a cost was read from is plotted together with the wave that scored it.
405
+ """
406
+ wavefield, traces = simulate(sim, source, indicator, sensors=sensors)
407
+ return objective(traces)[0], wavefield
408
+
409
+
410
+ def response_gradient(
411
+ sim: Simulation,
412
+ source: Source,
413
+ indicator: cpt.NDArray,
414
+ sensors: cpt.NDArray[cp.int32],
415
+ objective: Callable,
416
+ adjoint: Callable = sensitivity,
417
+ ) -> tuple[float, cpt.NDArray]:
418
+ """Cost of one shot and its derivative with respect to `indicator`.
419
+
420
+ Args:
421
+ sim: the simulation the forward and adjoint passes both step.
422
+ source: the shot to differentiate, its position interior nodes only.
423
+ indicator: the design field the gradient is taken with respect to.
424
+ sensors: (ndim, num_sensors) interior grid indices the objective reads.
425
+ objective: takes the (N, num_sensors) record, returns (cost, dcost/dtraces).
426
+ adjoint: which variant computes the gradient, `reconstruction_sensitivity`
427
+ where the stored history no longer fits.
428
+
429
+ Returns:
430
+ (cost, gradient), the gradient a field over the padded grid, already
431
+ reparametrized from (mass, stiff) onto `indicator`.
432
+ """
433
+ cost, grads, _, _ = adjoint(sim, source, indicator, sensors, objective)
434
+ return cost, _reparametrize(sim, indicator, grads)
435
+
436
+
437
+ # ---------------------------------------- tpto ---------------------------------------
438
+ def intensity(
439
+ sim: Simulation,
440
+ frequencies: npt.ArrayLike,
441
+ weights: cpt.NDArray | npt.ArrayLike | None = None,
442
+ ) -> Callable:
443
+ """Objective factory: J = sum |u_hat(f)|**2, the spectral intensity at the sensors.
444
+
445
+ Args:
446
+ sim: the simulation the record comes from, whose `dt` and `N` fix the
447
+ transform.
448
+ frequencies: one frequency, or several scored by the same run.
449
+ weights: (num_frequencies, num_sensors), or anything broadcasting to it, so one
450
+ record scores several ports each at its own frequency. Defaults to 1.
451
+
452
+ Returns:
453
+ the objective `sensitivity` takes. The transform is linear in the record, so
454
+ its adjoint is the same pair of tables read backwards.
455
+ """
456
+ # the phase reaches 1e5 radians over a long run, so it is built in double
457
+ f = np.atleast_1d(np.asarray(frequencies, dtype=np.float64))
458
+ t = np.arange(sim.N, dtype=np.float64) * sim.dt
459
+ phase = 2.0 * np.pi * t[:, None] * f[None, :]
460
+ cos = cp.asarray(np.cos(phase), dtype=sim.dtype)
461
+ sin = cp.asarray(np.sin(phase), dtype=sim.dtype)
462
+ scale = sim.dtype(sim.dt)
463
+ w = 1.0 if weights is None else cp.asarray(weights, dtype=sim.dtype)
464
+
465
+ # real tables rather than one complex one: half the work, and no conjugate to drop
466
+ def objective(traces):
467
+ re = scale * (cos.T @ traces)
468
+ im = -scale * (sin.T @ traces)
469
+ cost = float(cp.sum(w * (re * re + im * im)))
470
+ return cost, 2.0 * scale * (cos @ (w * re) - sin @ (w * im))
471
+
472
+ return objective