fullwave25 1.4.2.dev0__py3-none-any.whl → 1.4.2.dev1__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-dev0"
69
+ __version__ = "1.4.2-dev1"
70
70
 
71
71
  VERSION = __version__ # for convenience
72
72
  logger.info("Fullwave version: %s", __version__)
fullwave/medium.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  from collections import OrderedDict
7
- from concurrent.futures import ThreadPoolExecutor
7
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
8
  from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
  from typing import TYPE_CHECKING
@@ -93,8 +93,6 @@ def _upload_arrays_multi_gpu(
93
93
  dict mapping name -> CuPy array on *target_device*.
94
94
 
95
95
  """
96
- from concurrent.futures import as_completed # noqa: PLC0415
97
-
98
96
  import cupy as cp # noqa: PLC0415
99
97
 
100
98
  n_gpus = cp.cuda.runtime.getDeviceCount()
@@ -21,6 +21,8 @@ import urllib.error
21
21
  import urllib.request
22
22
  from pathlib import Path
23
23
 
24
+ from tqdm import tqdm
25
+
24
26
  logger = logging.getLogger("__main__." + __name__)
25
27
 
26
28
  GITHUB_REPO = "pinton-lab/fullwave25"
@@ -28,7 +30,7 @@ CACHE_DIR = Path.home() / ".cache" / "fullwave25" / "bins"
28
30
 
29
31
  # Pinned release tag for the solver binaries.
30
32
  # Update this only when new binaries are uploaded to a GitHub release.
31
- BINARY_RELEASE_TAG = "fullwave_bin_v1.6"
33
+ BINARY_RELEASE_TAG = "fullwave_bin_v1.7"
32
34
 
33
35
 
34
36
  def _download_url(filename: str, tag: str) -> str:
@@ -36,39 +38,34 @@ def _download_url(filename: str, tag: str) -> str:
36
38
 
37
39
 
38
40
  def _download_binary(url: str, dest: Path) -> None:
39
- """Download *url* to *dest*, showing a tqdm progress bar when available."""
41
+ """Download *url* to *dest*, showing a tqdm progress bar."""
40
42
  dest.parent.mkdir(parents=True, exist_ok=True)
41
43
  tmp = dest.with_suffix(".download_tmp")
42
44
  try:
45
+
46
+ class _Hook:
47
+ def __init__(self) -> None:
48
+ self._bar: tqdm | None = None
49
+
50
+ def __call__(self, block: int, block_size: int, total: int) -> None: # noqa: ARG002
51
+ if self._bar is None:
52
+ self._bar = tqdm(
53
+ total=total if total > 0 else None,
54
+ unit="B",
55
+ unit_scale=True,
56
+ desc=dest.name,
57
+ )
58
+ self._bar.update(block_size)
59
+
60
+ def close(self) -> None:
61
+ if self._bar is not None:
62
+ self._bar.close()
63
+
64
+ hook = _Hook()
43
65
  try:
44
- from tqdm import tqdm # noqa: PLC0415
45
-
46
- class _Hook:
47
- def __init__(self) -> None:
48
- self._bar: tqdm | None = None
49
-
50
- def __call__(self, block: int, block_size: int, total: int) -> None: # noqa: ARG002
51
- if self._bar is None:
52
- self._bar = tqdm(
53
- total=total if total > 0 else None,
54
- unit="B",
55
- unit_scale=True,
56
- desc=dest.name,
57
- )
58
- self._bar.update(block_size)
59
-
60
- def close(self) -> None:
61
- if self._bar is not None:
62
- self._bar.close()
63
-
64
- hook = _Hook()
65
- try:
66
- urllib.request.urlretrieve(url, tmp, reporthook=hook) # noqa: S310
67
- finally:
68
- hook.close()
69
-
70
- except ImportError:
71
- urllib.request.urlretrieve(url, tmp) # noqa: S310
66
+ urllib.request.urlretrieve(url, tmp, reporthook=hook) # noqa: S310
67
+ finally:
68
+ hook.close()
72
69
 
73
70
  tmp.rename(dest)
74
71
  dest.chmod(dest.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
@@ -0,0 +1,87 @@
1
+ """Find out whether a solver binary reads an input file, by the file name in its bytes."""
2
+
3
+ import mmap
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+ from typing import NamedTuple
7
+
8
+ HOLD_SOURCE_MARKER = b"hold_source.dat"
9
+ DEFAULT_FIX = "rebuild the binary, or set FULLWAVE25_BINARY_TAG to the release that has it"
10
+
11
+
12
+ class HoldSourceUnsupportedError(RuntimeError):
13
+ """Raised when the binary cannot hold the hard source after the drive ends."""
14
+
15
+
16
+ class _FileStamp(NamedTuple):
17
+ """A file with the size and modification time that tell whether it changed."""
18
+
19
+ path: Path
20
+ size: int
21
+ mtime_ns: int
22
+
23
+
24
+ @lru_cache(maxsize=64)
25
+ def _find(stamp: _FileStamp, marker: bytes) -> bool:
26
+ if stamp.size == 0:
27
+ return False
28
+ with stamp.path.open("rb") as file, mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_READ) as mm:
29
+ return mm.find(marker) != -1
30
+
31
+
32
+ def binary_has_marker(binary: str | Path, *, marker: bytes) -> bool:
33
+ """Return whether the bytes of ``binary`` contain ``marker``.
34
+
35
+ Parameters
36
+ ----------
37
+ binary : str | Path
38
+ The solver binary.
39
+ marker : bytes
40
+ The name of the input file the binary reads.
41
+
42
+ Returns
43
+ -------
44
+ bool
45
+ True when the binary contains the name. The answer is cached until the file changes.
46
+
47
+ """
48
+ path = Path(binary).resolve()
49
+ stat = path.stat()
50
+ return _find(_FileStamp(path=path, size=stat.st_size, mtime_ns=stat.st_mtime_ns), marker)
51
+
52
+
53
+ def check_binary_holds_source(
54
+ binary: str | Path,
55
+ *,
56
+ marker: bytes = HOLD_SOURCE_MARKER,
57
+ error_type: type[Exception] = HoldSourceUnsupportedError,
58
+ fix: str = DEFAULT_FIX,
59
+ ) -> None:
60
+ """Refuse a binary that would release the hard source cells when the drive ends.
61
+
62
+ Parameters
63
+ ----------
64
+ binary : str | Path
65
+ The solver binary.
66
+ marker : bytes, optional
67
+ The name of the input file that tells the binary to hold the source.
68
+ error_type : type[Exception], optional
69
+ The exception to raise.
70
+ fix : str, optional
71
+ What to do to get a binary that reads the file, worded for the message.
72
+
73
+ Raises
74
+ ------
75
+ Exception
76
+ ``error_type``, when the binary does not contain ``marker``.
77
+
78
+ """
79
+ if binary_has_marker(binary, marker=marker):
80
+ return
81
+ message = (
82
+ f"{Path(binary).name} does not read {marker.decode()}, so it releases the hard source "
83
+ "cells when the drive signal ends and source_type='hard' would not hold them. "
84
+ f"Use a binary that reads it: {fix}. "
85
+ "source_type='hard_released_after' needs no such binary."
86
+ )
87
+ raise error_type(message)
@@ -5,6 +5,7 @@ import logging
5
5
  import os
6
6
  import time
7
7
  from collections.abc import Sequence
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
8
9
  from pathlib import Path
9
10
 
10
11
  import numpy as np
@@ -64,6 +65,7 @@ class InputFileWriter:
64
65
  exponential_attenuation_pml_interior_offset_px: int = 0,
65
66
  use_gpu: bool = False,
66
67
  write_maps_the_solver_does_not_read: bool = False,
68
+ hold_hard_source: bool = False,
67
69
  ) -> None:
68
70
  """Initialize the InputGeneratorBase instance.
69
71
 
@@ -124,6 +126,11 @@ class InputFileWriter:
124
126
  which an older solver binary needs. The record the skipped maps
125
127
  replace is impedance_constraint.dat, and this writer always writes it
126
128
  on the relaxation path.
129
+ hold_hard_source : bool, optional
130
+ Whether the hard source cells stay at zero pressure after the drive ends.
131
+ True writes the flag file ``hold_source.dat``, which the solver reads to keep
132
+ the cells at zero for the rest of the run; the signals are written as given.
133
+ False, the default, lets the solver release the cells when the signal ends.
127
134
 
128
135
  """
129
136
  logger.debug("Initializing InputFileWriter instance.")
@@ -139,6 +146,7 @@ class InputFileWriter:
139
146
  self.use_isotropic_relaxation = use_isotropic_relaxation
140
147
  self.write_maps_the_solver_does_not_read = write_maps_the_solver_does_not_read
141
148
  self.use_gpu = use_gpu
149
+ self.hold_hard_source = hold_hard_source
142
150
 
143
151
  if validate_input:
144
152
  check_functions.check_path_exists(self.path_fullwave_simulation_bin)
@@ -347,10 +355,21 @@ class InputFileWriter:
347
355
 
348
356
  """
349
357
  if members is None:
350
- return getattr(self.source, name, None)
351
- if getattr(self.source, name, None) is None:
358
+ signal = getattr(self.source, name, None)
359
+ elif getattr(self.source, name, None) is None:
352
360
  return None
353
- return np.concatenate([getattr(member, name) for member in members], axis=0)
361
+ else:
362
+ signal = np.concatenate([getattr(member, name) for member in members], axis=0)
363
+ return signal
364
+
365
+ @property
366
+ def _holds_the_source(self) -> bool:
367
+ """Whether the solver keeps the hard source cells at zero after the drive ends."""
368
+ return (
369
+ self.hold_hard_source
370
+ and self.source.n_sources > 0
371
+ and self.source.icmat.shape[1] < self.grid.nt
372
+ )
354
373
 
355
374
  # --- constructor utils ---
356
375
 
@@ -909,8 +928,6 @@ class InputFileWriter:
909
928
  (c_min_val, c_max_val) of the sound speed.
910
929
 
911
930
  """
912
- from concurrent.futures import ThreadPoolExecutor, as_completed # noqa: PLC0415
913
-
914
931
  import cupy as cp # noqa: PLC0415
915
932
 
916
933
  sound_speed_np = self.medium.sound_speed
@@ -1259,6 +1276,7 @@ class InputFileWriter:
1259
1276
  "ncoordsout",
1260
1277
  "ncoordszero",
1261
1278
  "nTic",
1279
+ "hold_source",
1262
1280
  "modT",
1263
1281
  "modX",
1264
1282
  "modY",
@@ -1357,7 +1375,6 @@ class InputFileWriter:
1357
1375
  self._queue_v_abs_write(np.float32, save_path, var)
1358
1376
 
1359
1377
  def _save_coords_params(self, simulation_dir: Path) -> None:
1360
- nt_ic = self.source.icmat.shape[1]
1361
1378
  var_list = [
1362
1379
  ("nX", self.grid.nx),
1363
1380
  ("nY", self.grid.ny),
@@ -1365,9 +1382,11 @@ class InputFileWriter:
1365
1382
  ("ncoords", self.source.n_sources),
1366
1383
  ("ncoordsout", self.sensor.n_sensors),
1367
1384
  ("ncoordszero", self.medium.n_air),
1368
- ("nTic", nt_ic),
1385
+ ("nTic", self.source.icmat.shape[1]),
1369
1386
  ("modT", self.sensor.sampling_modulus_time),
1370
1387
  ]
1388
+ if self._holds_the_source:
1389
+ var_list.append(("hold_source", 1))
1371
1390
  n_sources_add = getattr(self.source, "n_sources_add", 0)
1372
1391
  if n_sources_add > 0:
1373
1392
  var_list.append(("ncoords_add", n_sources_add))
@@ -13,6 +13,7 @@ from time import time
13
13
  import numpy as np
14
14
  from numpy.typing import NDArray
15
15
 
16
+ from .cuda_utils import find_consecutive_gpus_without_peer_access
16
17
  from .utils import load_dat_data
17
18
 
18
19
  logger = logging.getLogger("__main__." + __name__)
@@ -211,8 +212,6 @@ class Launcher:
211
212
  )
212
213
  return
213
214
  try:
214
- from .cuda_utils import find_consecutive_gpus_without_peer_access # noqa: PLC0415
215
-
216
215
  pairs = find_consecutive_gpus_without_peer_access(device_ids)
217
216
  except (AttributeError, NameError, OSError, RuntimeError, ctypes.ArgumentError) as error:
218
217
  logger.debug("Peer access check skipped: %s", error)
fullwave/solver/solver.py CHANGED
@@ -7,6 +7,7 @@ import os
7
7
  import shutil
8
8
  import subprocess
9
9
  import time
10
+ import warnings
10
11
  from collections.abc import Iterator, Sequence
11
12
  from pathlib import Path
12
13
 
@@ -18,18 +19,21 @@ from fullwave.solver.input_file_writer import InputFileWriter
18
19
  from fullwave.solver.launcher import Launcher
19
20
  from fullwave.solver.pml_builder import PMLBuilder, PMLBuilderExponentialAttenuation
20
21
  from fullwave.solver.source_type import (
21
- HARD,
22
+ HARD_RELEASED_AFTER,
22
23
  SOURCE_TYPES,
23
24
  as_additive_source,
25
+ is_hard_held,
24
26
  is_soft,
25
27
  )
28
+ from fullwave.ultra import backend, binary, factory
26
29
  from fullwave.utils import (
27
- MemoryTempfile,
28
30
  check_functions,
31
+ run_directory_in_memory,
29
32
  )
30
33
  from fullwave.utils.signal_filter import apply_filter
31
34
 
32
35
  from .binary_manager import ensure_binary
36
+ from .binary_markers import check_binary_holds_source
33
37
  from .cuda_utils import get_cuda_architecture, retrieve_cuda_version
34
38
  from .utils import load_dat_data
35
39
 
@@ -40,6 +44,9 @@ logger = logging.getLogger("__main__." + __name__)
40
44
  # that, so a larger run only costs memory.
41
45
  DEFAULT_MAX_MEMBERS_PER_RUN = 32
42
46
 
47
+ # The smallest points per wavelength (ppw) the relaxation attenuation model is validated for.
48
+ MIN_VALIDATED_PPW_RELAXATION = 8
49
+
43
50
 
44
51
  def _split_evenly(members: list, largest: int) -> list[list]:
45
52
  """Split the transmits into the fewest runs of at most ``largest``, of nearly equal size.
@@ -476,6 +483,9 @@ class Solver:
476
483
  generates the required input files, and runs the simulation executable.
477
484
  """
478
485
 
486
+ #: The experimental fullwave2-ultra backend; set only by ``use_ultra=True``.
487
+ ultra = None
488
+
479
489
  def __init__( # noqa: PLR0912
480
490
  self,
481
491
  work_dir: Path,
@@ -500,8 +510,9 @@ class Solver:
500
510
  verify_gpu: bool = True,
501
511
  use_gpu_pml: bool = False,
502
512
  pml_alpha_entrance: float | None = None,
503
- source_type: str = HARD,
513
+ source_type: str = HARD_RELEASED_AFTER,
504
514
  write_maps_the_solver_does_not_read: bool = False,
515
+ use_ultra: bool = False,
505
516
  ) -> None:
506
517
  """Initialize a Solver instance for the fullwave simulation.
507
518
 
@@ -627,6 +638,21 @@ class Solver:
627
638
  relaxation path. Set it to True for a solver binary that predates
628
639
  that record. Such a binary stops with an error on kappax.dat rather
629
640
  than returning a wrong answer.
641
+ source_type : str, optional
642
+ How the source drives the field. "hard_released_after", the default,
643
+ assigns the source pressure while the drive signal plays, then lets the
644
+ source cells evolve freely. "hard" assigns it for the whole run, so the
645
+ cells stay at zero pressure after the signal ends. "soft" adds the signal
646
+ to the field. ``use_ultra`` follows the same three values.
647
+ use_ultra : bool, optional
648
+ Experimental. Run a user-supplied fullwave2-ultra binary (not shipped; pass it
649
+ as ``path_fullwave_simulation_bin`` or set ``FW2U_BATCH_BIN``): ``bench_2d_batch``
650
+ 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``.
630
656
 
631
657
  Raises
632
658
  ------
@@ -637,6 +663,34 @@ class Solver:
637
663
  and transducer) are defined simultaneously.
