fullwave25 1.4.2.dev1__py3-none-any.whl → 1.4.2.dev3__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.
fullwave/__init__.py CHANGED
@@ -66,7 +66,7 @@ try:
66
66
  __version__ = version("fullwave")
67
67
  except PackageNotFoundError:
68
68
  # Update via bump-my-version, not manually
69
- __version__ = "1.4.2-dev1"
69
+ __version__ = "1.4.2-dev3"
70
70
 
71
71
  VERSION = __version__ # for convenience
72
72
  logger.info("Fullwave version: %s", __version__)
@@ -14,6 +14,13 @@ class Beamformer:
14
14
  For faster implementations, consider using libraries such as
15
15
  mach beamformer: https://github.com/Forest-Neurotech/mach
16
16
 
17
+ Conventions: ``axial_position_m`` is measured from the top of the grid
18
+ (row 0), not from the array. ``transducer_coordinates`` must be the
19
+ receiver positions in grid points (for example from
20
+ ``Transducer.element_id_to_receiver_center``). The time origin is the
21
+ mean receiver row, so the array depth is subtracted from the two-way
22
+ travel time.
23
+
17
24
  References
18
25
  ----------
19
26
  - Fullwave2 BMME890 implementation
@@ -46,11 +53,11 @@ class Beamformer:
46
53
  lateral_position_m : np.ndarray
47
54
  Lateral positions (m) where the beamformed image will be computed.
48
55
  axial_position_m : np.ndarray
49
- Axial positions (m) where the beamformed image will be computed.
56
+ Axial positions (m) from the top of the grid where the image is computed.
50
57
  num_elements : int
51
58
  Number of transducer elements.
52
59
  transducer_coordinates : np.ndarray
53
- Coordinates of the transducer elements (m).
60
+ Receiver coordinates [n_elements, 2] in grid points (row, column).
54
61
  f_number : float, optional
55
62
  F-number for aperture calculation, by default 1.0.
56
63
 
@@ -83,6 +90,8 @@ class Beamformer:
83
90
  if signals.ndim == 2:
84
91
  signals = signals[:, :, np.newaxis]
85
92
  n_transmit = signals.shape[2]
93
+ # idt_0 is the pulse arrival at the receivers, so time is measured from the array.
94
+ z_array = np.mean(self.transducer_coordinates[:, 0]) * self.dx
86
95
 
87
96
  hilbert_signals = hilbert(signals, axis=1)
