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/wave.py ADDED
@@ -0,0 +1,518 @@
1
+ """The grid, the compile-time configuration, and the time loop every equation shares.
2
+
3
+ `Simulation` holds both and leaves the physics to a subclass in its own module
4
+ (`scalar.py`, `elastic.py`, `anisotropic.py`, `maxwell.py`), which names its kernel
5
+ sources and supplies the material and factor hooks. The `define_*` factories bind
6
+ compiled kernels to one such configuration, and `simulate` loops over the closures they
7
+ return.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import itertools
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+
17
+ import cupy as cp
18
+ import cupy.typing as cpt
19
+ import numpy as np
20
+ import numpy.typing as npt
21
+
22
+ from .boundary import canonical_boundary, define_boundary
23
+ from .stencils import preamble, weights
24
+
25
+ # Voigt row order per dimension as (k, l) strain pairs; the shear rows are PAIRS[d][d:]
26
+ PAIRS = {
27
+ 1: ((0, 0),),
28
+ 2: ((0, 0), (1, 1), (0, 1)),
29
+ 3: ((0, 0), (1, 1), (2, 2), (1, 2), (0, 2), (0, 1)),
30
+ }
31
+
32
+
33
+ # ------------------------------------- utilities -------------------------------------
34
+ def stable_dt(dx: tuple[float, ...], wavespeed: float, space_order: int = 2) -> float:
35
+ """CFL-stable timestep for an explicit scheme with grid spacing `dx`."""
36
+ lam = float(np.abs(weights(space_order // 2)).sum())
37
+ return 2.0 / (wavespeed * float(np.sqrt(lam * sum(1.0 / d**2 for d in dx))))
38
+
39
+
40
+ def stable_timestep(
41
+ sim: Simulation,
42
+ indicator: cpt.NDArray,
43
+ iterations: int = 60,
44
+ safety: float = 0.95,
45
+ ) -> float:
46
+ """Largest stable timestep for `sim` under `indicator`, measured not estimated.
47
+
48
+ The leapfrog is stable while the spectral radius of `dt**2 minv L` stays under 4,
49
+ and `L` is symmetric with `minv` diagonal, so a power iteration on the step kernel
50
+ itself converges to that radius. Exact for any order, any material and any boundary
51
+ layout, where `stable_dt` only knows the wave speed and the spacing.
52
+
53
+ Args:
54
+ sim: the simulation to measure, whose own `dt` sets the scale of the answer.
55
+ indicator: the design field, which is what a high contrast enters through.
56
+ iterations: power iterations, 60 being ample for three digits.
57
+ safety: fraction of the bound to return.
58
+
59
+ Returns:
60
+ the timestep to build `sim` with. A wide stencil over a strong contrast can put
61
+ this far below `stable_dt`, which is the signal to drop `space_order` rather
62
+ than to shrink `dt`.
63
+ """
64
+ mat = sim.build_materials(indicator)
65
+ step = define_step_method(sim, compile_kernels(sim), mat)
66
+ shifts = sim.component_offsets
67
+ if shifts is None:
68
+ shifts = np.zeros((sim.ncomp, sim.ndim))
69
+ slices = [
70
+ (c, *(slice(1, n - 1 - (shifts[c][d] > 0)) for d, n in enumerate(sim.Nx)))
71
+ for c in range(sim.ncomp)
72
+ ]
73
+ field = cp.asarray(
74
+ np.random.default_rng(0).standard_normal((sim.ncomp, *sim.Nx_padded)),
75
+ dtype=sim.dtype,
76
+ )
77
+ zero = cp.zeros_like(field)
78
+ out = cp.zeros_like(field)
79
+
80
+ def masked(values):
81
+ kept = cp.zeros_like(values)
82
+ for sl in slices:
83
+ kept[sl] = values[sl]
84
+ return kept
85
+
86
+ field = masked(field)
87
+ value = 0.0
88
+ for _ in range(iterations):
89
+ field /= cp.linalg.norm(field)
90
+ out[...] = 0.0
91
+ step(zero, field, out)
92
+ applied = masked(2.0 * field - out)
93
+ value = float(cp.sum(field * applied))
94
+ field = applied
95
+ if value <= 0.0:
96
+ raise ValueError(f"the operator came back non-positive: {value}")
97
+ return safety * sim.dt * float(np.sqrt(4.0 / value))
98
+
99
+
100
+ # -------------------------------------- helpers --------------------------------------
101
+ def padded_shape(Nx: tuple[int, ...]) -> tuple[int, ...]:
102
+ """Pad the fastest (last) axis of `Nx` up to a multiple of 32, for coalesced access."""
103
+ return (*Nx[:-1], ((Nx[-1] + 31) // 32) * 32)
104
+
105
+
106
+ def mirror_ghosts(sim: Simulation, field: cpt.NDArray) -> cpt.NDArray:
107
+ """Mirror `field`'s ghost layer onto its second-interior node, for homogeneous Neumann."""
108
+ for d in range(sim.ndim):
109
+ for ghost, mirror in ((0, 2), (sim.Nx[d] - 1, sim.Nx[d] - 3)):
110
+ dst = [slice(None)] * sim.ndim
111
+ dst[d] = ghost
112
+ src = [slice(None)] * sim.ndim
113
+ src[d] = mirror
114
+ field[tuple(dst)] = field[tuple(src)]
115
+ return field
116
+
117
+
118
+ def grid_coords(
119
+ Nx: tuple[int, ...], dx: tuple[float, ...], dtype: npt.DTypeLike = cp.float64
120
+ ) -> list[cpt.NDArray]:
121
+ """Padded grid coordinates for shape `Nx` at spacing `dx`, with node 1 at the origin."""
122
+ # indices 0 and -1 are ghost nodes outside the domain
123
+ axes = [(cp.arange(n, dtype=dtype) - 1) * d for n, d in zip(padded_shape(Nx), dx)]
124
+ return cp.meshgrid(*axes, indexing="ij")
125
+
126
+
127
+ @dataclass
128
+ class Source:
129
+ position: cpt.NDArray[cp.int32] # (ndim, num_sources) grid indices
130
+ signal: cpt.NDArray # (N, num_sources) time series
131
+
132
+
133
+ # W is the cell volume a node owns; docs/sensitivity.md derives it
134
+ def apply_cell_weights(sim: Simulation, field: cpt.NDArray) -> cpt.NDArray:
135
+ """In-place multiply of `field` by the cell weights W."""
136
+ # axes in sequence, so a corner compounds to 1/4 (1/8 in 3D)
137
+ for d in range(sim.ndim):
138
+ for index in (1, sim.Nx[d] - 2):
139
+ face = [slice(None)] * sim.ndim
140
+ face[d] = index
141
+ field[tuple(face)] *= 0.5
142
+ return field
143
+
144
+
145
+ def sensor_cell_weights(sim: Simulation, sensors: cpt.NDArray[cp.int32]) -> cpt.NDArray:
146
+ """Cell weights W at the sensor nodes only, as a (num_sensors,) vector."""
147
+ # the nested loop of apply_cell_weights, so the two agree on a degenerate axis too
148
+ w = cp.ones(sensors.shape[1], dtype=sim.dtype)
149
+ for d in range(sim.ndim):
150
+ for index in (1, sim.Nx[d] - 2):
151
+ w = cp.where(grid_rows(sim, sensors)[d] == index, w * 0.5, w)
152
+ return w
153
+
154
+
155
+ def grid_rows(
156
+ sim: Simulation, position: cpt.NDArray[cp.int32]
157
+ ) -> cpt.NDArray[cp.int32]:
158
+ """The `ndim` spatial rows of `position`, dropping a leading component row."""
159
+ return position[-sim.ndim :]
160
+
161
+
162
+ def flatten_indices(
163
+ sim: Simulation, position: cpt.NDArray[cp.int32]
164
+ ) -> cpt.NDArray[cp.int32]:
165
+ """Collapse (node_rows, num) indices `position` into flat indices of the field.
166
+
167
+ A vector unknown takes a leading component row, folded in as
168
+ `component * comp_stride`, so the gather and scatter kernels stay scalar.
169
+ """
170
+ if position.shape[0] != sim.node_rows:
171
+ raise ValueError(
172
+ f"position needs {sim.node_rows} rows for ncomp={sim.ncomp} in "
173
+ f"{sim.ndim}D, not {position.shape[0]}"
174
+ )
175
+ lin = cp.zeros(position.shape[1], dtype=cp.int32)
176
+ for d in range(sim.ndim):
177
+ lin += grid_rows(sim, position)[d] * cp.int32(sim.strides[d])
178
+ if sim.ncomp > 1:
179
+ lin += position[0] * cp.int32(sim.comp_stride)
180
+ return lin
181
+
182
+
183
+ # --------------------------------- staggered lattice ---------------------------------
184
+ def component_weights(sim: Simulation, c: int) -> cpt.NDArray:
185
+ """Cell weights of component `c`: halved on the walls of the unstaggered axes."""
186
+ w = cp.ones(sim.Nx_padded, dtype=sim.dtype)
187
+ for d in range(sim.ndim):
188
+ if d == c:
189
+ continue
190
+ for index in (1, sim.Nx[d] - 2):
191
+ wall = [slice(None)] * sim.ndim
192
+ wall[d] = index
193
+ w[tuple(wall)] *= 0.5
194
+ return w
195
+
196
+
197
+ def pair_weights(sim: Simulation, axes: tuple[int, int]) -> cpt.NDArray:
198
+ """Cell weights of a pair point: halved on the walls of the remaining axes."""
199
+ w = cp.ones(sim.Nx_padded, dtype=sim.dtype)
200
+ for d in range(sim.ndim):
201
+ if d in axes:
202
+ continue
203
+ for index in (1, sim.Nx[d] - 2):
204
+ wall = [slice(None)] * sim.ndim
205
+ wall[d] = index
206
+ w[tuple(wall)] *= 0.5
207
+ return w
208
+
209
+
210
+ def point_average(sim: Simulation, field: cpt.NDArray, c: int) -> cpt.NDArray:
211
+ """Arithmetic mean of `field` over the two nodes component `c` sits between."""
212
+ out = cp.zeros(sim.Nx_padded, dtype=sim.dtype)
213
+ lo = tuple(slice(0, n - 1) if d == c else slice(0, n) for d, n in enumerate(sim.Nx))
214
+ hi = tuple(slice(1, n) if d == c else slice(0, n) for d, n in enumerate(sim.Nx))
215
+ out[lo] = 0.5 * (field[lo] + field[hi])
216
+ return out
217
+
218
+
219
+ def point_average_adjoint(sim: Simulation, density: cpt.NDArray, c: int) -> cpt.NDArray:
220
+ """Transpose of `point_average`: half of `density` back onto each node it spans."""
221
+ out = 0.5 * density
222
+ to = [slice(None)] * sim.ndim
223
+ fro = [slice(None)] * sim.ndim
224
+ to[c], fro[c] = slice(1, None), slice(0, -1)
225
+ out[tuple(to)] += 0.5 * density[tuple(fro)]
226
+ return out
227
+
228
+
229
+ def pair_average(
230
+ sim: Simulation, field: cpt.NDArray, axes: tuple[int, int]
231
+ ) -> cpt.NDArray:
232
+ """Harmonic mean of `field` over the four nodes a pair point straddles.
233
+
234
+ Harmonic for the reason the cell scheme uses it: it keeps the coefficient single
235
+ valued across a material jump.
236
+ """
237
+ out = cp.zeros(sim.Nx_padded, dtype=sim.dtype)
238
+ safe = cp.maximum(field, cp.finfo(sim.dtype).tiny)
239
+ inner = tuple(
240
+ slice(0, n - 1) if d in axes else slice(0, n) for d, n in enumerate(sim.Nx)
241
+ )
242
+ for bits in itertools.product((0, 1), repeat=2):
243
+ shifted = tuple(
244
+ slice(bits[axes.index(d)], n - 1 + bits[axes.index(d)])
245
+ if d in axes
246
+ else slice(0, n)
247
+ for d, n in enumerate(sim.Nx)
248
+ )
249
+ out[inner] += 1.0 / safe[shifted]
250
+ out[inner] = 4.0 / out[inner]
251
+ return out
252
+
253
+
254
+ def pair_average_adjoint(
255
+ sim: Simulation, density: cpt.NDArray, field: cpt.NDArray, axes: tuple[int, int]
256
+ ) -> cpt.NDArray:
257
+ """Transpose of `pair_average`: d(harmonic mean)/d(node) is `(mean / node)**2 / 4`."""
258
+ safe = cp.maximum(field, cp.finfo(sim.dtype).tiny)
259
+ mean = pair_average(sim, field, axes)
260
+ scattered = density * mean * mean * 0.25
261
+ out = cp.zeros(sim.Nx_padded, dtype=sim.dtype)
262
+ for bits in itertools.product((0, 1), repeat=2):
263
+ to = [slice(None)] * sim.ndim
264
+ fro = [slice(None)] * sim.ndim
265
+ for d, b in zip(axes, bits):
266
+ to[d], fro[d] = slice(b, None), slice(0, -b if b else None)
267
+ out[tuple(to)] += scattered[tuple(fro)] / safe[tuple(to)] ** 2
268
+ return out
269
+
270
+
271
+ # -------------------------------- discretization setup -------------------------------
272
+ @dataclass
273
+ class Simulation:
274
+ """Grid, timestepping, and compile-time configuration shared by all wave equations."""
275
+
276
+ Nx: tuple[int, ...] # logical grid points per axis (incl. ghost nodes)
277
+ dx: tuple[float, ...]
278
+ N: int # number of time steps
279
+ dt: float
280
+ threads: tuple[int, ...] # threads per block, per axis
281
+ precision: str = "float32" # "float32" or "float64"
282
+ space_order: int = 2 # finite difference order: any even number
283
+ boundary: tuple = None # ((low, high),) per axis; None is the equation's default
284
+ damping: cpt.NDArray | None = None # nodal field d, None for a lossless operator
285
+
286
+ @property
287
+ def compile_flags(self) -> tuple[str, ...]:
288
+ """`-DUSE_DAMPING` when a damping field is set, else no extra flags."""
289
+ return ("-DUSE_DAMPING",) if self.damping is not None else ()
290
+
291
+ kernel_path = None # forward source this equation compiles, set by the subclass
292
+ sensitivity_path = None # and the adjoint one
293
+ default_boundary = None # what `boundary=None` means for this equation
294
+
295
+ @property
296
+ def ncomp(self) -> int:
297
+ """Field components per node: 1 for a scalar unknown, `ndim` for a vector one."""
298
+ return 1
299
+
300
+ @property
301
+ def component_offsets(self) -> npt.NDArray[np.float64] | None:
302
+ """(ncomp, ndim) grid offsets of each component in units of `dx`, or None for nodal."""
303
+ return None
304
+
305
+ @property
306
+ def reach(self) -> int:
307
+ """Nodes one step reads past a point, which a reconstruction strip must cover."""
308
+ return self.space_order // 2
309
+
310
+ def __post_init__(self) -> None:
311
+ """Derive `ndim`, padded shape, strides, dtype, and canonical `boundary`."""
312
+ self.ndim = len(self.Nx)
313
+ self.Nx_padded = padded_shape(self.Nx)
314
+ # C-contiguous strides over the padded shape (last axis has unit stride)
315
+ strides = [1] * self.ndim
316
+ for d in range(self.ndim - 2, -1, -1):
317
+ strides[d] = strides[d + 1] * self.Nx_padded[d + 1]
318
+ self.strides = tuple(strides)
319
+ self.dtype = cp.float32 if self.precision == "float32" else cp.float64
320
+ self.boundary = canonical_boundary(
321
+ self.boundary, self.ndim, self.default_boundary
322
+ )
323
+ if self.space_order % 2 != 0 or self.space_order < 2:
324
+ raise ValueError("space_order must be an even integer >= 2")
325
+ self.comp_stride = int(np.prod(self.Nx_padded))
326
+ self.node_rows = self.ndim + (self.ncomp > 1)
327
+ self.field_shape = (
328
+ self.Nx_padded if self.ncomp == 1 else (self.ncomp, *self.Nx_padded)
329
+ )
330
+ # flatten_indices accumulates in int32, so the whole field has to address in it
331
+ if self.ncomp * self.comp_stride >= 2**31:
332
+ raise ValueError(
333
+ f"{self.ncomp} x {self.comp_stride} nodes overflow the int32 flat "
334
+ f"index; coarsen the grid"
335
+ )
336
+
337
+ def define_step(self, kernels: cp.RawModule, mat: dict) -> Callable:
338
+ """Closure launching the finite-difference step kernel over (u0, u1, u2)."""
339
+ fd_kernel = kernels.get_function("fd_kernel")
340
+ grid, block = grid_block(self)
341
+ args = [
342
+ None,
343
+ None,
344
+ None,
345
+ *self.step_kernel_args(mat),
346
+ *axis_geometry(self, self.step_factors()),
347
+ ]
348
+
349
+ def fd_step(u0, u1, u2):
350
+ args[0], args[1], args[2] = u0, u1, u2
351
+ fd_kernel(grid, block, args)
352
+ return u2
353
+
354
+ return fd_step
355
+
356
+
357
+ # ----------------------------------- kernel helpers ----------------------------------
358
+ COMMON_PATH = Path(__file__).parent / "kernels" / "common.cuh"
359
+
360
+
361
+ def compile_kernels(sim: Simulation, path: Path | None = None) -> cp.RawModule:
362
+ """Compile `path` for `sim`, defaulting to its own source, stencil table injected."""
363
+ path = sim.kernel_path if path is None else path
364
+ options = ["--use_fast_math", f"-DNDIM={sim.ndim}", *sim.compile_flags]
365
+ if sim.precision == "float32":
366
+ options.append("-DUSE_FLOAT")
367
+ # injected as source, so the module cache keys on the order without a -D flag
368
+ code = preamble(sim.space_order) + COMMON_PATH.read_text() + Path(path).read_text()
369
+ return cp.RawModule(code=code, options=tuple(options))
370
+
371
+
372
+ def grid_block(sim: Simulation) -> tuple[tuple[int, ...], tuple[int, ...]]:
373
+ """CUDA (grid, block) dimensions for `sim`, fastest axis mapped to x."""
374
+ # map the fastest axis to grid/block x, the next to y, the next to z
375
+ extent = sim.Nx_padded
376
+ block = tuple(sim.threads[::-1])
377
+ grid = tuple(
378
+ (extent[d] + sim.threads[d] - 1) // sim.threads[d] for d in range(sim.ndim)
379
+ )[::-1]
380
+ return grid, block
381
+
382
+
383
+ def axis_geometry(sim: Simulation, factors: list) -> list:
384
+ """Interleave `factors` with axis extents and strides, in the layout the step kernel expects."""
385
+ # kernel args after the material arrays: f0, N0, [f1, N1, s0], [f2, N2, s1]
386
+ geom = [factors[0], sim.Nx[0]]
387
+ for d in range(1, sim.ndim):
388
+ geom += [factors[d], sim.Nx[d], sim.strides[d - 1]]
389
+ return geom
390
+
391
+
392
+ # -------------------------------- simulation functions -------------------------------
393
+ def define_step_method(sim: Simulation, kernels: cp.RawModule, mat: dict) -> Callable:
394
+ """The step closure `sim.define_step` builds, a hook so a scheme may launch several kernels."""
395
+ return sim.define_step(kernels, mat)
396
+
397
+
398
+ def define_excitation(
399
+ sim: Simulation,
400
+ position: cpt.NDArray[cp.int32],
401
+ kernels: cp.RawModule,
402
+ mat: dict,
403
+ ) -> Callable:
404
+ """Closure injecting `signal` at `position` into `u` at timestep `t_index`."""
405
+ excitation_kernel = kernels.get_function("excitation_kernel")
406
+ threads = 256
407
+ num_sources = position.shape[1]
408
+ blocks = (num_sources + threads - 1) // threads
409
+ lin_index = flatten_indices(sim, position)
410
+ weight = sim.excitation_weights(mat, lin_index)
411
+ # the whole (N, num_sources) record plus a row offset, not a row view
412
+ args = [None, None, np.int32(0), lin_index, np.int32(num_sources), weight]
413
+
414
+ def excitation_step(u, signal, t_index):
415
+ args[0], args[1] = u, signal
416
+ args[2] = np.int32(t_index * num_sources)
417
+ excitation_kernel((blocks,), (threads,), args)
418
+ return u
419
+
420
+ return excitation_step
421
+
422
+
423
+ def define_get_signal(
424
+ sim: Simulation, sensors: cpt.NDArray[cp.int32], kernels: cp.RawModule
425
+ ) -> Callable:
426
+ """Closure writing row `t_index` of the (N, num_sensors) record `um` from `u`."""
427
+ get_signal_kernel = kernels.get_function("get_signal_kernel")
428
+ threads = 256
429
+ num_sensors = sensors.shape[1]
430
+ blocks = (num_sensors + threads - 1) // threads
431
+ lin_index = flatten_indices(sim, sensors)
432
+ args = [None, None, np.int32(0), lin_index, np.int32(num_sensors)]
433
+
434
+ # writes row t of the whole (N, num_sensors) record, so the caller never slices
435
+ def get_signal_step(u, um, t_index):
436
+ args[0], args[1] = u, um
437
+ args[2] = np.int32(t_index * num_sensors)
438
+ get_signal_kernel((blocks,), (threads,), args)
439
+ return um
440
+
441
+ return get_signal_step
442
+
443
+
444
+ def define_set_signal(
445
+ sim: Simulation, sensors: cpt.NDArray[cp.int32], kernels: cp.RawModule
446
+ ) -> Callable:
447
+ """Closure writing `u` at `sensors` back from row `t_index` of the record `um`."""
448
+ set_signal_kernel = kernels.get_function("set_signal_kernel")
449
+ threads = 256
450
+ num_sensors = sensors.shape[1]
451
+ blocks = (num_sensors + threads - 1) // threads
452
+ lin_index = flatten_indices(sim, sensors)
453
+ args = [None, None, np.int32(0), lin_index, np.int32(num_sensors)]
454
+
455
+ # assignment rather than the atomicAdd of define_excitation, so it restores a state
456
+ def set_signal_step(u, um, t_index):
457
+ args[0], args[1] = u, um
458
+ args[2] = np.int32(t_index * num_sensors)
459
+ set_signal_kernel((blocks,), (threads,), args)
460
+ return u
461
+
462
+ return set_signal_step
463
+
464
+
465
+ def simulate(
466
+ sim: Simulation,
467
+ source: Source,
468
+ indicator: cpt.NDArray,
469
+ sensors: cpt.NDArray[cp.int32] | None = None,
470
+ record_every: int | None = None,
471
+ ) -> cpt.NDArray | tuple:
472
+ """Run `sim` forward under `source` and material `indicator`.
473
+
474
+ Args:
475
+ sim: the simulation to step, which fixes the grid and the kernels compiled.
476
+ source: the shot to inject, its signal an (N, num_sources) record.
477
+ indicator: the design field the materials are built from.
478
+ sensors: (ndim, num_sensors) grid indices to record at, or None for no record.
479
+ record_every: snapshot the interior field every this many steps, or None.
480
+
481
+ Returns:
482
+ the final interior field, followed by the (N, num_sensors) record when
483
+ `sensors` is given and the stacked host snapshots when `record_every` is.
484
+ """
485
+ U = cp.zeros((2, *sim.field_shape), dtype=sim.dtype)
486
+ u0, u1 = U[0], U[1]
487
+
488
+ mat = sim.build_materials(indicator)
489
+ kernels = compile_kernels(sim)
490
+ fd_step = define_step_method(sim, kernels, mat)
491
+ bc_step = define_boundary(sim, kernels)
492
+ excitation_step = define_excitation(sim, source.position, kernels, mat)
493
+ if sensors is not None:
494
+ get_signal = define_get_signal(sim, sensors, kernels)
495
+ um = cp.zeros((sim.N, sensors.shape[1]), dtype=sim.dtype)
496
+ interior = (Ellipsis, *(slice(0, n) for n in sim.Nx))
497
+ snapshots = []
498
+
499
+ def field(u):
500
+ return u[interior]
501
+
502
+ for t in range(sim.N):
503
+ u0 = fd_step(u0, u1, u0)
504
+ u0 = excitation_step(u0, source.signal, t)
505
+ u0 = bc_step(u0)
506
+ u1, u0 = u0, u1
507
+ if sensors is not None:
508
+ get_signal(u1, um, t)
509
+ if record_every is not None and t % record_every == 0:
510
+ snapshots.append(field(u1).get())
511
+
512
+ if sensors is not None and record_every is not None:
513
+ return field(u1), um, np.stack(snapshots)
514
+ if sensors is not None:
515
+ return field(u1), um
516
+ if record_every is not None:
517
+ return field(u1), np.stack(snapshots)
518
+ return field(u1)
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: cuwave
3
+ Version: 0.1.0
4
+ Summary: GPU finite-difference wave solver with differentiable adjoints
5
+ Author-email: Leon Herrmann <herrmann.leon@pm.me>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/cmpmech/cuwave
8
+ Project-URL: Documentation, https://github.com/cmpmech/cuwave/blob/main/docs/Home.md
9
+ Project-URL: Changelog, https://github.com/cmpmech/cuwave/releases
10
+ Keywords: wave-equation,finite-difference,adjoint,full-waveform-inversion,topology-optimization,cuda,cupy
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: GPU :: NVIDIA CUDA
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Physics
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: numpy>=1.24
20
+ Provides-Extra: examples
21
+ Requires-Dist: matplotlib>=3.7; extra == "examples"
22
+ Requires-Dist: pillow>=9.1; extra == "examples"
23
+ Provides-Extra: nn
24
+ Requires-Dist: torch>=2.0; extra == "nn"
25
+ Dynamic: license-file
26
+
27
+ # CuWave
28
+
29
+ <picture>
30
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/cmpmech/cuwave/main/.assets/readme-dark.webp">
31
+ <img src="https://raw.githubusercontent.com/cmpmech/cuwave/main/.assets/readme-light.webp" width="100%">
32
+ </picture>
33
+
34
+ **CuWave** is a single-GPU, differentiable finite difference wave propagation code.
35
+ Possible applications include
36
+
37
+ <table width="100%">
38
+ <tr>
39
+ <td valign="middle"><a href="https://www.sciencedirect.com/science/article/pii/S0045782523000166"><strong>nondestructive testing via full waveform inversion</strong></a></td>
40
+ <td width="60%" align="right" valign="middle"><picture>
41
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/cmpmech/cuwave/main/.assets/fwi-dark.png">
42
+ <img width="100%" src="https://raw.githubusercontent.com/cmpmech/cuwave/main/.assets/fwi-light.png" alt="nondestructive testing via full waveform inversion">
43
+ </picture></td>
44
+ </tr>
45
+ <tr>
46
+ <td valign="middle"><a href="https://doi.org/10.1007/s00158-025-04237-y"><strong>transient acoustic topology optimization</strong></a></td>
47
+ <td width="60%" align="center" valign="middle"><picture>
48
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/cmpmech/cuwave/main/.assets/tato-dark.webp">
49
+ <img width="46.512%" src="https://raw.githubusercontent.com/cmpmech/cuwave/main/.assets/tato-light.webp" alt="transient acoustic topology optimization">
50
+ </picture></td>
51
+ </tr>
52
+ <tr>
53
+ <td valign="middle"><a href="https://www.science.org/doi/10.1126/sciadv.aay6946"><strong>analog neural networks</strong></a></td>
54
+ <td width="60%" align="right" valign="middle"></td>
55
+ </tr>
56
+ <tr>
57
+ <td valign="middle"><a href="https://opg.optica.org/josab/fulltext.cfm?uri=josab-38-2-496"><strong>transient photonic topology optimization</strong></a></td>
58
+ <td width="60%" align="right" valign="middle"><picture>
59
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/cmpmech/cuwave/main/.assets/tpto-dark.webp">
60
+ <img width="100%" src="https://raw.githubusercontent.com/cmpmech/cuwave/main/.assets/tpto-light.webp" alt="transient photonic topology optimization">
61
+ </picture></td>
62
+ </tr>
63
+ </table>
64
+
65
+ ## Documentation
66
+
67
+ - see the [documentation](https://github.com/cmpmech/cuwave/blob/main/docs/Home.md) for how the code works (AI-assisted; verify with sources for critical details)
68
+ - see [examples](https://github.com/cmpmech/cuwave/tree/main/examples) for how to apply the code
69
+
70
+ ## Development status
71
+
72
+ **Scalar** & **acoustic wave** equations have been developed over the last 2 years and are thoroughly validated.
73
+
74
+ > [!IMPORTANT]
75
+ > **Elastic** & **electromagnetic wave** equations were developed with AI assistance (Claude) and have undergone less validation. The elastic wave equation is currently being validated against experimental results.
76
+
77
+ ## Performance
78
+
79
+ CuWave's runtime for identical discretizations is comparable to that of other established wave propagation finite difference codes. Speedups in 2D with reflecting boundaries (**a ratio above 1 means CuWave is that many times faster**) compared to the following frameworks:
80
+ - **scalar wave equation**
81
+ - [Deepwave](https://github.com/ar4/deepwave) (forward: ~1.1x, sensitivity: ~1x)
82
+ - [NVIDIA Warp](https://github.com/NVIDIA/warp) (forward: ~2.1x, sensitivity: ~1.8x)
83
+ - [SeismicWaves.jl](https://github.com/GinvLab/SeismicWaves.jl) (forward: ~4.4x, sensitivity: ~4x)
84
+ - [Devito](https://github.com/devitocodes/devito) on CPU (forward: ~3.6x, sensitivity: ~3.8x)
85
+ - **elastic wave equation**
86
+ - Deepwave (forward: ~1.1x, sensitivity: ~1.3x)
87
+ - SeismicWaves.jl (forward: ~4x, sensitivity: ~5x)
88
+ Tested on one NVIDIA RTX PRO 500 Blackwell laptop GPU (6 GB) on the largest possible grids with CuWave's `superposition_sensitivity` as reference for the sensitivities. The specific numbers need to be taken with a grain of salt, as they are subject to specific hardware and simulation setup. All implementations operate on the same order of magnitude.
89
+
90
+ Additional benefits of **CuWave** are
91
+ - the built-in **higher order finite difference** schemes, allowing for fewer grid points
92
+ - a sensitivity analysis whose **memory is independent of the number of timesteps**, allowing for orders of magnitude larger grids
93
+
94
+ ## Install
95
+
96
+ Dependencies are kept **lightweight**. Only CuPy is required beyond standard Python library.
97
+
98
+ ```bash
99
+ pip install cupy-cuda12x # or cupy-cuda11x, to match your CUDA
100
+ pip install cuwave # or `pip install -e .` from a checkout
101
+ ```
102
+
103
+ CuPy must be installed separately because the wheel depends on your CUDA toolkit;
104
+ all remaining dependencies are declared in `pyproject.toml`.
105
+
106
+ PyTorch is optional for the regularization via neural optimization; see [pytorch](https://pytorch.org/get-started/locally/) for the installation. Otherwise it is not needed.
107
+
108
+ > [!NOTE]
109
+ > Match PyTorch's CUDA version to CuPy's, or the two runtimes clash at the first kernel launch.
110
+ > With `cupy-cuda12x`:
111
+ > ```bash
112
+ > pip install torch --index-url https://download.pytorch.org/whl/cu128
113
+ > ```
114
+
115
+ The tests under `tests/` are `unittest` classes, but `pytest` is the recommended runner:
116
+
117
+ ```bash
118
+ pip install pytest
119
+ python -m pytest tests/ -q # ~20 s on a GPU, ~3 s without: CUDA and PyTorch tests skip when unavailable
120
+ ```
121
+
122
+ ## References
123
+
124
+ If you use our code for your scientific research, please acknowledge this by referring to the following publication:
125
+
126
+ _Herrmann, L., Bürchner, T., Kudela, L., Kollmannsberger, S., 2026, **A memory-efficient adjoint method to enable billion parameter optimization on a single GPU in dynamic problems**, Structural and Multidisciplinary Optimization, Volume 69, 52 (2026), DOI: [10.1007/s00158-025-04237-y](https://doi.org/10.1007/s00158-025-04237-y)_
127
+
128
+ ## Contact
129
+
130
+ For questions, bug reports, or collaboration inquiries, please don't hesitate to contact Leon Herrmann at [herrmann.leon@pm.me](mailto:herrmann.leon@pm.me).
131
+
132
+ ## License
133
+
134
+ MIT; see [LICENSE](https://github.com/cmpmech/cuwave/blob/main/LICENSE).
@@ -0,0 +1,32 @@
1
+ cuwave/__init__.py,sha256=PNj76PUFei2Sw1Z4N5-wB9q9Y16sCV4FhbQZARsDQR0,277
2
+ cuwave/anisotropic.py,sha256=wD_JLTbPTAHOMCnowoKdRI9Gcib4F7s_eDlAsj8qIOg,14631
3
+ cuwave/boundary.py,sha256=E-EmhcKuwF0UhGBOoCj_jnpGtQ2QSHm8_1rrkbHEMe4,9930
4
+ cuwave/elastic.py,sha256=POxrD6ru4x3fwr9kWP4Z5GeOYOnWvhHFI49MX2E9BhY,13873
5
+ cuwave/evals.py,sha256=8rJmB4yRZF7FLGMSswBejBHoaj8kEE8TEI1yNqs2ObM,5488
6
+ cuwave/geometry.py,sha256=XvaFawIWwawKkHZF6RPZ1mUaol-9WtYbNfy9AtIxSEQ,7901
7
+ cuwave/maxwell.py,sha256=5vQ4I4unxoRm_-LBj_pH0XAa86PD2D5jVRlVes-AkXo,17812
8
+ cuwave/nn.py,sha256=QM7Q9H3PkLbxF_05vJ6_QcZonCAmf6f9h11cpeZdw58,3834
9
+ cuwave/optimization.py,sha256=3oj-8iULKKZtm39xWkeqnvKsnrRfbhP2ia-8szt98x4,4325
10
+ cuwave/postprocessing.py,sha256=aCByHTlcwZA7EA-rqpSkIuMEYyevHdnQgwCkhvtuu5M,7064
11
+ cuwave/regularization.py,sha256=PbTf2AE0oESnxPNH9GQDLcgAgXhLSE47weuNITM1OBI,10517
12
+ cuwave/scalar.py,sha256=4c-npaeEZw006eVebi20yusjga8Kgs1UGmqr2N73mjE,9418
13
+ cuwave/sensitivity.py,sha256=n_qJgbWXVatM7cWQc20GAKt92EIejC3Vpem0sWhaJhY,22508
14
+ cuwave/signals.py,sha256=CV8QxTpHq4YjCMf47n0ZwAUmqUQBbMj9aPZtuE5FHNw,2196
15
+ cuwave/stencils.py,sha256=0w81sBKi1wNfEi-a_M3me29CG8IBDBnwYjGlylLOxC0,1621
16
+ cuwave/utils.py,sha256=bH3tmEWsfozg00bKh4lvH9pviu09phrBQ7eb7iQlEgU,18588
17
+ cuwave/wave.py,sha256=Yazqg_3_R3_Gl7n51_ZGAxO38fk4dKu6x-tLMnoVPdQ,20273
18
+ cuwave/kernels/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
+ cuwave/kernels/anisotropic.cu,sha256=Arxw_ybY9IHs2snUPXk0HvboRbEin4CJ2xC-zCVGnA4,6478
20
+ cuwave/kernels/anisotropic_sensitivity.cu,sha256=wresk5Qk9A3tKsIO3pC5e_Aru69i_2nl_Gtmowehesk,8162
21
+ cuwave/kernels/common.cuh,sha256=uwHSY6lePaEKVZ2yM-xPboOnjC-mreaEuYyTBW5ESoc,3396
22
+ cuwave/kernels/elastic.cu,sha256=Vt3uC26m_QXp_JU0Hy5tMyRfYgmVWOwzJ9dGXYsTTHU,8890
23
+ cuwave/kernels/elastic_sensitivity.cu,sha256=cA2Av22_gA7ywSQDDsXaplXLle2ZBApHlDevpOXXfRk,8939
24
+ cuwave/kernels/maxwell.cu,sha256=VriqARV3-xnR8jN8roDLt2JCasXZIxnUaiIVlxLINio,6234
25
+ cuwave/kernels/maxwell_sensitivity.cu,sha256=guFrAbPKyjwlgn7dokzu9dd95D7wohKRSt2Moy4LjI4,5544
26
+ cuwave/kernels/scalar.cu,sha256=HEgte8QSHKszwwdXT0-D6-3oF9mZrkVQrNj-d0d_i9E,6052
27
+ cuwave/kernels/scalar_sensitivity.cu,sha256=2LsnA9AjiX8eV066cFQKKti_t7XIZxc8MNBra4flPlY,5984
28
+ cuwave-0.1.0.dist-info/licenses/LICENSE,sha256=Zo_H3adB0WTzDHG7DL0dYMjyXrR8PFzwlcJX1F-IBvc,1070
29
+ cuwave-0.1.0.dist-info/METADATA,sha256=iiTyX7i-sbajNYLAWPi_fmqLgmVP1BKBGLvoKsYQ_ic,7082
30
+ cuwave-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
31
+ cuwave-0.1.0.dist-info/top_level.txt,sha256=HZSy3rgj7_raEX1LvNk5DOFGe6b2MingOIqjwdcc4tM,7
32
+ cuwave-0.1.0.dist-info/RECORD,,