fullwave25 1.4.2.dev3__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-dev3"
69
+ __version__ = "1.4.2-dev4"
70
70
 
71
71
  VERSION = __version__ # for convenience
72
72
  logger.info("Fullwave version: %s", __version__)
@@ -14,12 +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.
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.
23
24
 
24
25
  References
25
26
  ----------
@@ -38,6 +39,7 @@ class Beamformer:
38
39
  axial_position_m: np.ndarray,
39
40
  num_elements: int,
40
41
  transducer_coordinates: np.ndarray,
42
+ face_row_px: int,
41
43
  f_number: float = 1.0,
42
44
  ) -> None:
43
45
  """Initialize the beamformer with simulation and transducer parameters.
@@ -53,11 +55,14 @@ class Beamformer:
53
55
  lateral_position_m : np.ndarray
54
56
  Lateral positions (m) where the beamformed image will be computed.
55
57
  axial_position_m : np.ndarray
56
- Axial positions (m) from the top of the grid where the image is computed.
58
+ Depths (m) below the probe face where the image is computed.
57
59
  num_elements : int
58
60
  Number of transducer elements.
59
61
  transducer_coordinates : np.ndarray
60
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``.
61
66
  f_number : float, optional
62
67
  F-number for aperture calculation, by default 1.0.
63
68
 
@@ -69,6 +74,7 @@ class Beamformer:
69
74
  self.axial_position_m = axial_position_m
70
75
  self.num_elements = num_elements
71
76
  self.transducer_coordinates = transducer_coordinates
77
+ self.face_row_px = face_row_px
72
78
  self.f_number = f_number
73
79
 
74
80
  def run(self, signals: np.ndarray) -> np.ndarray:
@@ -83,7 +89,9 @@ class Beamformer:
83
89
  Returns
84
90
  -------
85
91
  np.ndarray
86
- 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.
87
95
 
88
96
  """
89
97
  # signals: [n_elements, n_time, n_transmit]
@@ -110,12 +118,13 @@ class Beamformer:
110
118
  desc="Lateral positions",
111
119
  total=len(self.lateral_position_m),
112
120
  ):
113
- for i_axial, axial in tqdm(
121
+ for i_axial, depth in tqdm(
114
122
  enumerate(self.axial_position_m),
115
123
  desc="Axial positions",
116
124
  total=len(self.axial_position_m),
117
125
  leave=False,
118
126
  ):
127
+ axial = self.face_row_px * self.dx + depth
119
128
  fcen = np.array(
120
129
  [
121
130
  int(axial / self.dx),
@@ -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)