88
97
  beamformed_image = np.zeros(
@@ -133,7 +142,7 @@ class Beamformer:
133
142
  dd = np.round(dd / (self.dt / self.dx * self.c0)).astype(int)
134
143
  dd = dd - dd.min()
135
144
 
136
- idt = idt_0 + int(2 * axial / self.c0 / self.dt)
145
+ idt = idt_0 + int(2 * (axial - z_array) / self.c0 / self.dt)
137
146
 
138
147
  # idp: 1D indices into signals flattened array
139
148
  # we convert 2D indices to 1D indices
fullwave/solver/solver.py CHANGED
@@ -1,11 +1,8 @@
1
1
  """solver module."""
2
2
 
3
3
  import contextlib
4
- import gc
5
4
  import logging
6
5
  import os
7
- import shutil
8
- import subprocess
9
6
  import time
10
7
  import warnings
11
8
  from collections.abc import Iterator, Sequence
@@ -30,6 +27,11 @@ from fullwave.utils import (
30
27
  check_functions,
31
28
  run_directory_in_memory,
32
29
  )
30
+ from fullwave.utils.gpu_memory import (
31
+ free_gpu_memory_bytes,
32
+ members_that_fit,
33
+ release_gpu_memory_pools,
34
+ )
33
35
  from fullwave.utils.signal_filter import apply_filter
34
36
 
35
37
  from .binary_manager import ensure_binary
@@ -74,48 +76,6 @@ def _split_evenly(members: list, largest: int) -> list[list]:
74
76
  return runs
75
77
 
76
78
 
77
- def _free_gpu_memory_bytes(device_ids: Sequence[str]) -> dict[str, int] | None:
78
- """Return the free memory of each GPU in bytes, as nvidia-smi reports it.
79
-
80
- Parameters
81
- ----------
82
- device_ids : Sequence[str]
83
- The GPU indices, as nvidia-smi counts them.
84
-
85
- Returns
86
- -------
87
- dict[str, int] | None
88
- The free bytes of each GPU, or None when nvidia-smi cannot be run or does not
89
- report every GPU.
90
-
91
- """
92
- wanted = [device.strip() for device in device_ids]
93
- nvidia_smi_path = shutil.which("nvidia-smi")
94
- if nvidia_smi_path is None:
95
- return None
96
- try:
97
- found = subprocess.run( # noqa: S603
98
- [nvidia_smi_path, "--query-gpu=index,memory.free", "--format=csv,noheader,nounits"],
99
- capture_output=True,
100
- text=True,
101
- check=True,
102
- timeout=30,
103
- ).stdout
104
- except (OSError, subprocess.SubprocessError):
105
- return None
106
- free = {}
107
- for line in found.splitlines():
108
- index, _, mebibytes = line.partition(",")
109
- if index.strip() in wanted:
110
- try:
111
- free[index.strip()] = int(float(mebibytes)) * 1024**2
112
- except ValueError:
113
- return None
114
- if set(free) != set(wanted):
115
- return None
116
- return free
117
-
118
-
119
79
  COMPATIBLE_CUDA_ARCHITECTURES = [
120
80
  "sm_50", # Maxwell: GTX 750, GTX 750 Ti
121
81
  "sm_52", # Maxwell: GTX 980, GTX 970
@@ -513,6 +473,7 @@ class Solver:
513
473
  source_type: str = HARD_RELEASED_AFTER,
514
474
  write_maps_the_solver_does_not_read: bool = False,
515
475
  use_ultra: bool = False,
476
+ ultra_sponge_thickness_px: int | None = None,
516
477
  ) -> None:
517
478
  """Initialize a Solver instance for the fullwave simulation.
518
479
 
@@ -648,11 +609,17 @@ class Solver:
648
609
  Experimental. Run a user-supplied fullwave2-ultra binary (not shipped; pass it
649
610
  as ``path_fullwave_simulation_bin`` or set ``FW2U_BATCH_BIN``): ``bench_2d_batch``
650
611
  for a 2D grid, ``bench_3d_opt`` for a 3D grid.
651
- ``exponential_attenuation_pml_thickness_px`` sets the sponge width in cells.
652
- A source with ``p0_additive`` and ``source_type="soft"`` are added to the pressure
653
- through the binary's additive channel, which needs a binary that reads
654
- ``ncoords_add.dat`` (and ``icmat_add.dat`` on a 3D grid). Unsupported options raise
655
- ``UltraUnsupportedError``.
612
+ The backend runs the exponential attenuation model (frequency-independent:
613
+ the same attenuation at every frequency). A source with ``p0_additive`` and
614
+ ``source_type="soft"`` are added to the pressure through the binary's additive
615
+ channel, which needs a binary that reads ``ncoords_add.dat`` (and ``icmat_add.dat``
616
+ on a 3D grid). Options that only tune the native solver are ignored with a
617
+ warning; the README lists them.
618
+ Other unsupported options raise ``UltraUnsupportedError``.
619
+ ultra_sponge_thickness_px : int, optional
620
+ Width of the absorbing sponge of the ``use_ultra`` backend, in cells.
621
+ None, the default, gives 6 wavelengths in 2D and 4 wavelengths in 3D.
622
+ Used only with ``use_ultra=True``.
656
623
 
657
624
  Raises
658
625
  ------
@@ -676,21 +643,32 @@ class Solver:
676
643
  run_on_memory=run_on_memory,
677
644
  cuda_device_id=cuda_device_id,
678
645
  verify_gpu=verify_gpu,
679
- nbdy=exponential_attenuation_pml_thickness_px,
646
+ nbdy=ultra_sponge_thickness_px,
680
647
  options={
681
648
  "use_pml": use_pml,
682
649
  "m_spatial_order": m_spatial_order,
683
- "pml_layer_thickness_px": pml_layer_thickness_px,
684
- "n_transition_layer": n_transition_layer,
685
650
  "use_gpu": use_gpu,
686
- "use_gpu_pml": use_gpu_pml,
687
- "pml_alpha_entrance": pml_alpha_entrance,
688
651
  "source_type": source_type,
689
- "save_gpu_memory": save_gpu_memory,
690
- "write_maps_the_solver_does_not_read": write_maps_the_solver_does_not_read,
652
+ "use_exponential_attenuation": use_exponential_attenuation,
653
+ "native_only_options": {
654
+ "pml_layer_thickness_px": pml_layer_thickness_px,
655
+ "n_transition_layer": n_transition_layer,
656
+ "pml_alpha_entrance": pml_alpha_entrance,
657
+ "exponential_attenuation_pml_thickness_px": (
658
+ exponential_attenuation_pml_thickness_px
659
+ ),
660
+ "use_gpu_pml": use_gpu_pml,
661
+ "save_gpu_memory": save_gpu_memory,
662
+ "write_maps_the_solver_does_not_read": (
663
+ write_maps_the_solver_does_not_read
664
+ ),
665
+ "use_isotropic_relaxation": use_isotropic_relaxation,
666
+ },
691
667
  },
692
668
  )
693
669
  return
670
+ if ultra_sponge_thickness_px is not None:
671
+ backend.warn_sponge_without_ultra()
694
672
  if exponential_attenuation_pml_thickness_px is not None and not use_exponential_attenuation:
695
673
  error_msg = (
696
674
  "exponential_attenuation_pml_thickness_px belongs to the exponential "
@@ -968,31 +946,6 @@ class Solver:
968
946
  )
969
947
  logger.warning(message)
970
948
 
971
- @staticmethod
972
- def _release_gpu_memory_pools() -> None:
973
- """Release all CuPy GPU memory pool blocks back to CUDA.
974
-
975
- Call ``gc.collect()`` first so that Python releases references to
976
- CuPy arrays, then drain both the device and pinned memory pools.
977
- This prevents stale allocations from causing memory pressure when
978
- subsequent operations allocate large GPU arrays.
979
-
980
- The release is best effort. It does nothing when CuPy is absent, and
981
- it does nothing when CuPy is present without a usable CUDA driver,
982
- which is the case on a machine with no GPU.
983
- """
984
- gc.collect()
985
- try:
986
- import cupy as cp # noqa: PLC0415
987
- except ImportError:
988
- return
989
-
990
- try:
991
- cp.get_default_memory_pool().free_all_blocks()
992
- cp.get_default_pinned_memory_pool().free_all_blocks()
993
- except cp.cuda.runtime.CUDARuntimeError as error:
994
- logger.debug("GPU memory pools were not released: %s", error)
995
-
996
949
  @staticmethod
997
950
  def _check_input(
998
951
  grid: fullwave.Grid,
@@ -1420,10 +1373,10 @@ class Solver:
1420
1373
  "Input data generation completed in %s. Skipping simulation execution.",
1421
1374
  simulation_dir,
1422
1375
  )
1423
- self._release_gpu_memory_pools()
1376
+ release_gpu_memory_pools()
1424
1377
  return simulation_dir
1425
1378
 
1426
- self._release_gpu_memory_pools()
1379
+ release_gpu_memory_pools()
1427
1380
 
1428
1381
  if members is not None:
1429
1382
  return self._run_members(
@@ -1933,9 +1886,8 @@ class Solver:
1933
1886
  def _members_that_fit(self, sensor: fullwave.Sensor) -> int | None:
1934
1887
  """Return how many transmits the GPU memory estimate fits into the free memory.
1935
1888
 
1936
- The estimate of one GPU grows by the same amount for each transmit. The count
1937
- keeps 10 % of the free memory and 512 MiB free for the CUDA context and for
1938
- what the estimate does not count, and takes the smallest count over the GPUs.
1889
+ The estimate of one GPU grows by the same amount for each transmit. The count is the
1890
+ smallest over the GPUs.
1939
1891
 
1940
1892
  Parameters
1941
1893
  ----------
@@ -1948,18 +1900,21 @@ class Solver:
1948
1900
  The count, or None when the free memory cannot be read.
1949
1901
 
1950
1902
  """
1951
- free = _free_gpu_memory_bytes(self.fullwave_launcher.cuda_device_id.split(","))
1903
+ free = free_gpu_memory_bytes(self.fullwave_launcher.cuda_device_id.split(","))
1952
1904
  if free is None:
1953
1905
  return None
1954
1906
  one = self._gpu_memory_bytes(sensor, n_members=1)
1955
1907
  two = self._gpu_memory_bytes(sensor, n_members=2)
1956
- reserve = 512 * 1024**2
1957
1908
  counts = []
1958
1909
  for (device, one_bytes, *_), (_, two_bytes, *_) in zip(one, two, strict=True):
1959
- per_member = max(two_bytes - one_bytes, 1)
1960
- shared = one_bytes - per_member
1961
- usable = 0.9 * free.get(device, 0) - reserve - shared
1962
- counts.append(int(usable // per_member))
1910
+ per_member_bytes = max(two_bytes - one_bytes, 1)
1911
+ counts.append(
1912
+ members_that_fit(
1913
+ free_bytes=free.get(device, 0),
1914
+ shared_bytes=one_bytes - per_member_bytes,
1915
+ per_member_bytes=per_member_bytes,
1916
+ )
1917
+ )
1963
1918
  return min(counts)
1964
1919
 
1965
1920
  def _estimate_gpu_memory(
fullwave/transducer.py CHANGED
@@ -352,6 +352,9 @@ class TransducerGeometry:
352
352
  ) -> tuple[NDArray[np.int64], NDArray[np.int64], NDArray[np.int64], NDArray[np.int64]]:
353
353
  """Build flat coordinate arrays for source and sensor pixels.
354
354
 
355
+ For a flat array the sensor row is the first row in front of the element
356
+ (``position_px[0] + element_layer_px``), outside the source cells.
357
+
355
358
  Returns
356
359
  -------
357
360
  source_coords : NDArray[np.int64], shape [N_src, ndim]
@@ -404,7 +407,7 @@ class TransducerGeometry:
404
407
  source_ids_parts.append(np.full(len(src), element_index + 1))
405
408
 
406
409
  # Single center pixel for sensor output
407
- sx = element_pos_x + self.element_layer_px - 1
410
+ sx = self._flat_sensor_row(element_pos_x=element_pos_x)
408
411
  sy = element_pos_y + self.element_width_px - 1
409
412
  sz = element_pos_z + self.element_height_px // 2 - 1
410
413
  sensor_coords_parts.append(np.array([[sx, sy, sz]]))
@@ -432,7 +435,7 @@ class TransducerGeometry:
432
435
  source_coords_parts.append(src)
433
436
  source_ids_parts.append(np.full(len(src), element_index + 1))
434
437
 
435
- sx = element_pos_x + self.element_layer_px - 1
438
+ sx = self._flat_sensor_row(element_pos_x=element_pos_x)
436
439
  if self.average_surface_signals:
437
440
  out_ys = np.arange(element_pos_y, element_pos_y + self.element_width_px)
438
441
  out_xs = np.full(len(out_ys), sx)
@@ -452,6 +455,39 @@ class TransducerGeometry:
452
455
  sensor_ids = np.concatenate(sensor_ids_parts, axis=0).astype(np.int64)
453
456
  return source_coords, source_ids, sensor_coords, sensor_ids
454
457
 
458
+ def _flat_sensor_row(self, *, element_pos_x: int) -> int:
459
+ """Return the sensor row of a flat-array element.
460
+
461
+ The row lies in front of the source cells because a hard source keeps a
462
+ static pressure after the drive, which a receiver inside it would record.
463
+
464
+ Parameters
465
+ ----------
466
+ element_pos_x : int
467
+ First (back) source row of the element.
468
+
469
+ Returns
470
+ -------
471
+ int
472
+ The sensor row, ``element_pos_x + element_layer_px``.
473
+
474
+ Raises
475
+ ------
476
+ ValueError
477
+ If the sensor row is outside the grid.
478
+
479
+ """
480
+ sensor_row = int(element_pos_x + self.element_layer_px)
481
+ if sensor_row >= self.stored_grid_size[0]:
482
+ error_msg = (
483
+ f"The sensor row {sensor_row} (one row in front of the element) is "
484
+ f"outside the grid, which has {self.stored_grid_size[0]} rows in x. "
485
+ "Move the transducer back or enlarge the grid."
486
+ )
487
+ logger.error(error_msg)
488
+ raise ValueError(error_msg)
489
+ return sensor_row
490
+
455
491
  def _arc_element_coords(
456
492
  self,
457
493
  ) -> tuple[NDArray[np.int64], NDArray[np.int64], NDArray[np.int64], NDArray[np.int64]]:
@@ -1728,6 +1764,18 @@ class Transducer:
1728
1764
  out[eid] = np.rint(coords[sel].mean(axis=0)).astype(np.int64)
1729
1765
  return out
1730
1766
 
1767
+ @property
1768
+ def element_id_to_receiver_center(self) -> dict[int, NDArray[np.int64]]:
1769
+ """Return the mapping from element IDs to the mean receiver coordinate in grid points."""
1770
+ ndim = 3 if self.is_3d else 2
1771
+ out: dict[int, NDArray[np.int64]] = {}
1772
+ for eid, coords in self.element_id_to_element_surface.items():
1773
+ if len(coords) == 0:
1774
+ out[eid] = np.full(ndim, -1, dtype=np.int64)
1775
+ else:
1776
+ out[eid] = np.rint(coords.mean(axis=0)).astype(np.int64)
1777
+ return out
1778
+
1731
1779
  @property
1732
1780
  def sensor(self) -> fullwave.sensor.Sensor:
1733
1781
  """Return the Sensor object with active sensor coordinates.
fullwave/ultra/backend.py CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import inspect
5
6
  import time
6
7
  import warnings
7
8
  from pathlib import Path
8
- from typing import TYPE_CHECKING
9
+ from typing import TYPE_CHECKING, NamedTuple
9
10
 
10
11
  import numpy as np
11
12
 
@@ -36,15 +37,43 @@ if TYPE_CHECKING:
36
37
  CFL_LIMIT_3D = 0.30
37
38
  SPEED_OPTIMIZED_PPW = 6
38
39
 
40
+ PACKAGE_DIR = Path(__file__).parents[1]
41
+
39
42
  _shown_warnings: set[str] = set()
40
43
 
41
44
 
45
+ def _stacklevel_of_first_caller_outside_package() -> int:
46
+ """Return the ``warnings.warn`` stacklevel of the first frame outside the fullwave package."""
47
+ frame = inspect.currentframe()
48
+ stacklevel = 0
49
+ while frame is not None and Path(frame.f_code.co_filename).is_relative_to(PACKAGE_DIR):
50
+ frame = frame.f_back
51
+ stacklevel += 1
52
+ return stacklevel
53
+
54
+
42
55
  def _warn_once(*, name: str, message: str, category: type[Warning]) -> None:
43
- """Warn with ``message`` the first time ``name`` is seen in this process."""
56
+ """Warn with ``message`` the first time ``name`` is seen in this process.
57
+
58
+ The warning points at the line of the user's code, however deep the call is.
59
+ """
44
60
  if name in _shown_warnings:
45
61
  return
46
62
  _shown_warnings.add(name)
47
- warnings.warn(message, category, stacklevel=5)
63
+ warnings.warn(message, category, stacklevel=_stacklevel_of_first_caller_outside_package())
64
+
65
+
66
+ def warn_sponge_without_ultra() -> None:
67
+ """Warn once per process that ``ultra_sponge_thickness_px`` needs ``use_ultra=True``."""
68
+ _warn_once(
69
+ name="sponge_without_ultra",
70
+ message=(
71
+ "ultra_sponge_thickness_px is ignored without use_ultra=True. The native "
72
+ "absorber is sized by pml_layer_thickness_px (relaxation model) or "
73
+ "exponential_attenuation_pml_thickness_px (exponential model)."
74
+ ),
75
+ category=UserWarning,
76
+ )
48
77
 
49
78
 
50
79
  def warn_ppw_above_speed_optimum(*, ppw: int) -> None:
@@ -72,6 +101,25 @@ def warn_experimental() -> None:
72
101
  )
73
102
 
74
103
 
104
+ def warn_ignored(*, option: str, detail: str = "") -> None:
105
+ """Warn once per process that ``use_ultra=True`` ignores ``option``."""
106
+ suffix = f"; {detail}" if detail else ""
107
+ _warn_once(
108
+ name=f"ignored:{option}",
109
+ message=f"{option} is ignored with use_ultra=True{suffix}.",
110
+ category=UserWarning,
111
+ )
112
+
113
+
114
+ def warn_one_device_only(*, device: int) -> None:
115
+ """Warn once per process that ``use_ultra=True`` runs on one GPU, the first one listed."""
116
+ _warn_once(
117
+ name="one_device_only",
118
+ message=f"cuda_device_id lists several GPUs; use_ultra=True runs on GPU {device} only.",
119
+ category=UserWarning,
120
+ )
121
+
122
+
75
123
  def refuse(message: str) -> None:
76
124
  """Raise ``UltraUnsupportedError`` with ``message``."""
77
125
  raise UltraUnsupportedError(message)
@@ -122,9 +170,12 @@ def single_device(cuda_device_id: str | int | list | None) -> int:
122
170
  devices = list(cuda_device_id)
123
171
  else:
124
172
  devices = [cuda_device_id]
125
- if len(devices) != 1:
126
- refuse("use_ultra=True runs on one GPU; give one cuda_device_id")
127
- return int(devices[0])
173
+ if not devices:
174
+ refuse("cuda_device_id lists no device")
175
+ device = int(devices[0])
176
+ if len(devices) > 1:
177
+ warn_one_device_only(device=device)
178
+ return device
128
179
 
129
180
 
130
181
  def work_directory(
@@ -377,39 +428,70 @@ class UltraBackend3D:
377
428
  )
378
429
 
379
430
 
431
+ SPONGE_HINT = "set the sponge width with ultra_sponge_thickness_px"
432
+
433
+
434
+ class NativeOnlyOption(NamedTuple):
435
+ """An option of the native solver that ``use_ultra=True`` ignores."""
436
+
437
+ solver_default: object
438
+ hint: str = ""
439
+
440
+
441
+ NATIVE_ONLY_OPTIONS: dict[str, NativeOnlyOption] = {
442
+ "pml_layer_thickness_px": NativeOnlyOption(solver_default=None, hint=SPONGE_HINT),
443
+ "n_transition_layer": NativeOnlyOption(solver_default=None, hint=SPONGE_HINT),
444
+ "pml_alpha_entrance": NativeOnlyOption(solver_default=None, hint=SPONGE_HINT),
445
+ "exponential_attenuation_pml_thickness_px": NativeOnlyOption(
446
+ solver_default=None, hint=SPONGE_HINT
447
+ ),
448
+ "use_gpu_pml": NativeOnlyOption(solver_default=False),
449
+ "save_gpu_memory": NativeOnlyOption(solver_default=False),
450
+ "write_maps_the_solver_does_not_read": NativeOnlyOption(solver_default=False),
451
+ "use_isotropic_relaxation": NativeOnlyOption(solver_default=True),
452
+ }
453
+
454
+
455
+ def warn_native_only_options(*, options: dict[str, object]) -> None:
456
+ """Warn once per process for each native-only option set to a non-default value."""
457
+ for name, option in NATIVE_ONLY_OPTIONS.items():
458
+ if options[name] != option.solver_default:
459
+ warn_ignored(option=name, detail=option.hint)
460
+
461
+
462
+ def warn_relaxation_model_requested(*, use_exponential_attenuation: bool) -> None:
463
+ """Warn once per process when the relaxation model is requested, as the native default does."""
464
+ if use_exponential_attenuation:
465
+ return
466
+ _warn_once(
467
+ name="attenuation_model",
468
+ message=(
469
+ "use_ultra=True runs the exponential attenuation model, whose attenuation is "
470
+ "frequency-independent (the same at every frequency), not the relaxation model that "
471
+ "use_exponential_attenuation=False requests, whose attenuation is "
472
+ "frequency-dependent."
473
+ ),
474
+ category=UserWarning,
475
+ )
476
+
477
+
380
478
  def check_solver_options(
381
479
  *,
382
480
  use_pml: bool,
383
481
  m_spatial_order: int,
384
- pml_layer_thickness_px: int | None,
385
- n_transition_layer: int | None,
386
482
  use_gpu: bool,
387
- use_gpu_pml: bool,
388
- pml_alpha_entrance: float | None,
389
483
  source_type: str,
390
- save_gpu_memory: bool,
391
- write_maps_the_solver_does_not_read: bool,
484
+ native_only_options: dict[str, object],
485
+ use_exponential_attenuation: bool,
392
486
  ) -> None:
393
- """Refuse the ``Solver`` options that ``use_ultra=True`` cannot honor."""
487
+ """Refuse the ``Solver`` options that ``use_ultra=True`` cannot honor; warn on the rest."""
394
488
  if not use_pml:
395
489
  refuse("use_pml=False is not supported by use_ultra=True")
396
- if (
397
- pml_layer_thickness_px is not None
398
- or n_transition_layer is not None
399
- or use_gpu_pml
400
- or pml_alpha_entrance is not None
401
- ):
402
- refuse(
403
- "PML settings do not apply to use_ultra=True; "
404
- "set the sponge with exponential_attenuation_pml_thickness_px"
405
- )
406
490
  if m_spatial_order != 8:
407
491
  refuse("use_ultra=True supports m_spatial_order=8 only")
408
492
  if not use_gpu:
409
493
  refuse("use_ultra=True needs the GPU")
410
494
  if source_type not in SOURCE_TYPES:
411
495
  refuse(f"source_type must be one of {SOURCE_TYPES}, not {source_type!r}")
412
- if save_gpu_memory:
413
- refuse("save_gpu_memory is not supported by use_ultra=True")
414
- if write_maps_the_solver_does_not_read:
415
- refuse("write_maps_the_solver_does_not_read does not apply to use_ultra=True")
496
+ warn_native_only_options(options=native_only_options)
497
+ warn_relaxation_model_requested(use_exponential_attenuation=use_exponential_attenuation)
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import logging
5
6
  import shutil
6
7
  import time
7
8
  from pathlib import Path
@@ -11,6 +12,11 @@ import numpy as np
11
12
 
12
13
  from fullwave.solver.binary_markers import check_binary_holds_source
13
14
  from fullwave.solver.source_type import HARD_RELEASED_AFTER, is_hard_held
15
+ from fullwave.utils.gpu_memory import (
16
+ free_gpu_memory_bytes,
17
+ members_that_fit,
18
+ release_gpu_memory_pools,
19
+ )
14
20
 
15
21
  from . import binary as binary_module
16
22
  from . import dat_io
@@ -23,9 +29,10 @@ from .backend import (
23
29
  to_host,
24
30
  work_directory,
25
31
  )
26
- from .errors import UltraUnsupportedError
32
+ from .binary import OUTPUT_TAIL_CHARS
33
+ from .errors import UltraBinaryError, UltraUnsupportedError
27
34
  from .time_axis import check_time_axis, delay_additive_signal
28
- from .writer_2d import write_run_directory_2d, write_sources_2d
35
+ from .writer_2d import extension_cells, write_run_directory_2d, write_sources_2d
29
36
 
30
37
  if TYPE_CHECKING:
31
38
  from collections.abc import Sequence
@@ -34,20 +41,125 @@ if TYPE_CHECKING:
34
41
 
35
42
  import fullwave
36
43
 
44
+ logger = logging.getLogger("__main__." + __name__)
45
+
46
+ FLOAT_BYTES = 4
47
+ BYTES_PER_CELL_PER_MEMBER = 6 * FLOAT_BYTES # pressure and two velocities at two time levels
48
+ BYTES_PER_CELL_SHARED = 172 # material maps, sponge coefficients and sponge memory variables
49
+ BYTES_PER_AXIS_CELL = 2 * FLOAT_BYTES
50
+ BYTES_PER_COORDINATE = 2 * np.dtype(np.int32).itemsize
51
+ STENCIL_TABLE_BYTES = 9 * 2 * FLOAT_BYTES
52
+
53
+
54
+ def device_bytes_2d(
55
+ *,
56
+ n_x: int,
57
+ n_y: int,
58
+ n_t: int,
59
+ mod_t: int,
60
+ ncoords: int,
61
+ n_tic: int,
62
+ ncoordsout: int,
63
+ ncoords_add: int = 0,
64
+ n_tic_add: int = 0,
65
+ ncoordszero: int = 0,
66
+ ) -> tuple[int, int]:
67
+ """Return the GPU memory of a 2D batch, in bytes, without the CUDA context.
68
+
69
+ Parameters
70
+ ----------
71
+ n_x, n_y : int
72
+ Cells of the extended grid, which includes the sponge and the stencil margin.
73
+ n_t, mod_t : int
74
+ Number of time steps and sensor decimation in time.
75
+ ncoords, n_tic : int
76
+ Hard source cells and source length in steps.
77
+ ncoordsout : int
78
+ Sensor points.
79
+ ncoords_add, n_tic_add : int
80
+ Additive source cells and source length in steps.
81
+ ncoordszero : int
82
+ Cells held at zero pressure.
83
+
84
+ Returns
85
+ -------
86
+ tuple[int, int]
87
+ The bytes shared by all members and the bytes added by each member.
88
+ """
89
+ cells = n_x * n_y
90
+ frames = (n_t - 1) // mod_t
91
+ n_coordinates = ncoords + ncoordszero + ncoordsout + ncoords_add
92
+ per_member = (
93
+ BYTES_PER_CELL_PER_MEMBER * cells
94
+ + FLOAT_BYTES * ncoords * n_tic
95
+ + FLOAT_BYTES * ncoords_add * n_tic_add
96
+ + FLOAT_BYTES * frames * ncoordsout
97
+ )
98
+ shared = (
99
+ BYTES_PER_CELL_SHARED * cells
100
+ + BYTES_PER_AXIS_CELL * (n_x + n_y)
101
+ + BYTES_PER_COORDINATE * n_coordinates
102
+ + FLOAT_BYTES * ncoordsout
103
+ + STENCIL_TABLE_BYTES
104
+ )
105
+ return shared, per_member
106
+
37
107
 
38
108
  def _members_per_run(n_members: int, max_members_per_run: int | None) -> list[range]:
39
109
  """Split the members into the fewest runs of at most ``max_members_per_run``."""
40
110
  if max_members_per_run is None:
41
111
  return [range(n_members)]
42
- if max_members_per_run < 1:
43
- msg = f"max_members_per_run must be 1 or more, not {max_members_per_run}"
44
- raise ValueError(msg)
45
112
  n_runs = -(-n_members // max_members_per_run)
46
113
  return [
47
114
  range(int(part[0]), int(part[-1]) + 1) for part in np.array_split(range(n_members), n_runs)
48
115
  ]
49
116
 
50
117
 
118
+ def check_genout_files(
119
+ genout_paths: Sequence[Path], *, ncoordsout: int, rundir: Path, stdout: str
120
+ ) -> None:
121
+ """Check that every output file of a run is complete.
122
+
123
+ Parameters
124
+ ----------
125
+ genout_paths : sequence of Path
126
+ The output file of each batch member.
127
+ ncoordsout : int
128
+ Number of sensor points per frame.
129
+ rundir : Path
130
+ The run directory, named in the error message.
131
+ stdout : str
132
+ Standard output of the binary, of which the end goes into the error message.
133
+
134
+ Raises
135
+ ------
136
+ UltraBinaryError
137
+ If a file is missing or empty, does not hold whole ``float32`` frames, or differs in
138
+ size from the other files of the run.
139
+ """
140
+ frame_bytes = FLOAT_BYTES * ncoordsout
141
+ sizes = {path.name: path.stat().st_size if path.is_file() else None for path in genout_paths}
142
+ common_size = max((size for size in sizes.values() if size), default=0)
143
+ incomplete = [
144
+ name
145
+ for name, size in sizes.items()
146
+ if not size or size % frame_bytes != 0 or size != common_size
147
+ ]
148
+ if not incomplete:
149
+ return
150
+ descriptions = [
151
+ f"{name} (missing)" if sizes[name] is None else f"{name} ({sizes[name]} bytes)"
152
+ for name in incomplete
153
+ ]
154
+ msg = (
155
+ "The fullwave2-ultra binary exited with code 0 but its output is incomplete: "
156
+ f"{', '.join(descriptions)}. "
157
+ f"Each output file must be non-empty, a multiple of {frame_bytes} bytes and of equal size. "
158
+ f"Run directory: {rundir}. Last output of the binary:\n{stdout[-OUTPUT_TAIL_CHARS:]}"
159
+ )
160
+ raise UltraBinaryError(msg)
161
+
162
+
51
163
  class UltraBackend2D:
52
164
  """Write, run and read 2D fullwave2-ultra simulations of one medium and several transmits.
53
165
 
@@ -66,7 +178,7 @@ class UltraBackend2D:
66
178
  cuda_device_id : str | int | list | None
67
179
  One CUDA device.
68
180
  verify_gpu : bool
69
- Check that the device is visible before the launch.
181
+ Check that the device is visible before the run.
70
182
  source_type : str
71
183
  ``"hard"`` sets the pressure at the source cells for the whole run.
72
184
  ``"hard_released_after"`` sets it while the signal plays, then lets the cells evolve
@@ -228,7 +340,10 @@ class UltraBackend2D:
228
340
  highpass_cutoff_mhz: float | None = None,
229
341
  bandpass_cutoff_mhz: tuple[float, float] | None = None,
230
342
  ) -> NDArray | list[Path]:
