fullwave25 1.4.2.dev2__py3-none-any.whl → 1.4.2.dev4__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-dev2"
69
+ __version__ = "1.4.2-dev4"
70
70
 
71
71
  VERSION = __version__ # for convenience
72
72
  logger.info("Fullwave version: %s", __version__)
@@ -14,6 +14,14 @@ 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 the depth below the probe face, so
18
+ depth 0 is the face row ``face_row_px`` (``Transducer.face_row_px``, the
19
+ first medium row, one row in front of the receiver row).
20
+ ``transducer_coordinates`` must be the receiver positions in grid points
21
+ (for example from ``Transducer.element_id_to_receiver_center``). The time
22
+ origin is the mean receiver row, so the array depth is subtracted from the
23
+ two-way travel time.
24
+
17
25
  References
18
26
  ----------
19
27
  - Fullwave2 BMME890 implementation
@@ -31,6 +39,7 @@ class Beamformer:
31
39
  axial_position_m: np.ndarray,
32
40
  num_elements: int,
33
41
  transducer_coordinates: np.ndarray,
42
+ face_row_px: int,
34
43
  f_number: float = 1.0,
35
44
  ) -> None:
36
45
  """Initialize the beamformer with simulation and transducer parameters.
@@ -46,11 +55,14 @@ class Beamformer:
46
55
  lateral_position_m : np.ndarray
47
56
  Lateral positions (m) where the beamformed image will be computed.
48
57
  axial_position_m : np.ndarray
49
- Axial positions (m) where the beamformed image will be computed.
58
+ Depths (m) below the probe face where the image is computed.
50
59
  num_elements : int
51
60
  Number of transducer elements.
52
61
  transducer_coordinates : np.ndarray
53
- Coordinates of the transducer elements (m).
62
+ Receiver coordinates [n_elements, 2] in grid points (row, column).
63
+ face_row_px : int
64
+ Grid row of the probe face (depth 0), for example
65
+ ``Transducer.face_row_px``.
54
66
  f_number : float, optional
55
67
  F-number for aperture calculation, by default 1.0.
56
68
 
@@ -62,6 +74,7 @@ class Beamformer:
62
74
  self.axial_position_m = axial_position_m
63
75
  self.num_elements = num_elements
64
76
  self.transducer_coordinates = transducer_coordinates
77
+ self.face_row_px = face_row_px
65
78
  self.f_number = f_number
66
79
 
67
80
  def run(self, signals: np.ndarray) -> np.ndarray:
@@ -76,13 +89,17 @@ class Beamformer:
76
89
  Returns
77
90
  -------
78
91
  np.ndarray
79
- Beamformed image. Shape: [n_axial, n_lateral, n_transmit].
92
+ Beamformed image. Shape: [n_axial, n_lateral, n_transmit]. Row 0
93
+ of the first axis is the first depth of ``axial_position_m``, which
94
+ is measured from the probe face.
80
95
 
81
96
  """
82
97
  # signals: [n_elements, n_time, n_transmit]
83
98
  if signals.ndim == 2:
84
99
  signals = signals[:, :, np.newaxis]
85
100
  n_transmit = signals.shape[2]
101
+ # idt_0 is the pulse arrival at the receivers, so time is measured from the array.
102
+ z_array = np.mean(self.transducer_coordinates[:, 0]) * self.dx
86
103
 
87
104
  hilbert_signals = hilbert(signals, axis=1)
88
105
  beamformed_image = np.zeros(
@@ -101,12 +118,13 @@ class Beamformer:
101
118
  desc="Lateral positions",
102
119
  total=len(self.lateral_position_m),
103
120
  ):
