fullwave25 1.4.1.dev2__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.1-dev2"
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.5.2"
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)
@@ -4,6 +4,8 @@ import concurrent.futures
4
4
  import logging
5
5
  import os
6
6
  import time
7
+ from collections.abc import Sequence
8
+ from concurrent.futures import ThreadPoolExecutor, as_completed
7
9
  from pathlib import Path
8
10
 
9
11
  import numpy as np
@@ -63,6 +65,7 @@ class InputFileWriter:
63
65
  exponential_attenuation_pml_interior_offset_px: int = 0,
64
66
  use_gpu: bool = False,
65
67
  write_maps_the_solver_does_not_read: bool = False,
68
+ hold_hard_source: bool = False,
66
69
  ) -> None:
67
70
  """Initialize the InputGeneratorBase instance.
68
71
 
@@ -123,6 +126,11 @@ class InputFileWriter:
123
126
  which an older solver binary needs. The record the skipped maps
124
127
  replace is impedance_constraint.dat, and this writer always writes it
125
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.
126
134
 
127
135
  """
128
136
  logger.debug("Initializing InputFileWriter instance.")
@@ -138,6 +146,7 @@ class InputFileWriter:
138
146
  self.use_isotropic_relaxation = use_isotropic_relaxation
139
147
  self.write_maps_the_solver_does_not_read = write_maps_the_solver_does_not_read
140
148
  self.use_gpu = use_gpu
149
+ self.hold_hard_source = hold_hard_source
141
150
 
142
151
  if validate_input:
143
152
  check_functions.check_path_exists(self.path_fullwave_simulation_bin)
@@ -202,6 +211,7 @@ class InputFileWriter:
202
211
  *,
203
212
  is_static_map: bool = False,
204
213
  recalculate_pml: bool = True,
214
+ members: Sequence[fullwave.Source] | None = None,
205
215
  ) -> Path:
206
216
  r"""Run the input data generation and return the simulation directory path.
207
217
 
@@ -230,6 +240,11 @@ class InputFileWriter:
230
240
  set False if you are using the same PML parameters for each transmit event
231
241
  such as plane wave
232
242
  AND this is the second or later transmit event.
243
+ members : Sequence[fullwave.Source] | None
244
+ The transmits of a batch run, in order. Each member gives its own signals and
245
+ shares the coordinates of ``source``. The signals of all members are written one
246
+ after another, and nsims.dat holds the member count. None writes a single
247
+ transmit from ``source``.
233
248
 
234
249
  Returns
235
250
  -------
@@ -243,11 +258,19 @@ class InputFileWriter:
243
258
 
244
259
  self._init_pending_writes()
245
260
 
261
+ member_count_file = simulation_dir / "nsims.dat"
262
+ if members is None:
263
+ # A file left by an earlier batch run in this directory would turn this run into
264
+ # a batch.
265
+ member_count_file.unlink(missing_ok=True)
266
+ else:
267
+ self._queue_v_abs_write(np.int32, member_count_file, len(members))
268
+
246
269
  self._queue_ic_write(
247
270
  simulation_dir / "icmat.dat",
248
- np.transpose(self.source.icmat),
271
+ np.transpose(self._signals("icmat", members)),
249
272
  )
250
- p0_additive = getattr(self.source, "p0_additive", None)
273
+ p0_additive = self._signals("p0_additive", members)
251
274
  incoords_add = getattr(self.source, "incoords_add", None)
252
275
  if p0_additive is not None:
253
276
  self._queue_ic_write(
@@ -257,7 +280,7 @@ class InputFileWriter:
257
280
  if incoords_add is not None:
258
281
  self._queue_coords_write(simulation_dir / "icc_add.dat", incoords_add)
259
282
  for _vel_suffix, _vel_attr in (("u", "u0"), ("v", "v0"), ("w", "w0")):
260
- _signal_vel = getattr(self.source, _vel_attr, None)
283
+ _signal_vel = self._signals(_vel_attr, members)
261
284
  _incoords_vel = getattr(self.source, f"incoords_{_vel_suffix}", None)
262
285
  if _signal_vel is not None:
263
286
  self._queue_ic_write(
@@ -310,6 +333,44 @@ class InputFileWriter:
310
333
  logger.info(message)
311
334
  return simulation_dir
312
335
 
336
+ def _signals(
337
+ self,
338
+ name: str,
339
+ members: Sequence[fullwave.Source] | None,
340
+ ) -> np.ndarray | None:
341
+ """Return one source signal matrix, with the rows of every member stacked in order.
342
+
343
+ Parameters
344
+ ----------
345
+ name : str
346
+ The signal attribute of fullwave.Source: icmat, p0_additive, u0, v0 or w0.
347
+ members : Sequence[fullwave.Source] | None
348
+ The transmits of a batch run, or None for the single transmit of ``source``.
349
+
350
+ Returns
351
+ -------
352
+ np.ndarray | None
353
+ [n_points, n_t] for a single transmit, [n_members * n_points, n_t] for a batch,
354
+ or None when the source has no signal of this kind.
355
+
356
+ """
357
+ if members is None:
358
+ signal = getattr(self.source, name, None)
359
+ elif getattr(self.source, name, None) is None:
360
+ return None
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
+ )
373
+
313
374
  # --- constructor utils ---
314
375
 
315
376
  def _set_d_mat(self) -> None:
@@ -867,8 +928,6 @@ class InputFileWriter:
867
928
  (c_min_val, c_max_val) of the sound speed.
868
929
 
869
930
  """