638
664
 
639
665
  """
666
+ self.ultra = None
667
+ if use_ultra:
668
+ self._init_ultra(
669
+ work_dir=work_dir,
670
+ grid=grid,
671
+ medium=medium,
672
+ source=source,
673
+ sensor=sensor,
674
+ transducer=transducer,
675
+ path_fullwave_simulation_bin=path_fullwave_simulation_bin,
676
+ run_on_memory=run_on_memory,
677
+ cuda_device_id=cuda_device_id,
678
+ verify_gpu=verify_gpu,
679
+ nbdy=exponential_attenuation_pml_thickness_px,
680
+ options={
681
+ "use_pml": use_pml,
682
+ "m_spatial_order": m_spatial_order,
683
+ "pml_layer_thickness_px": pml_layer_thickness_px,
684
+ "n_transition_layer": n_transition_layer,
685
+ "use_gpu": use_gpu,
686
+ "use_gpu_pml": use_gpu_pml,
687
+ "pml_alpha_entrance": pml_alpha_entrance,
688
+ "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,
691
+ },
692
+ )
693
+ return
640
694
  if exponential_attenuation_pml_thickness_px is not None and not use_exponential_attenuation:
641
695
  error_msg = (
642
696
  "exponential_attenuation_pml_thickness_px belongs to the exponential "
@@ -646,6 +700,14 @@ class Solver:
646
700
  )
647
701
  raise ValueError(error_msg)
648
702
 
703
+ if not use_exponential_attenuation and grid.ppw < MIN_VALIDATED_PPW_RELAXATION:
704
+ warnings.warn(
705
+ f"ppw={grid.ppw} is below {MIN_VALIDATED_PPW_RELAXATION}. "
706
+ f"The relaxation model is validated for ppw >= {MIN_VALIDATED_PPW_RELAXATION}.",
707
+ UserWarning,
708
+ stacklevel=2,
709
+ )
710
+
649
711
  # type hints
650
712
  self.source: fullwave.Source
651
713
  self.sensor: fullwave.Sensor
@@ -680,14 +742,7 @@ class Solver:
680
742
  "\n"
681
743
  )
682
744
  logger.info(message)
683
- self.tempfile = MemoryTempfile(
684
- preferred_paths=["/run/user/{uid}"],
685
- remove_paths=["/dev/shm", "/run/shm"], # noqa: S108
686
- additional_paths=["/var/run"],
687
- filesystem_types=["tmpfs"],
688
- fallback=True,
689
- )
690
- self.tempdir = self.tempfile.TemporaryDirectory()
745
+ self.tempdir = run_directory_in_memory()
691
746
  self.work_dir = Path(self.tempdir.name)
692
747
  else:
693
748
  self.work_dir = work_dir
@@ -799,6 +854,7 @@ class Solver:
799
854
  use_exponential_attenuation=use_exponential_attenuation,
800
855
  )
801
856
  logger.info("source_type=%s, the source is driven by addition", source_type)
857
+ self._check_binary_holds_source(path_fullwave_simulation_bin)
802
858
 
803
859
  if sensor is not None:
804
860
  self.sensor = sensor
@@ -849,6 +905,49 @@ class Solver:
849
905
  pml_alpha_entrance=pml_alpha_entrance,
850
906
  )
851
907
 
908
+ def _init_ultra(
909
+ self,
910
+ *,
911
+ work_dir: Path,
912
+ grid: fullwave.Grid,
913
+ medium: fullwave.Medium,
914
+ source: fullwave.Source | None,
915
+ sensor: fullwave.Sensor | None,
916
+ transducer: fullwave.Transducer | None,
917
+ path_fullwave_simulation_bin: Path | None,
918
+ run_on_memory: bool | None,
919
+ cuda_device_id: str | int | list | None,
920
+ verify_gpu: bool,
921
+ nbdy: int | None,
922
+ options: dict,
923
+ ) -> None:
924
+ """Set up the experimental fullwave2-ultra backend (see ``fullwave.ultra``)."""
925
+ backend.warn_experimental()
926
+ backend.warn_ppw_above_speed_optimum(ppw=grid.ppw)
927
+ backend.check_solver_options(**options)
928
+ binary_name = binary.BINARY_NAME_3D if grid.is_3d else binary.BINARY_NAME_2D
929
+ ultra_binary = binary.resolve_binary(path_fullwave_simulation_bin, name=binary_name)
930
+ self._check_input(grid, medium, source, sensor, transducer, ultra_binary)
931
+ if source is None:
932
+ source = transducer.source
933
+ if sensor is None:
934
+ sensor = transducer.sensor
935
+ self.grid, self.medium, self.source, self.sensor = grid, medium, source, sensor
936
+ self.is_3d = grid.is_3d
937
+ self.ultra = factory.make_backend(
938
+ work_dir=Path(work_dir),
939
+ grid=grid,
940
+ medium=medium,
941
+ source=source,
942
+ sensor=sensor,
943
+ binary=ultra_binary,
944
+ run_on_memory=run_on_memory,
945
+ nbdy=nbdy,
946
+ cuda_device_id=cuda_device_id,
947
+ verify_gpu=verify_gpu,
948
+ source_type=options["source_type"],
949
+ )
950
+
852
951
  def _warn_if_the_additive_signal_was_not_scaled(self) -> None:
853
952
  """Say so when an additive signal was built by hand and may carry no scale.