104
- for i_axial, axial in tqdm(
121
+ for i_axial, depth in tqdm(
105
122
  enumerate(self.axial_position_m),
106
123
  desc="Axial positions",
107
124
  total=len(self.axial_position_m),
108
125
  leave=False,
109
126
  ):
127
+ axial = self.face_row_px * self.dx + depth
110
128
  fcen = np.array(
111
129
  [
112
130
  int(axial / self.dx),
@@ -133,7 +151,7 @@ class Beamformer:
133
151
  dd = np.round(dd / (self.dt / self.dx * self.c0)).astype(int)
134
152
  dd = dd - dd.min()
135
153
 
136
- idt = idt_0 + int(2 * axial / self.c0 / self.dt)
154
+ idt = idt_0 + int(2 * (axial - z_array) / self.c0 / self.dt)
137
155
 
138
156
  # idp: 1D indices into signals flattened array
139
157
  # we convert 2D indices to 1D indices
@@ -293,6 +293,7 @@ class InputFileWriter:
293
293
  _incoords_vel,
294
294
  )
295
295
  self._queue_coords_write(simulation_dir / "icc.dat", self.source.incoords)
296
+ self._save_drive_params(simulation_dir, members)
296
297
  self._copy_simulation_bin_file(simulation_dir)
297
298
 
298
299
  if not self.use_exponential_attenuation:
@@ -1275,8 +1276,6 @@ class InputFileWriter:
1275
1276
  "ncoords_w",
1276
1277
  "ncoordsout",
1277
1278
  "ncoordszero",
1278
- "nTic",
1279
- "hold_source",
1280
1279
  "modT",
1281
1280
  "modX",
1282
1281
  "modY",
@@ -1374,6 +1373,27 @@ class InputFileWriter:
1374
1373
  save_path = simulation_dir / f"{var_name}.dat"
1375
1374
  self._queue_v_abs_write(np.float32, save_path, var)
1376
1375
 
1376
+ def _save_drive_params(
1377
+ self,
1378
+ simulation_dir: Path,
1379
+ members: Sequence[fullwave.Source] | None = None,
1380
+ ) -> None:
1381
+ """Write the drive length and the hold flag of this transmit.
1382
+
1383
+ Every transmit folder gets its own files, never a link to the shared
1384
+ static-map files, and a flag left by an earlier run is removed when the
1385
+ hold is off. The members of a batch share one length, which can exceed the
1386
+ length of the source, so the drive length is the one of the members.
1387
+ """
1388
+ drive_length_file = simulation_dir / "nTic.dat"
1389
+ hold_flag_file = simulation_dir / "hold_source.dat"
1390
+ drive_length_file.unlink(missing_ok=True)
1391
+ hold_flag_file.unlink(missing_ok=True)
1392
+ drive_source = self.source if members is None else members[0]
1393
+ self._queue_v_abs_write(np.int32, drive_length_file, drive_source.icmat.shape[1])
1394
+ if self._holds_the_source:
1395
+ self._queue_v_abs_write(np.int32, hold_flag_file, 1)
1396
+
1377
1397
  def _save_coords_params(self, simulation_dir: Path) -> None:
1378
1398
  var_list = [
1379
1399
  ("nX", self.grid.nx),
@@ -1382,11 +1402,8 @@ class InputFileWriter:
1382
1402
  ("ncoords", self.source.n_sources),
1383
1403
  ("ncoordsout", self.sensor.n_sensors),
1384
1404
  ("ncoordszero", self.medium.n_air),
1385
- ("nTic", self.source.icmat.shape[1]),
1386
1405
  ("modT", self.sensor.sampling_modulus_time),
1387
1406
  ]
1388
- if self._holds_the_source:
1389
- var_list.append(("hold_source", 1))
1390
1407
  n_sources_add = getattr(self.source, "n_sources_add", 0)
1391
1408
  if n_sources_add > 0:
1392
1409
  var_list.append(("ncoords_add", n_sources_add))
fullwave/solver/solver.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """solver module."""
2
2
 
3
3
  import contextlib
4
+ import copy
4
5
  import logging
5
6
  import os
6
7
  import time
@@ -22,6 +23,7 @@ from fullwave.solver.source_type import (
22
23
  is_hard_held,
23
24
  is_soft,
24
25
  )
26
+ from fullwave.ultra import attenuation_parameters as power_law_attenuation
25
27
  from fullwave.ultra import backend, binary, factory
26
28
  from fullwave.utils import (
27
29
  check_functions,
@@ -470,10 +472,12 @@ class Solver:
470
472
  verify_gpu: bool = True,
471
473
  use_gpu_pml: bool = False,
472
474
  pml_alpha_entrance: float | None = None,
473
- source_type: str = HARD_RELEASED_AFTER,
475
+ source_type: str | None = None,
474
476
  write_maps_the_solver_does_not_read: bool = False,
475
477
  use_ultra: bool = False,
476
478
  ultra_sponge_thickness_px: int | None = None,
479
+ ultra_attenuation_parameters: str | Path | None = None,
480
+ ultra_unlimited_materials: bool = False,
477
481
  ) -> None:
478
482
  """Initialize a Solver instance for the fullwave simulation.
479
483
 
@@ -599,12 +603,14 @@ class Solver:
599
603
  relaxation path. Set it to True for a solver binary that predates
600
604
  that record. Such a binary stops with an error on kappax.dat rather
601
605
  than returning a wrong answer.
602
- source_type : str, optional
603
- How the source drives the field. "hard_released_after", the default,
604
- assigns the source pressure while the drive signal plays, then lets the
605
- source cells evolve freely. "hard" assigns it for the whole run, so the
606
- cells stay at zero pressure after the signal ends. "soft" adds the signal
607
- to the field. ``use_ultra`` follows the same three values.
606
+ source_type : str | None, optional
607
+ How the source drives the field. None, the default, takes the type of the
608
+ ``transducer`` when one is given, and "hard_released_after" otherwise. A
609
+ value that differs from the transducer's type raises ValueError.
610
+ "hard_released_after" assigns the source pressure while the drive signal
611
+ plays, then lets the source cells evolve freely. "hard" assigns it for the
612
+ whole run, so the cells stay at zero pressure after the signal ends.
613
+ "soft" adds the signal to the field. ``use_ultra`` follows the same three values.
608
614
  use_ultra : bool, optional
609
615
  Experimental. Run a user-supplied fullwave2-ultra binary (not shipped; pass it
610
616
  as ``path_fullwave_simulation_bin`` or set ``FW2U_BATCH_BIN``): ``bench_2d_batch``
@@ -620,6 +626,13 @@ class Solver:
620
626
  Width of the absorbing sponge of the ``use_ultra`` backend, in cells.
621
627
  None, the default, gives 6 wavelengths in 2D and 4 wavelengths in 3D.
622
628
  Used only with ``use_ultra=True``.
629
+ ultra_attenuation_parameters : str | Path, optional
630
+ Power-law attenuation for the ``use_ultra`` backend. Give "centered", "author",
631
+ or the path of a parameter file. Needs ``use_ultra=True`` and
632
+ ``use_exponential_attenuation=False``.
633
+ ultra_unlimited_materials : bool, optional
634
+ If True, allow any number of distinct materials (uses more GPU memory). Needs
635
+ ``ultra_attenuation_parameters``.
623
636
 
624
637
  Raises
625
638
  ------
@@ -630,9 +643,28 @@ class Solver:
630
643
  and transducer) are defined simultaneously.
631
644
 
632
645
  """
646
+ source_type = self._resolve_source_type(source_type=source_type, transducer=transducer)
633
647
  self.ultra = None
648
+ power_law_attenuation.check_unlimited_materials(
649
+ unlimited_materials=ultra_unlimited_materials,
650
+ parameters=ultra_attenuation_parameters,
651
+ use_ultra=use_ultra,
652
+ )
653
+ if ultra_attenuation_parameters is not None:
654
+ request = power_law_attenuation.check_request(
655
+ parameters=ultra_attenuation_parameters,
656
+ use_ultra=use_ultra,
657
+ use_exponential_attenuation=use_exponential_attenuation,
658
+ binary=path_fullwave_simulation_bin,
659
+ is_3d=grid.is_3d,
660
+ unlimited_materials=ultra_unlimited_materials,
661
+ )
662
+ ultra_attenuation_parameters = request.parameter_file
663
+ path_fullwave_simulation_bin = request.binary
634
664
  if use_ultra:
635
665
  self._init_ultra(
666
+ attenuation_parameters=ultra_attenuation_parameters,
667
+ unlimited_materials=ultra_unlimited_materials,
636
668
  work_dir=work_dir,
637
669
  grid=grid,
638
670
  medium=medium,
@@ -813,7 +845,12 @@ class Solver:
813
845
  logger.error(error_msg)
814
846
  raise ValueError(error_msg)
815
847
  self.source_type = source_type
816
- if is_soft(source_type) and self.source.p0_additive is not None:
848
+ source_is_the_transducers = source is None and transducer is not None
849
+ if is_soft(source_type) and source_is_the_transducers:
850
+ logger.info(
851
+ "source_type=%s, the transducer's source is driven by addition", source_type
852
+ )
853
+ elif is_soft(source_type) and self.source.p0_additive is not None:
817
854
  error_msg = (
818
855
  "this source already carries an additive signal. source_type='soft' "
819
856
  "converts a hard source into a soft one, and this source holds no "
@@ -824,7 +861,7 @@ class Solver:
824
861
  raise ValueError(error_msg)
825
862
  if not is_soft(source_type) and self.source.p0_additive is not None:
826
863
  self._warn_if_the_additive_signal_was_not_scaled()
827
- if is_soft(source_type):
864
+ if is_soft(source_type) and not source_is_the_transducers:
828
865
  self.source = as_additive_source(
829
866
  self.source,
830
867
  self.grid,
@@ -898,11 +935,15 @@ class Solver:
898
935
  verify_gpu: bool,
899
936
  nbdy: int | None,
900
937
  options: dict,
938
+ attenuation_parameters: Path | None,
939
+ unlimited_materials: bool,
901
940
  ) -> None:
902
941
  """Set up the experimental fullwave2-ultra backend (see ``fullwave.ultra``)."""
903
942
  backend.warn_experimental()
904
943
  backend.warn_ppw_above_speed_optimum(ppw=grid.ppw)
905
- backend.check_solver_options(**options)
944
+ backend.check_solver_options(
945
+ **options, has_attenuation_parameters=attenuation_parameters is not None
946
+ )
906
947
  binary_name = binary.BINARY_NAME_3D if grid.is_3d else binary.BINARY_NAME_2D
907
948
  ultra_binary = binary.resolve_binary(path_fullwave_simulation_bin, name=binary_name)
908
949
  self._check_input(grid, medium, source, sensor, transducer, ultra_binary)
@@ -924,8 +965,48 @@ class Solver:
924
965
  cuda_device_id=cuda_device_id,
925
966
  verify_gpu=verify_gpu,
926
967
  source_type=options["source_type"],
968
+ attenuation_parameters=attenuation_parameters,
969
+ unlimited_materials=unlimited_materials,
927
970
  )
928
971
 
972
+ @staticmethod
973
+ def _resolve_source_type(
974
+ *, source_type: str | None, transducer: fullwave.Transducer | None
975
+ ) -> str:
976
+ """Return the source type of the run.
977
+
978
+ Parameters
979
+ ----------
980
+ source_type : str | None
981
+ The Solver's own argument.
982
+ transducer : fullwave.Transducer | None
983
+ The transducer that drives the run, if any.
984
+
985
+ Returns
986
+ -------
987
+ str
988
+ The transducer's type when the argument is None and a transducer is given,
989
+ the argument when it is given, and "hard_released_after" otherwise.
990
+
991
+ Raises
992
+ ------
993
+ ValueError
994
+ If the argument differs from the transducer's type.
995
+ """
996
+ if transducer is None:
997
+ return HARD_RELEASED_AFTER if source_type is None else source_type
998
+ if source_type is None:
999
+ return transducer.source_type
1000
+ if source_type != transducer.source_type:
1001
+ error_msg = (
1002
+ f"Solver source_type {source_type!r} differs from the transducer's "
1003
+ f"source_type {transducer.source_type!r}. Set the source type in one "
1004
+ "place, on the Transducer, and leave the Solver's source_type unset."
1005
+ )
1006
+ logger.error(error_msg)
1007
+ raise ValueError(error_msg)
1008
+ return source_type
1009
+
929
1010
  def _warn_if_the_additive_signal_was_not_scaled(self) -> None:
930
1011
  """Say so when an additive signal was built by hand and may carry no scale.
931
1012
 
@@ -1541,8 +1622,10 @@ class Solver:
1541
1622
  Parameters
1542
1623
  ----------
1543
1624
  sources : Sequence[fullwave.Source]
1544
- The transmits, in order. Each one must have the same source coordinates,
1545
- the same signal kinds and the same signal length as the Solver's source.
1625
+ The transmits, in order. Each one must have the same source coordinates
1626
+ and the same signal kinds as the Solver's source. The signals may differ in
1627
+ length and are padded with trailing zeros to the longest drive; with
1628
+ ``use_ultra=True`` they must have one length.
1546
1629
  The Solver's ``source_type`` applies to every transmit.
1547
1630
  simulation_dir_name : str | Path
1548
1631
  The directory name of the simulation under the work directory.
@@ -1604,7 +1687,7 @@ class Solver:
1604
1687
  highpass_cutoff_mhz=highpass_cutoff_mhz,
1605
1688
  bandpass_cutoff_mhz=bandpass_cutoff_mhz,
1606
1689
  )
1607
- members = self._batch_members(sources)
1690
+ members = self._batch_members(self._pad_to_longest_drive(sources))
1608
1691
  # The sensor and the sizing below need the extended grid and medium.
1609
1692
  self.pml_builder.run(use_pml=self.use_pml)
1610
1693
  sensor = self._output_sensor(
@@ -1647,6 +1730,43 @@ class Solver:
1647
1730
  return [path for run_paths in outputs for path in run_paths]
1648
1731
  return np.concatenate(outputs, axis=0)
1649
1732
 
1733
+ def _pad_to_longest_drive(self, sources: Sequence[fullwave.Source]) -> list[fullwave.Source]:
1734
+ """Return the transmits with every signal padded with zeros to the longest drive.
1735
+
1736
+ The longest drive is the longest signal among the transmits and the Solver's
1737
+ source. The transmits are copied; the given sources stay as they are.
1738
+
1739
+ Parameters
1740
+ ----------
1741
+ sources : Sequence[fullwave.Source]
1742
+ The transmits, in order.
1743
+
1744
+ Returns
1745
+ -------
1746
+ list[fullwave.Source]
1747
+ The transmits, with signals of equal length.
1748
+
1749
+ """
1750
+ names = ("p0", "p0_additive", "u0", "v0", "w0")
1751
+ reference = [self.source] if getattr(self, "source", None) is not None else []
1752
+ padded = []
1753
+ for source in sources:
1754
+ member = copy.copy(source)
1755
+ for name in names:
1756
+ signal = getattr(member, name, None)
1757
+ if signal is None:
1758
+ continue
1759
+ length = max(
1760
+ np.shape(getattr(other, name, None))[-1]
1761
+ for other in [*sources, *reference]
1762
+ if getattr(other, name, None) is not None
1763
+ )
1764
+ if np.shape(signal)[-1] < length:
1765
+ pad = [(0, 0)] * (np.ndim(signal) - 1) + [(0, length - np.shape(signal)[-1])]
1766
+ setattr(member, name, np.pad(signal, pad))
1767
+ padded.append(member)
1768
+ return padded
1769
+
1650
1770
  def _batch_members(self, sources: Sequence[fullwave.Source]) -> list[fullwave.Source]:
1651
1771
  """Check the transmits of a batch against the Solver's source and return them.
1652
1772
 
@@ -1730,7 +1850,10 @@ class Solver:
1730
1850
  return f"the {kind} signal is present in only one of them"
1731
1851
  if expected is None:
1732
1852
  continue
1733
- if np.shape(found) != np.shape(expected):
1853
+ if (
1854
+ np.shape(found)[:-1] != np.shape(expected)[:-1]
1855
+ or np.shape(found)[-1] < np.shape(expected)[-1]
1856
+ ):
1734
1857
  return f"the {kind} signal shape {np.shape(found)} != {np.shape(expected)}"
1735
1858
  expected_coords = getattr(self.source, coords, None)
1736
1859
  found_coords = getattr(member, coords, None)