870
- from concurrent.futures import ThreadPoolExecutor, as_completed # noqa: PLC0415
871
-
872
931
  import cupy as cp # noqa: PLC0415
873
932
 
874
933
  sound_speed_np = self.medium.sound_speed
@@ -1217,6 +1276,7 @@ class InputFileWriter:
1217
1276
  "ncoordsout",
1218
1277
  "ncoordszero",
1219
1278
  "nTic",
1279
+ "hold_source",
1220
1280
  "modT",
1221
1281
  "modX",
1222
1282
  "modY",
@@ -1315,7 +1375,6 @@ class InputFileWriter:
1315
1375
  self._queue_v_abs_write(np.float32, save_path, var)
1316
1376
 
1317
1377
  def _save_coords_params(self, simulation_dir: Path) -> None:
1318
- nt_ic = self.source.icmat.shape[1]
1319
1378
  var_list = [
1320
1379
  ("nX", self.grid.nx),
1321
1380
  ("nY", self.grid.ny),
@@ -1323,9 +1382,11 @@ class InputFileWriter:
1323
1382
  ("ncoords", self.source.n_sources),
1324
1383
  ("ncoordsout", self.sensor.n_sensors),
1325
1384
  ("ncoordszero", self.medium.n_air),
1326
- ("nTic", nt_ic),
1385
+ ("nTic", self.source.icmat.shape[1]),
1327
1386
  ("modT", self.sensor.sampling_modulus_time),
1328
1387
  ]
1388
+ if self._holds_the_source:
1389
+ var_list.append(("hold_source", 1))
1329
1390
  n_sources_add = getattr(self.source, "n_sources_add", 0)
1330
1391
  if n_sources_add > 0:
1331
1392
  var_list.append(("ncoords_add", n_sources_add))
@@ -1,5 +1,6 @@
1
1
  """Module for launching Fullwave simulation."""
2
2
 
3
+ import copy
3
4
  import ctypes
4
5
  import logging
5
6
  import os
@@ -12,6 +13,7 @@ from time import time
12
13
  import numpy as np
13
14
  from numpy.typing import NDArray
14
15
 
16
+ from .cuda_utils import find_consecutive_gpus_without_peer_access
15
17
  from .utils import load_dat_data
16
18
 
17
19
  logger = logging.getLogger("__main__." + __name__)
@@ -85,6 +87,26 @@ class Launcher:
85
87
  self.save_gpu_memory = save_gpu_memory
86
88
  logger.debug("Launcher instance created.")
87
89
 
90
+ def with_binary(self, path_fullwave_simulation_bin: Path) -> "Launcher":
91
+ """Return a copy of this launcher that runs another binary.
92
+
93
+ Parameters
94
+ ----------
95
+ path_fullwave_simulation_bin : Path
96
+ The binary of the copy.
97
+
98
+ Returns
99
+ -------
100
+ Launcher
101
+ The copy, with the same devices and memory settings.
102
+
103
+ """
104
+ error_msg = f"Fullwave simulation binary not found at {path_fullwave_simulation_bin}"
105
+ assert path_fullwave_simulation_bin.exists(), error_msg
106
+ launcher = copy.copy(self)
107
+ launcher._path_fullwave_simulation_bin = path_fullwave_simulation_bin
108
+ return launcher
109
+
88
110
  @staticmethod
89
111
  def _parse_cuda_device_id(cuda_device_id: str | int | list | None) -> str:
90
112
  """Parse the CUDA device ID input into a string format.
@@ -190,8 +212,6 @@ class Launcher:
190
212
  )
191
213
  return
192
214
  try:
193
- from .cuda_utils import find_consecutive_gpus_without_peer_access # noqa: PLC0415
194
-
195
215
  pairs = find_consecutive_gpus_without_peer_access(device_ids)
196
216
  except (AttributeError, NameError, OSError, RuntimeError, ctypes.ArgumentError) as error:
197
217
  logger.debug("Peer access check skipped: %s", error)