854
953
 
@@ -1170,6 +1269,16 @@ class Solver:
1170
1269
  or if either filter option is set but ``load_results=False``.
1171
1270
 
1172
1271
  """
1272
+ if self.ultra is not None:
1273
+ return self.ultra.run(
1274
+ simulation_dir_name,
1275
+ is_static_map=is_static_map,
1276
+ record_whole_domain=record_whole_domain,
1277
+ load_results=load_results,
1278
+ generate_input_only=generate_input_only,
1279
+ highpass_cutoff_mhz=highpass_cutoff_mhz,
1280
+ bandpass_cutoff_mhz=bandpass_cutoff_mhz,
1281
+ )
1173
1282
  return self._run_one(
1174
1283
  simulation_dir_name,
1175
1284
  members=None,
@@ -1288,6 +1397,7 @@ class Solver:
1288
1397
  exponential_attenuation_pml_interior_offset_px=interior_offset,
1289
1398
  use_gpu=self.use_gpu_pml,
1290
1399
  write_maps_the_solver_does_not_read=self.write_maps_the_solver_does_not_read,
1400
+ hold_hard_source=is_hard_held(self.source_type),
1291
1401
  )
1292
1402
  simulation_dir = input_file_writer.run(
1293
1403
  simulation_dir_name,
@@ -1529,6 +1639,18 @@ class Solver:
1529
1639
  transmit in one simulation.
1530
1640
 
1531
1641
  """