231
- """Run the transmits, split into runs of at most ``max_members_per_run`` (None: one run).
343
+ """Run the transmits in runs of at most ``max_members_per_run`` (None: one run).
344
+
345
+ The free GPU memory can lower the limit, with a warning. The traces do not depend on
346
+ the split.
232
347
 
233
348
  Returns
234
349
  -------
@@ -272,6 +387,9 @@ class UltraBackend2D:
272
387
  if len(sources) == 0:
273
388
  msg = "run_batch needs at least one source."
274
389
  raise ValueError(msg)
390
+ if max_members_per_run is not None and max_members_per_run < 1:
391
+ msg = f"max_members_per_run must be 1 or more, not {max_members_per_run}"
392
+ raise ValueError(msg)
275
393
  n_t = check_time_axis(self.grid)
276
394
  transmits = [self._check_member(source, index=i) for i, source in enumerate(sources)]
277
395
  signals = [hard for hard, _ in transmits]
@@ -284,7 +402,19 @@ class UltraBackend2D:
284
402
  msg = f"the source has {n_steps} steps, more than the {n_t} of the simulation"
285
403
  raise ValueError(msg)
286
404
 
287
- runs = _members_per_run(len(signals), max_members_per_run)
405
+ has_additive = self.source.n_sources_add > 0
406
+ if generate_input_only:
407
+ members_per_run_limit = max_members_per_run
408
+ else:
409
+ release_gpu_memory_pools()
410
+ members_per_run_limit = self._members_per_run_limit(
411
+ n_members=len(signals),
412
+ max_members_per_run=max_members_per_run,
413
+ n_t=n_t,
414
+ n_tic=signals[0].shape[1],
415
+ n_tic_add=additive_signals[0].shape[1] if has_additive else 0,
416
+ )
417
+ runs = _members_per_run(len(signals), members_per_run_limit)
288
418
  outputs = []