1642
+ if self.ultra is not None:
1643
+ return self.ultra.run_batch(
1644
+ sources,
1645
+ simulation_dir_name,
1646
+ max_members_per_run=max_members_per_run,
1647
+ is_static_map=is_static_map,
1648
+ record_whole_domain=record_whole_domain,
1649
+ load_results=load_results,
1650
+ generate_input_only=generate_input_only,
1651
+ highpass_cutoff_mhz=highpass_cutoff_mhz,
1652
+ bandpass_cutoff_mhz=bandpass_cutoff_mhz,
1653
+ )
1532
1654
  members = self._batch_members(sources)
1533
1655
  # The sensor and the sizing below need the extended grid and medium.
1534
1656
  self.pml_builder.run(use_pml=self.use_pml)
@@ -1702,7 +1824,9 @@ class Solver:
1702
1824
  use_isotropic_relaxation=self.use_isotropic_relaxation,
1703
1825
  batch=True,
1704
1826
  )
1705
- return ensure_binary(local_path)
1827
+ binary = ensure_binary(local_path)
1828
+ self._check_binary_holds_source(binary)
1829
+ return binary
1706
1830
  name = Path(self.path_fullwave_simulation_bin).name
1707
1831
  if "batch" not in name:
1708
1832
  error_msg = (
@@ -1714,6 +1838,23 @@ class Solver:
1714
1838
  raise ValueError(error_msg)
1715
1839
  return Path(self.path_fullwave_simulation_bin)
1716
1840
 
1841
+ def _check_binary_holds_source(self, binary: Path) -> None:
1842
+ """Refuse a binary that cannot hold the hard source, when the run needs it held.
1843
+
1844
+ Parameters
1845
+ ----------
1846
+ binary : Path
1847
+ The binary that runs the simulation.
1848
+
1849
+ """
1850
+ holds_source = (
1851
+ is_hard_held(self.source_type)
1852
+ and self.source.n_sources > 0
1853
+ and self.source.icmat.shape[1] < self.grid.nt
1854
+ )
1855
+ if holds_source:
1856
+ check_binary_holds_source(binary)
1857
+
1717
1858
  @contextlib.contextmanager
1718
1859
  def _binary_in_use(self, binary: Path) -> Iterator[None]:
1719
1860
  """Run the simulations of the block on ``binary``, then restore the Solver's binary.
@@ -1,4 +1,11 @@
1
- """Drive the source by addition rather than by assignment.
1
+ """Source types: hard, hard released after the drive, and soft.
2
+
3
+ ``"hard"`` assigns the source pressure for the whole run, so the source cells stay at
4
+ zero pressure after the drive signal ends. ``"hard_released_after"`` assigns the
5
+ pressure while the drive signal plays, then lets the cells evolve freely.
6
+ ``"soft"`` adds the signal to the field, as described below.
7
+
8
+ A soft source drives the source by addition rather than by assignment.
2
9
 
3
10
  A hard source sets the pressure of one grid row on every step, while the
4
11
  staggered derivative spans `2 * m_spatial_order` pressure points. One hard source row
@@ -19,14 +26,15 @@ from typing import TYPE_CHECKING
19
26
  import numpy as np
20
27
  from numpy.typing import NDArray
21
28
 
22
- from fullwave.source import Source
29
+ from fullwave.source import Source, additive_signal_scale
23
30
 
24
31
  if TYPE_CHECKING:
25
32
  import fullwave
26
33
 
27
34
  HARD = "hard"
35
+ HARD_RELEASED_AFTER = "hard_released_after"
28
36
  SOFT = "soft"
29
- SOURCE_TYPES = (HARD, SOFT)
37
+ SOURCE_TYPES = (HARD, HARD_RELEASED_AFTER, SOFT)
30
38
 
31
39
 
32
40
  def _is_on_the_device(array: object) -> bool:
@@ -62,6 +70,11 @@ def is_soft(source_type: str) -> bool:
62
70
  return source_type == SOFT
63
71
 
64
72
 
73
+ def is_hard_held(source_type: str) -> bool:
74
+ """Return whether the source cells stay at zero pressure after the drive ends."""
75
+ return source_type == HARD
76
+
77
+
65
78
  def source_row(coords: NDArray[np.int64], grid_shape: tuple[int, ...]) -> int:
66
79
  """Return the row the source occupies, and check that it fills one whole row.
67
80
 
@@ -101,15 +114,6 @@ def source_row(coords: NDArray[np.int64], grid_shape: tuple[int, ...]) -> int:
101
114
  return int(rows[0])
102
115
 
103
116
 
104
- def additive_signal_scale(
105
- sound_speed: NDArray[np.float64] | float,
106
- dt: float,
107
- dx: float,
108
- ) -> NDArray[np.float64] | float:
109
- """Return the factor that makes an additive source radiate its nominal pressure."""
110
- return 2.0 * sound_speed * dt / dx
111
-
112
-
113
117
  def node_sound_speeds(
114
118
  sound_speed: NDArray[np.float64] | float,
115
119
  coords: NDArray[np.int64],
fullwave/source.py CHANGED
@@ -18,6 +18,15 @@ if TYPE_CHECKING:
18
18
  logger = logging.getLogger("__main__." + __name__)
19
19
 
20
20
 
21
+ def additive_signal_scale(
22
+ sound_speed: NDArray[np.float64] | float,
23
+ dt: float,
24
+ dx: float,
25
+ ) -> NDArray[np.float64] | float:
26
+ """Return the factor that makes an additive source radiate its nominal pressure."""
27
+ return 2.0 * sound_speed * dt / dx
28
+
29
+
21
30
  @dataclass
22
31
  class Source:
23
32
  """Source class for Fullwave."""
@@ -272,8 +281,6 @@ class Source:
272
281
  A source with no assigned positions and a scaled additive signal.
273
282
 
274
283
  """
275
- from fullwave.solver.source_type import additive_signal_scale # noqa: PLC0415
276
-
277
284
  speed = float(grid.c0) if sound_speed is None else sound_speed
278
285
  scale = additive_signal_scale(speed, float(grid.dt), float(grid.dx))
279
286
  scale = np.atleast_1d(np.asarray(scale, dtype=float))