289
419
  for index, members in enumerate(runs):
290
420
  name = simulation_dir_name if len(runs) == 1 else f"{simulation_dir_name}_{index}"
@@ -292,7 +422,7 @@ class UltraBackend2D:
292
422
  self._run_one(
293
423
  name,
294
424
  [signals[m] for m in members],
295
- [additive_signals[m] for m in members] if self.source.n_sources_add else None,
425
+ [additive_signals[m] for m in members] if has_additive else None,
296
426
  n_t=n_t,
297
427
  load_results=load_results,
298
428
  generate_input_only=generate_input_only,
@@ -304,6 +434,67 @@ class UltraBackend2D:
304
434
  return np.concatenate(outputs, axis=0)
305
435
  return [path for run_paths in outputs for path in run_paths]
306
436
 
437
+ def _members_per_run_limit(
438
+ self,
439
+ *,
440
+ n_members: int,
441
+ max_members_per_run: int | None,
442
+ n_t: int,
443
+ n_tic: int,
444
+ n_tic_add: int,
445
+ ) -> int | None:
446
+ """Return the most members one run may hold, or None when nothing limits it.
447
+
448
+ A run that does not fit into the free GPU memory ends with empty output files.
449
+ """
450
+ wanted = n_members if max_members_per_run is None else min(max_members_per_run, n_members)
451
+ free = free_gpu_memory_bytes([str(self.device)])
452
+ if free is None:
453
+ logger.warning(
454
+ "The free GPU memory is unknown, so the %d transmits per run are not checked "
455
+ "against it.",
456
+ wanted,
457
+ )
458
+ return max_members_per_run
459
+ shared_bytes, per_member_bytes = self._member_bytes(
460
+ n_t=n_t, n_tic=n_tic, n_tic_add=n_tic_add
461
+ )
462
+ fit = max(
463
+ members_that_fit(
464
+ free_bytes=free[str(self.device)],
465
+ shared_bytes=shared_bytes,
466
+ per_member_bytes=per_member_bytes,
467
+ ),
468
+ 1,
469
+ )
470
+ if fit < wanted:
471
+ logger.warning(
472
+ "The free GPU memory fits %d transmits per run, not %d. "
473
+ "The %d transmits run in runs of at most %d.",
474
+ fit,
475
+ wanted,
476
+ n_members,
477
+ fit,
478
+ )
479
+ return min(wanted, fit)
480
+
481
+ def _member_bytes(self, *, n_t: int, n_tic: int, n_tic_add: int) -> tuple[int, int]:
482
+ """Return the GPU bytes shared by all members and the bytes added by each member."""
483
+ _, margin = extension_cells(ppw=self.grid.ppw, nbdy=self.nbdy)
484
+ n_x, n_y = (cells + 2 * margin for cells in self.medium.sound_speed.shape)
485
+ return device_bytes_2d(
486
+ n_x=n_x,
487
+ n_y=n_y,
488
+ n_t=n_t,
489
+ mod_t=self.sensor.sampling_modulus_time,
490
+ ncoords=len(self.source.incoords),
491
+ n_tic=n_tic,
492
+ ncoordsout=len(self.sensor.outcoords),
493
+ ncoords_add=len(self.source.incoords_add) if n_tic_add else 0,
494
+ n_tic_add=n_tic_add,
495
+ ncoordszero=len(self.medium.air_coords),
496
+ )
497
+
307
498
  def _run_one(
308
499
  self,
309
500
  simulation_dir_name: str | Path,
@@ -357,6 +548,9 @@ class UltraBackend2D:
357
548
  )
358
549
  self.last_run.update(run_seconds=time.perf_counter() - started, stdout=stdout)
359
550
  genout_paths = [rundir / dat_io.genout_name(m) for m in range(len(signals))]
551
+ check_genout_files(
552
+ genout_paths, ncoordsout=scalars["ncoordsout"], rundir=rundir, stdout=stdout
553
+ )
360
554
  if not load_results:
361
555
  return genout_paths
362
556
  traces = np.stack(
fullwave/ultra/binary.py CHANGED
@@ -28,6 +28,8 @@ MARKER_AIR_WINDOW_2D = b"nzero0.dat"
28
28
  MARKER_SOURCE_RELEASE_2D = b"nszero0.dat"
29
29
  MARKER_HOLD_SOURCE_3D = b"nszero0.dat"
30
30
 
31
+ OUTPUT_TAIL_CHARS = 2000
32
+
31
33
 
32
34
  class PreflightError(RuntimeError):
33
35
  """Raised when no usable CUDA device is visible."""
@@ -146,7 +148,11 @@ def run_binary(
146
148
  check=False,
147
149
  )
148
150
  if result.returncode != 0:
149
- tail = (result.stderr or result.stdout)[-2000:]
150
- msg = f"{Path(binary).name} failed (returncode={result.returncode}):\n{tail}"
151
+ stderr_tail = (result.stderr or "")[-OUTPUT_TAIL_CHARS:]
152
+ stdout_tail = (result.stdout or "")[-OUTPUT_TAIL_CHARS:]
153
+ msg = (
154
+ f"{Path(binary).name} failed (returncode={result.returncode}):\n"
155
+ f"stderr:\n{stderr_tail}\nstdout:\n{stdout_tail}"
156
+ )
151
157
  raise RuntimeError(msg)
152
158
  return result.stdout or ""
@@ -19,6 +19,27 @@ if TYPE_CHECKING:
19
19
  SPONGE_WAVELENGTHS = 6
20
20
 
21
21
 
22
+ def extension_cells(*, ppw: float, nbdy: int | None, m_stencil: int = 8) -> tuple[int, int]:
23
+ """Return the sponge width and the cells added on each side of the domain.
24
+
25
+ Parameters
26
+ ----------
27
+ ppw : float
28
+ Points per wavelength.
29
+ nbdy : int | None
30
+ Sponge width in cells; None gives ``ppw * SPONGE_WAVELENGTHS``.
31
+ m_stencil : int
32
+ Stencil half-width.
33
+
34
+ Returns
35
+ -------
36
+ tuple[int, int]
37
+ The sponge width ``nbdy`` and the extension ``mext = nbdy + m_stencil``.
38
+ """
39
+ nbdy = int(ppw * SPONGE_WAVELENGTHS) if nbdy is None else nbdy
40
+ return nbdy, nbdy + m_stencil
41
+
42
+
22
43
  def write_run_directory_2d(
23
44
  outdir: str | Path,
24
45
  *,
@@ -65,9 +86,8 @@ def write_run_directory_2d(
65
86
  """
66
87
  outdir = Path(outdir)
67
88
  outdir.mkdir(parents=True, exist_ok=True)
68
- nbdy = int(ppw * SPONGE_WAVELENGTHS) if nbdy is None else nbdy
89
+ nbdy, mext = extension_cells(ppw=ppw, nbdy=nbdy, m_stencil=m_stencil)
69
90
  dx, dt, n_t = time_axis(c0=c0, omega0=omega0, dur=dur, ppw=ppw, cfl=cfl)
70
- mext = nbdy + m_stencil
71
91
 
72
92
  c = extend_map(maps["cmap"], mext)
73
93
  rho = extend_map(maps["rmap"], mext)
@@ -0,0 +1,109 @@
1
+ """Read the free GPU memory and release the CuPy memory pools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import gc
6
+ import logging
7
+ import shutil
8
+ import subprocess
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Sequence
13
+
14
+ logger = logging.getLogger("__main__." + __name__)
15
+
16
+ GPU_MEMORY_FRACTION = 0.9
17
+ GPU_MEMORY_RESERVE_BYTES = 512 * 1024**2
18
+
19
+
20
+ def members_that_fit(*, free_bytes: int, shared_bytes: int, per_member_bytes: int) -> int:
21
+ """Return how many batch members fit into the free memory of a GPU.
22
+
23
+ Only ``GPU_MEMORY_FRACTION`` of the free memory is used, less ``GPU_MEMORY_RESERVE_BYTES``
24
+ for the CUDA context and for what the estimate does not count.
25
+
26
+ Parameters
27
+ ----------
28
+ free_bytes : int
29
+ The free memory of the GPU.
30
+ shared_bytes : int
31
+ The memory used once, whatever the number of members.
32
+ per_member_bytes : int
33
+ The memory each member adds.
34
+
35
+ Returns
36
+ -------
37
+ int
38
+ The count, which is zero or negative when not even one member fits.
39
+
40
+ """
41
+ usable_bytes = GPU_MEMORY_FRACTION * free_bytes - GPU_MEMORY_RESERVE_BYTES - shared_bytes
42
+ return int(usable_bytes // per_member_bytes)
43
+
44
+
45
+ def free_gpu_memory_bytes(device_ids: Sequence[str]) -> dict[str, int] | None:
46
+ """Return the free memory of each GPU in bytes, as nvidia-smi reports it.
47
+
48
+ Parameters
49
+ ----------
50
+ device_ids : Sequence[str]
51
+ The GPU indices, as nvidia-smi counts them.
52
+
53
+ Returns
54
+ -------
55
+ dict[str, int] | None
56
+ The free bytes of each GPU, or None when nvidia-smi cannot be run or does not
57
+ report every GPU.
58
+
59
+ """
60
+ wanted = [device.strip() for device in device_ids]
61
+ nvidia_smi_path = shutil.which("nvidia-smi")
62
+ if nvidia_smi_path is None:
63
+ return None
64
+ try:
65
+ found = subprocess.run( # noqa: S603
66
+ [nvidia_smi_path, "--query-gpu=index,memory.free", "--format=csv,noheader,nounits"],
67
+ capture_output=True,
68
+ text=True,
69
+ check=True,
70
+ timeout=30,
71
+ ).stdout
72
+ except (OSError, subprocess.SubprocessError):
73
+ return None
74
+ free = {}
75
+ for line in found.splitlines():
76
+ index, _, mebibytes = line.partition(",")
77
+ if index.strip() in wanted:
78
+ try:
79
+ free[index.strip()] = int(float(mebibytes)) * 1024**2
80
+ except ValueError:
81
+ return None
82
+ if set(free) != set(wanted):
83
+ return None
84
+ return free
85
+
86
+
87
+ def release_gpu_memory_pools() -> None:
88
+ """Release all CuPy GPU memory pool blocks back to CUDA.
89
+
90
+ Call ``gc.collect()`` first so that Python releases references to
91
+ CuPy arrays, then drain both the device and pinned memory pools.
92
+ This prevents stale allocations from causing memory pressure when
93
+ subsequent operations allocate large GPU arrays.
94
+
95
+ The release is best effort. It does nothing when CuPy is absent, and
96
+ it does nothing when CuPy is present without a usable CUDA driver,
97
+ which is the case on a machine with no GPU.
98
+ """
99
+ gc.collect()
100
+ try:
101
+ import cupy as cp # noqa: PLC0415
102
+ except ImportError:
103
+ return
104
+
105
+ try:
106
+ cp.get_default_memory_pool().free_all_blocks()
107
+ cp.get_default_pinned_memory_pool().free_all_blocks()
108
+ except cp.cuda.runtime.CUDARuntimeError as error:
109
+ logger.debug("GPU memory pools were not released: %s", error)
@@ -35,6 +35,44 @@ def _on_the_host(array: NDArray) -> NDArray:
35
35
  return _array_module(array).asnumpy(array)
36
36
 
37
37
 
38
+ # float32 stores 1.2 as 1.2000000477 (about 6e-8 relative); table nodes are 1e-2 or more apart.
39
+ _FLOAT32_NOISE_RTOL = 1e-6
40
+
41
+
42
+ def _snap_to_nearby_node(
43
+ *,
44
+ values: NDArray[np.float64],
45
+ nodes: NDArray[np.float64],
46
+ xp: object,
47
+ ) -> NDArray[np.float64]:
48
+ """Replace each value that is float32 noise away from a table node by that node.
49
+
50
+ Parameters
51
+ ----------
52
+ values : NDArray[np.float64]
53
+ Requested values.
54
+ nodes : NDArray[np.float64]
55
+ Sorted table nodes.
56
+ xp : object
57
+ Array module of ``values``, numpy or cupy.
58
+
59
+ Returns
60
+ -------
61
+ NDArray[np.float64]
62
+ The values, with near-node values replaced by the node.
63
+
64
+ """
65
+ if nodes.size < 2:
66
+ return values
67
+ values = xp.asarray(values)
68
+ upper_index = xp.clip(xp.searchsorted(nodes, values, side="left"), 1, nodes.size - 1)
69
+ lower_node = nodes[upper_index - 1]
70
+ upper_node = nodes[upper_index]
71
+ is_near_lower = xp.abs(values - lower_node) <= _FLOAT32_NOISE_RTOL * xp.abs(lower_node)
72
+ is_near_upper = xp.abs(values - upper_node) <= _FLOAT32_NOISE_RTOL * xp.abs(upper_node)
73
+ return xp.where(is_near_lower, lower_node, xp.where(is_near_upper, upper_node, values))
74
+
75
+
38
76
  def scale_relaxation_attenuation(
39
77
  relaxation_param_dict: dict[str, NDArray[np.float64]],
40
78
  factor: NDArray[np.float64] | float,
@@ -938,6 +976,8 @@ class RelaxationParametersGenerator:
938
976
 
939
977
  """
940
978
  alpha_coeff = band_scaled_alpha_coeff(alpha_coeff, alpha_power, band_scale)
979
+ alpha_coeff = self._snap_to_table(alpha_coeff, table_axis=self.alpha_list)
980
+ alpha_power = self._snap_to_table(alpha_power, table_axis=self.power_list)
941
981
  self._warn_invalid(alpha_coeff, alpha_power)
942
982
 
943
983
  use_gpu = not isinstance(alpha_coeff, np.ndarray)
@@ -991,6 +1031,7 @@ class RelaxationParametersGenerator:
991
1031
 
992
1032
  """
993
1033
  xp = np if isinstance(alpha_coeff, np.ndarray) else _array_module(alpha_coeff)
1034
+ alpha_coeff = self._snap_to_table(alpha_coeff, table_axis=self.alpha_list)
994
1035
  axis = xp.asarray(np.asarray(self.alpha_list).ravel().round(10))
995
1036
  return (
996
1037
  alpha_coeff / axis[self._axis_index(alpha_coeff, axis, self.alpha_min, self.alpha_max)]
@@ -1113,6 +1154,15 @@ class RelaxationParametersGenerator:
1113
1154
  ],
1114
1155
  )
1115
1156
 
1157
+ @staticmethod
1158
+ def _snap_to_table(
1159
+ values: NDArray[np.float64], *, table_axis: NDArray[np.float64]
1160
+ ) -> NDArray[np.float64]:
1161
+ """Replace each value that is float32 noise away from a node of one table axis."""
1162
+ xp = np if isinstance(values, np.ndarray) else _array_module(values)
1163
+ nodes = xp.asarray(np.asarray(table_axis).ravel().round(10))
1164
+ return _snap_to_nearby_node(values=values, nodes=nodes, xp=xp)
1165
+
1116
1166
  @staticmethod
1117
1167
  def _axis_index(
1118
1168
  value: NDArray[np.float64],
@@ -1122,6 +1172,7 @@ class RelaxationParametersGenerator:
1122
1172
  ) -> NDArray[np.int64]:
1123
1173
  """Return the axis index the lookup kernel selects, which clips at both ends."""
1124
1174
  xp = np if isinstance(value, np.ndarray) else _array_module(value)
1175
+ value = _snap_to_nearby_node(values=value, nodes=axis, xp=xp)
1125
1176
  clipped = xp.clip(value, lowest, highest)
1126
1177
  return xp.minimum(xp.searchsorted(axis, clipped, side="left"), axis.size - 1)
1127
1178
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fullwave25
3
- Version: 1.4.2-dev1
3
+ Version: 1.4.2-dev3
4
4
  Summary: Fullwave 2.5: Ultrasound wave propagation simulation with heterogeneous power law attenuation modelling capabilities
5
5
  Author: Masashi Sode, Gianmarco Pinton
6
6
  Maintainer: Masashi Sode
@@ -417,6 +417,8 @@ traces = solver.run() # (n_sensors, n_frames)
417
417
  # 2D only: traces = solver.run_batch(sources) # (n_transmits, n_sensors, n_frames)
418
418
  ```
419
419
 
420
+ In 2D, `run_batch` treats `max_members_per_run` as an upper bound that the free GPU memory can lower, and it keeps the requested value, with a warning, when the free memory cannot be read. The native path uses an explicit `max_members_per_run` as given.
421
+
420
422
  Supported now:
421
423
 
422
424
  - 2D grids with `run` and `run_batch`, including air cells.
@@ -430,12 +432,16 @@ Supported now:
430
432
  - Velocity sources, and sources that are not pressure sources.
431
433
  - Sparse-grid sensors, `is_static_map`, and `record_whole_domain`.
432
434
  - Media other than `fullwave.Medium` (exponential attenuation), and `alpha_power` other than 1.
433
- - `use_pml=False`, the PML settings (set the sponge width with `exponential_attenuation_pml_thickness_px`), `m_spatial_order` other than 8, `use_gpu=False`, `save_gpu_memory`, and more than one GPU.
435
+ - `use_pml=False`, `m_spatial_order` other than 8, `use_gpu=False`, and an invalid `source_type`.
434
436
  - In 2D, a binary that does not read the files for air cells, for the additive channel, or for releasing the source (with such a binary, pad `p0` to `grid.nt`).
435
437
  - In 3D, air cells with a binary that does not read `icczero.dat` and `nzero0.dat`, and additive sources with a binary that does not read `ncoords_add.dat` and `icmat_add.dat`.
436
438
 
437
439
  This list will grow as the backend does.
438
440
 
441
+ The backend runs the exponential attenuation model (frequency-independent: the same attenuation at every frequency). Set the width of its absorbing sponge, in cells, with `ultra_sponge_thickness_px`; the default is 6 wavelengths in 2D and 4 wavelengths in 3D. `exponential_attenuation_pml_thickness_px` no longer sets this width with `use_ultra=True`; a script that relied on it now gets the default width until it passes `ultra_sponge_thickness_px`.
442
+
443
+ Options that only tune the native solver are ignored with a warning, once per process: `pml_layer_thickness_px`, `n_transition_layer`, `pml_alpha_entrance`, `exponential_attenuation_pml_thickness_px` (use `ultra_sponge_thickness_px` instead), `use_gpu_pml`, `save_gpu_memory`, `write_maps_the_solver_does_not_read`, `use_isotropic_relaxation=False`, and a `cuda_device_id` that lists more than one device (the run uses the first one listed). `use_exponential_attenuation=False`, the native default, also warns that the backend runs the exponential attenuation model, whose attenuation is frequency-independent, not the relaxation model, whose attenuation is frequency-dependent. Pass `use_exponential_attenuation=True` to silence it.
444
+
439
445
  ---
440
446
 
441
447
  ## Attention
@@ -1,12 +1,12 @@
1
- fullwave/__init__.py,sha256=yU0dwHvWMslDd0clb94ezpuzqrmnv-J3qBz8jgHdRRA,2382
1
+ fullwave/__init__.py,sha256=Lu4dG1mNq4CKaKD34LmhtJNlYn8m2gRXrw8qYqnAZKg,2382
2
2
  fullwave/grid.py,sha256=YbxAAgdYKR8AXwobtTZzHm9C4KR4D5--51zgbs0aQGY,7435
3
3
  fullwave/medium.py,sha256=qoAh-aYLVkC_ojt1K61hDNV0j9KfIeRkh35tZU8gSKM,68574
4
4
  fullwave/sensor.py,sha256=McHyaSKxM11_Kfb_2hfnOtV9Z1UMBCLkeovncLT8_bA,9308
5
5
  fullwave/source.py,sha256=5IHktJubOsk94qBWlLBbu-Vxo2UczVFL4EnEyKqEIUE,21321
6
- fullwave/transducer.py,sha256=WmaYwKOPSWLVnjDFZkpP1qlLs4IT27HLtB04vJhm0Ec,75778
6
+ fullwave/transducer.py,sha256=H67VF_T2KKF68mWtXG6DrVYKaaZieGc7p-IDtWxX1SQ,77623
7
7
  fullwave/transmit.py,sha256=BAD_G-J7Q1pMppXw1yjLrQDUdkhPq-3Q4eNq4jaf7Ss,9693
8
8
  fullwave/beamformer/__init__.py,sha256=tNPq_5lfLXfAdueyfQtdTxLTVEV6nEDkeSGAeTf7Q5s,88
9
- fullwave/beamformer/beamformer.py,sha256=0jE_B5UlxDDBsUcQSHa5d1q3uSTSCyqgNiUJrU10juI,5601
9
+ fullwave/beamformer/beamformer.py,sha256=7dlQVArUGpUeUP6O-9QyMpR6asbbI5JBVJ_F1fpSowU,6175
10
10
  fullwave/constants/__init__.py,sha256=WOmkhLsSe86D71gwKsa4Tz8QPeOdayLGgvBCh7UEOPk,104
11
11
  fullwave/constants/material_properties.py,sha256=SouMKD4ONzYl0VU8ZsC2I_85y-ZLj9scRNwAArCD7bg,2729
12
12
  fullwave/medium_builder/__init__.py,sha256=QVz0sA4niq51O94RnXUVlHutx7FYEIa1TRcR5fDc_Ps,201
@@ -28,7 +28,7 @@ fullwave/solver/input_file_writer.py,sha256=tzi1MZF-KhNIstrhlu-2lTWGInRtbPaSSFNw
28
28
  fullwave/solver/launcher.py,sha256=pNZBecyANOtGP9rrG-kfa1HDrXH7qCT8BL4NCTmsRog,13272
29
29
  fullwave/solver/pml_builder.py,sha256=Pfy3AklZT-p6TX61A5ntFmpt2mJb5TDaV-gdtpjIwNo,87341
30
30
  fullwave/solver/shipped_database.py,sha256=lPqlgY-ShDyRqtH65m5RyNIMVlqswECVDNh5XCk1Yzg,3194
31
- fullwave/solver/solver.py,sha256=-TWt2DLz7d5Nk4A2JHCHOqk4n7C4WgSKwndphLhJuAM,99250
31
+ fullwave/solver/solver.py,sha256=rMVmBB294f3b2PoQpSJWlEifzxPo9ssFOffzLJ5NkjE,97971
32
32
  fullwave/solver/source_type.py,sha256=iozo2wGuFLAbhRmXKETcmYV2I0UrA-AB3mnbFRp2s1o,11545
33
33
  fullwave/solver/utils.py,sha256=0Q9PKl-4hohWXGzuldqo2XubI-LEHMjs0kYgb4oTLC0,5561
34
34
  fullwave/solver/bins/_gpu/2d/num_relax=2/fullwave2_2d_2_relax_multi_gpu_cuda118,sha256=bIzyHFzr_bsIBSlCcRA3M9LlTSRrBVn6ASJHyozvcwU,1245432
@@ -44,9 +44,9 @@ fullwave/solver/bins/database/relaxation_params_database_num_relax=3_20260828_15
44
44
  fullwave/solver/bins/database/relaxation_params_database_num_relax=4_20260828_1456.mat,sha256=YfR8RaWQn2dDYiw9TACSUx27QUpKhO7iJQA8rAPLHZA,253928
45
45
  fullwave/solver/bins/database/relaxation_params_database_num_relax=4_20260828_1456_invalid_cells.json,sha256=-qA_p-S_3rr8Hl-fXfgvI6m2EED3duc_wW0RlKDUHoE,63983
46
46
  fullwave/ultra/__init__.py,sha256=O6-Dw5_UmmG6ikKQ8NCXrmkBjG_CHzWJpBDTwfUhcM0,356
47
- fullwave/ultra/backend.py,sha256=xse6Q1CuFYGLeljnGlNtlKfipUNvncq9OcMqtnaEqS4,15426
48
- fullwave/ultra/backend_2d.py,sha256=J4W2B83Fmky6Cn-z-qu3EgO_gJdpkWbGO0oGTWYR38E,14754
49
- fullwave/ultra/binary.py,sha256=PpEq6YIxRmmCyiNCjotDII4iVD_gYaRnf8g5hM6dPac,4615
47
+ fullwave/ultra/backend.py,sha256=_L2B9OUNL8suime6bPp-GD89RYn4bg37jG3WZv6qnKI,18627
48
+ fullwave/ultra/backend_2d.py,sha256=f_cK-W9dJeyRfbkGCfCJ60wwIRILhnHjMRwlGuWdW88,21552
49
+ fullwave/ultra/binary.py,sha256=xouFNbwi3NMaH8kuAgk1GQeLGe8DZrwG0RDBftDav-8,4795
50
50
  fullwave/ultra/dat_io.py,sha256=mmHfMSsBFUeNSkDC4IMKwGMB_ryA5V_FYzlAG_G6Ht8,2170
51
51
  fullwave/ultra/dat_io_3d.py,sha256=ZVBdHe-caTHdofkHUqPlHZSWKlv793ll6juofH5W6y4,1183
52
52
  fullwave/ultra/errors.py,sha256=Z1uY2sdnxAA0_fzFf3QmYgR0ZyBJGmsdQyrzoLhyEFY,962
@@ -54,20 +54,21 @@ fullwave/ultra/factory.py,sha256=hfEAXz2D8EHscgsy2XFK3Iiom8dGdrT2o5yDFGk5qds,548
54
54
  fullwave/ultra/medium_to_aexp.py,sha256=_uOHDhH6lwQfj4sSgE_jd5Ujq5vCnXvdCdu6Fcx9QKE,1864
55
55
  fullwave/ultra/stencil.py,sha256=XG5rPZsJbKaFDkli2GVmFdeqP72QkwTIEZc_49vGmNE,4912
56
56
  fullwave/ultra/time_axis.py,sha256=KXsc2EaT6zo-Glh27nxxX50BfpC3oO8jkwbO6SgE3bg,1825
57
- fullwave/ultra/writer_2d.py,sha256=PxF_3xU2cVTMSg-_QUZuVutaoIXO0pPbjOz4P0jt8Ak,7037
57
+ fullwave/ultra/writer_2d.py,sha256=QlVZyED8TwtbQQKZ40v0VnWm-DIgo735RCg_c9Ad4Jg,7645
58
58
  fullwave/ultra/writer_3d.py,sha256=gW-GcWWItdE43rcwx6YWn_O9edTwkiCeIcX1IIrJAz0,6985
59
59
  fullwave/utils/__init__.py,sha256=YKBUZnQ6E9AiT0tt4gL9HVapvcGv0lbcXXQyF-IWBXc,517
60
60
  fullwave/utils/check_functions.py,sha256=benZqFjOQF7aaQDTBGaOq-eplJ-dC8Dz4jcqPE-9HSk,1514
61
61
  fullwave/utils/coordinates.py,sha256=TEVQE9JXUIVFoKEyg8ZZMeGh31elbpecHnDgbljHQ14,4402
62
+ fullwave/utils/gpu_memory.py,sha256=yitC9ZaVws5wBXjsaCuW7qGPDZlrbelYLsSDnxMilkQ,3420
62
63
  fullwave/utils/memory_tempfile.py,sha256=RqoFX67VCMIHNIDYWwHLCaS4dwSPK6eKsvElXX43Qjg,15083
63
64
  fullwave/utils/numerical.py,sha256=khdi2KE3y_Ky9cEnmGXvVHjeYuKfb-8gQTV1a8nx9uA,3260
64
65
  fullwave/utils/plot_utils.py,sha256=SoByd2fEr3VdvMM9D2kNq8VgaAQ-fh82bDGRFdeq2R8,39327
65
66
  fullwave/utils/pulse.py,sha256=aqnCdBxivACouIGlot1aE2k4V9nctUxNCEPzdBpsupg,2811
66
- fullwave/utils/relaxation_parameters.py,sha256=wHUNMtJGAJwgCyUYd24_qtQZflMnkQTYhxSJwjE3vA4,46151
67
+ fullwave/utils/relaxation_parameters.py,sha256=pNul3_8rrbBrjkcc0nKBJ6An9y3bI92DVxNu1Ww8cf4,48133
67
68
  fullwave/utils/scatterer.py,sha256=CLqD085f9pqX4cF1YiT2IkzT5P0o1xp45Do7lf5BLnM,24049
68
69
  fullwave/utils/signal_filter.py,sha256=_Tf42aBygGtIUrcSPybYqvZoAW2o_4dd-hHTEvZ8--E,5219
69
70
  fullwave/utils/signal_process.py,sha256=MP38r37JSPHN4sT-lIReXS5BT2GT_tkRxD0Y1wQ-lNs,5153
70
- fullwave25-1.4.2.dev1.dist-info/METADATA,sha256=OqrNOXGpOzdvWtUv4yUCUq1fZtgBs5ipGbfNjaELV9o,26885
71
- fullwave25-1.4.2.dev1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
72
- fullwave25-1.4.2.dev1.dist-info/licenses/LICENSE,sha256=46mU2C5kSwOnkqkw9XQAJlhBL2JAf1_uCD8lVcXyMRg,7652
73
- fullwave25-1.4.2.dev1.dist-info/RECORD,,
71
+ fullwave25-1.4.2.dev3.dist-info/METADATA,sha256=q0L2mArY9AQfnOaJKDYCLIke8U4wNM0w5TOm-EPxMIU,28226
72
+ fullwave25-1.4.2.dev3.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
73
+ fullwave25-1.4.2.dev3.dist-info/licenses/LICENSE,sha256=46mU2C5kSwOnkqkw9XQAJlhBL2JAf1_uCD8lVcXyMRg,7652
74
+ fullwave25-1.4.2.dev3.dist-info/RECORD,,