fullwave25 1.2.6.dev0__tar.gz → 1.2.6.dev2__tar.gz

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.
Files changed (72) hide show
  1. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/.bumpversion.toml +1 -1
  2. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/PKG-INFO +1 -1
  3. fullwave25-1.2.6.dev2/debug_solver_bin/fullwave2_2d_exponential_attenuation_multi_gpu_cuda124 +0 -0
  4. fullwave25-1.2.6.dev2/debug_solver_bin/fullwave2_3d_exponential_attenuation_multi_gpu_cuda124 +0 -0
  5. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/__init__.py +1 -1
  6. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/sensor.py +70 -6
  7. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/binary_manager.py +1 -1
  8. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/input_file_writer.py +29 -0
  9. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/pml_builder.py +34 -16
  10. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/solver.py +131 -18
  11. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/utils/__init__.py +2 -1
  12. fullwave25-1.2.6.dev2/fullwave/utils/signal_filter.py +161 -0
  13. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/pyproject.toml +1 -1
  14. fullwave25-1.2.6.dev2/signal_filter_example.png +0 -0
  15. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/uv.lock +1 -1
  16. fullwave25-1.2.6.dev0/debug_solver_bin/fullwave2_2d_2_relax_multi_gpu_cuda124 +0 -0
  17. fullwave25-1.2.6.dev0/debug_solver_bin/fullwave2_2d_exponential_attenuation_multi_gpu +0 -0
  18. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/.gitignore +0 -0
  19. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/.pre-commit-config.yaml +0 -0
  20. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/.python-version +0 -0
  21. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/20260120_0844.log +0 -0
  22. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/CITATION.cff +0 -0
  23. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/CLAUDE.md +0 -0
  24. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/CONTRIBUTING.md +0 -0
  25. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/LICENSE +0 -0
  26. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/Makefile +0 -0
  27. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/README.md +0 -0
  28. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/beamformer.m +0 -0
  29. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/experiments/__init__.py +0 -0
  30. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/experiments/exp_scatterer_diameter.py +0 -0
  31. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/beamformer/__init__.py +0 -0
  32. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/beamformer/beamformer.py +0 -0
  33. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/constants/__init__.py +0 -0
  34. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/constants/material_properties.py +0 -0
  35. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/grid.py +0 -0
  36. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium.py +0 -0
  37. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium_builder/__init__.py +0 -0
  38. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium_builder/domain.py +0 -0
  39. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium_builder/medium_builder.py +0 -0
  40. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium_builder/presets/__init__.py +0 -0
  41. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium_builder/presets/data/.keep +0 -0
  42. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium_builder/presets/data/abdominal_wall/i2365f_etfw1.mat +0 -0
  43. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium_builder/presets/domain_abdominal_wall.py +0 -0
  44. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium_builder/presets/domain_background.py +0 -0
  45. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium_builder/presets/domain_scatterer.py +0 -0
  46. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium_builder/presets/domain_simple.py +0 -0
  47. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/medium_builder/presets/domain_water_gel.py +0 -0
  48. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/__init__.py +0 -0
  49. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/bins/_gpu/2d/num_relax=2/fullwave2_2d_2_relax_multi_gpu_cuda118 +0 -0
  50. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/bins/_gpu/2d/num_relax=2/fullwave2_2d_2_relax_multi_gpu_cuda124 +0 -0
  51. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/bins/_gpu/2d/num_relax=2/fullwave2_2d_2_relax_multi_gpu_cuda129 +0 -0
  52. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/bins/_gpu/2d/num_relax=2/fullwave2_2d_2_relax_multi_gpu_cuda130 +0 -0
  53. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/bins/_gpu/3d/num_relax=2/fullwave2_3d_2_relax_multi_gpu_cuda118 +0 -0
  54. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/bins/_gpu/3d/num_relax=2/fullwave2_3d_2_relax_multi_gpu_cuda124 +0 -0
  55. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/bins/_gpu/3d/num_relax=2/fullwave2_3d_2_relax_multi_gpu_cuda129 +0 -0
  56. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/bins/_gpu/3d/num_relax=2/fullwave2_3d_2_relax_multi_gpu_cuda130 +0 -0
  57. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/bins/database/relaxation_params_database_num_relax=2_20260113_0957.mat +0 -0
  58. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/cuda_utils.py +0 -0
  59. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/launcher.py +0 -0
  60. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/solver/utils.py +0 -0
  61. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/source.py +0 -0
  62. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/transducer.py +0 -0
  63. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/utils/check_functions.py +0 -0
  64. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/utils/coordinates.py +0 -0
  65. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/utils/memory_tempfile.py +0 -0
  66. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/utils/numerical.py +0 -0
  67. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/utils/plot_utils.py +0 -0
  68. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/utils/pulse.py +0 -0
  69. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/utils/relaxation_parameters.py +0 -0
  70. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/utils/scatterer.py +0 -0
  71. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/fullwave/utils/signal_process.py +0 -0
  72. {fullwave25-1.2.6.dev0 → fullwave25-1.2.6.dev2}/ruff.toml +0 -0
@@ -1,5 +1,5 @@
1
1
  [tool.bumpversion]
2
- current_version = "1.2.6-dev0"
2
+ current_version = "1.2.6-dev2"
3
3
  parse = """(?x)
4
4
  (?P<major>0|[1-9]\\d*)\\.
5
5
  (?P<minor>0|[1-9]\\d*)\\.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fullwave25
3
- Version: 1.2.6.dev0
3
+ Version: 1.2.6.dev2
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
@@ -60,7 +60,7 @@ try:
60
60
  __version__ = version("fullwave")
61
61
  except PackageNotFoundError:
62
62
  # Update via bump-my-version, not manually
63
- __version__ = "1.2.6-dev0"
63
+ __version__ = "1.2.6-dev2"
64
64
 
65
65
  VERSION = __version__ # for convenience
66
66
  logger.info("Fullwave version: %s", __version__)
@@ -21,13 +21,16 @@ class Sensor:
21
21
  outcoords: NDArray[np.int64]
22
22
  sampling_modulus_time: int = 1
23
23
 
24
- def __init__(
24
+ def __init__( # noqa: PLR0915
25
25
  self,
26
26
  mask: NDArray[np.bool] | None = None,
27
27
  sampling_modulus_time: int = 1,
28
28
  *,
29
29
  coords: NDArray[np.int64] | None = None,
30
30
  grid_shape: tuple[int, ...] | None = None,
31
+ mod_x: int | None = None,
32
+ mod_y: int | None = None,
33
+ mod_z: int = 0,
31
34
  ) -> None:
32
35
  """Sensor class for Fullwave.
33
36
 
@@ -36,7 +39,7 @@ class Sensor:
36
39
  mask : NDArray[np.bool] | None
37
40
  Binary matrix where the pressure is recorded at each time-step
38
41
  shape: [nx, ny] for 2D, [nx, ny, nz] for 3D.
39
- Mutually exclusive with coords/grid_shape.
42
+ Mutually exclusive with coords/grid_shape and mod_x/mod_y.
40
43
  sampling_modulus_time: int
41
44
  Sampling modulus in time. Default is 1 (record at every time step).
42
45
  Changing this value to n will record the pressure every n time steps.
@@ -46,39 +49,95 @@ class Sensor:
46
49
  Must be provided together with grid_shape.
47
50
  grid_shape : tuple[int, ...] | None
48
51
  Shape of the computational grid. Required when using coords input.
52
+ mod_x : int | None
53
+ Spatial decimation stride in x (depth) for sparse-grid sensor output.
54
+ When provided together with mod_y, activates sparse-grid mode.
55
+ In this mode the solver binary generates sensor positions automatically
56
+ as [::mod_x, ::mod_y] (and [::mod_z] for 3D) across the domain,
57
+ ignoring any explicit coords/mask. Mutually exclusive with mask/coords.
58
+ mod_y : int | None
59
+ Spatial decimation stride in y (lateral). Must be provided with mod_x.
60
+ mod_z : int
61
+ Spatial decimation stride in z (elevational). Only relevant in 3D simulations.
62
+ Providing mod_z > 0 together with mod_x and mod_y creates a 3D sparse sensor.
63
+ Default 0 (2D sensor, or no z-subsampling when used in a 3D run).
49
64
 
50
65
  Raises
51
66
  ------
52
67
  ValueError
53
68
  If grid_shape is not provided when using coords input.
54
69
  If both mask and coords are provided (mutually exclusive).
55
- If neither mask nor coords (with grid_shape) is provided.
70
+ If only one of mod_x / mod_y is provided.
71
+ If mod_x/mod_y are mixed with mask or coords.
72
+ If none of the three input modes is supplied.
56
73
 
57
74
  """
58
- if coords is not None:
75
+ if mod_x is not None or mod_y is not None:
76
+ # --- sparse-grid mode ---
77
+ if mod_x is None or mod_y is None:
78
+ msg = "Both mod_x and mod_y must be provided for sparse-grid sensor mode"
79
+ raise ValueError(msg)
80
+ if mask is not None or coords is not None:
81
+ msg = "mod_x/mod_y are mutually exclusive with mask and coords"
82
+ raise ValueError(msg)
83
+ if mod_x <= 0 or mod_y <= 0:
84
+ msg = "mod_x and mod_y must be positive integers"
85
+ raise ValueError(msg)
86
+ if mod_z < 0:
87
+ msg = "mod_z must be a non-negative integer"
88
+ raise ValueError(msg)
89
+ self.is_sparse_grid = True
90
+ self.mod_x = mod_x
91
+ self.mod_y = mod_y
92
+ self.mod_z = mod_z
93
+ self.is_3d = mod_z > 0
94
+ ndim = 3 if self.is_3d else 2
95
+ # Empty placeholder — the binary computes positions from mod values.
96
+ self.outcoords = np.empty((0, ndim), dtype=np.int64)
97
+ self.grid_shape = None
98
+ elif coords is not None:
59
99
  if grid_shape is None:
60
100
  msg = "grid_shape is required when using coords input"
61
101
  raise ValueError(msg)
62
102
  if mask is not None:
63
103
  msg = "mask and coords are mutually exclusive"
64
104
  raise ValueError(msg)
105
+ self.is_sparse_grid = False
106
+ self.mod_x = 0
107
+ self.mod_y = 0
108
+ self.mod_z = 0
65
109
  self.outcoords = np.atleast_2d(coords).astype(np.int64, copy=False)
66
110
  self.grid_shape = tuple(grid_shape)
111
+ self.is_3d = len(self.grid_shape) == 3
67
112
  elif mask is not None:
113
+ self.is_sparse_grid = False
114
+ self.mod_x = 0
115
+ self.mod_y = 0
116
+ self.mod_z = 0
68
117
  mask = np.atleast_2d(mask)
69
118
  self.grid_shape = mask.shape
70
119
  self.outcoords = map_to_coords(mask)
120
+ self.is_3d = len(self.grid_shape) == 3
71
121
  else:
72
- msg = "Either mask or coords (with grid_shape) must be provided"
122
+ msg = "Either mask, coords (with grid_shape), or mod_x with mod_y must be provided"
73
123
  raise ValueError(msg)
74
124
 
75
125
  self.sampling_modulus_time = sampling_modulus_time
76
- self.is_3d = len(self.grid_shape) == 3
77
126
  super().__init__()
78
127
  logger.debug("Sensor instance created.")
79
128
 
80
129
  def validate(self, grid_shape: NDArray[np.int64] | tuple) -> None:
81
130
  """Check if the sensor coordinates are consistent with the grid shape."""
131
+ if self.is_sparse_grid:
132
+ grid_shape = tuple(grid_shape) if isinstance(grid_shape, np.ndarray) else grid_shape
133
+ if len(grid_shape) == 3 and self.mod_z == 0:
134
+ msg = (
135
+ "Sparse-grid sensor used in a 3D simulation but mod_z was not provided. "
136
+ "Pass mod_z > 0 to Sensor (e.g. Sensor(mod_x=4, mod_y=4, mod_z=4))."
137
+ )
138
+ raise ValueError(msg)
139
+ logger.debug("Sparse-grid sensor validated.")
140
+ return
82
141
  grid_shape = tuple(grid_shape) if isinstance(grid_shape, np.ndarray) else grid_shape
83
142
  assert self.grid_shape == grid_shape, f"{self.grid_shape} != {grid_shape}"
84
143
  assert self.n_sensors > 0, "No active sensor found."
@@ -173,6 +232,11 @@ class Sensor:
173
232
  Formatted string containing source information.
174
233
 
175
234
  """
235
+ if self.is_sparse_grid:
236
+ mod_str = f"mod_x={self.mod_x}, mod_y={self.mod_y}"
237
+ if self.is_3d:
238
+ mod_str += f", mod_z={self.mod_z}"
239
+ return f"Sensor (sparse-grid): \n Strides: {mod_str}\n Is 3D: {self.is_3d}\n"
176
240
  return (
177
241
  f"Sensor: \n"
178
242
  f" Number of sensors: {self.n_sensors}\n"
@@ -28,7 +28,7 @@ CACHE_DIR = Path.home() / ".cache" / "fullwave25" / "bins"
28
28
 
29
29
  # Pinned release tag for the solver binaries.
30
30
  # Update this only when new binaries are uploaded to a GitHub release.
31
- BINARY_RELEASE_TAG = "fullwave_bin_v1.2"
31
+ BINARY_RELEASE_TAG = "fullwave_bin_v1.3"
32
32
 
33
33
 
34
34
  def _download_url(filename: str, tag: str) -> str:
@@ -36,6 +36,7 @@ class InputFileWriter:
36
36
  use_exponential_attenuation: bool = False,
37
37
  use_isotropic_relaxation: bool = False,
38
38
  release_after_write: bool = False,
39
+ pml_thickness: int = 0,
39
40
  ) -> None:
40
41
  """Initialize the InputGeneratorBase instance.
41
42
 
@@ -74,6 +75,10 @@ class InputFileWriter:
74
75
  Whether to release the variable from memory after writing to file.
75
76
  This can help reduce memory usage when generating input files for large simulations.
76
77
  default is False.
78
+ pml_thickness : int, optional
79
+ PML boundary thickness in grid points (n_pml_layer + n_transition_layer).
80
+ Required by the solver binary when using sparse grid (mod_x/mod_y != 0) to determine
81
+ the interior domain boundaries. Also written when mod_x == 0 for future use.
77
82
 
78
83
  """
79
84
  logger.debug("Initializing InputFileWriter instance.")
@@ -99,6 +104,7 @@ class InputFileWriter:
99
104
  self.is_3d = self.grid.is_3d
100
105
  self.use_exponential_attenuation = use_exponential_attenuation
101
106
  self.release_after_write = release_after_write
107
+ self.pml_thickness = pml_thickness
102
108
 
103
109
  self._dim = int(
104
110
  np.rint(self.medium.sound_speed.max()) - np.rint(self.medium.sound_speed.min()),
@@ -828,6 +834,7 @@ class InputFileWriter:
828
834
  self._save_step_params(simulation_dir)
829
835
  self._save_coords_params(simulation_dir)
830
836
  self._save_d_params(simulation_dir, dim)
837
+ self._save_sparse_grid_params(simulation_dir)
831
838
 
832
839
  if self.use_isotropic_relaxation:
833
840
  rename_dict = {
@@ -894,6 +901,7 @@ class InputFileWriter:
894
901
  self._save_step_params(simulation_dir)
895
902
  self._save_coords_params(simulation_dir)
896
903
  self._save_d_params(simulation_dir, dim)
904
+ self._save_sparse_grid_params(simulation_dir)
897
905
 
898
906
  def _build_symbolic_links_for_dat_files(self, src_dir: Path, dst_dir: Path) -> None:
899
907
  var_name_list = [
@@ -916,6 +924,9 @@ class InputFileWriter:
916
924
  "ncoordszero",
917
925
  "nTic",
918
926
  "modT",
927
+ "modX",
928
+ "modY",
929
+ "pml_thickness",
919
930
  "d",
920
931
  "dmap",
921
932
  "ndmap",
@@ -948,6 +959,8 @@ class InputFileWriter:
948
959
  "bpmly2",
949
960
  ],
950
961
  )
962
+ if self.is_3d:
963
+ var_name_list.append("modZ")
951
964
  if self.is_3d and not self.use_isotropic_relaxation:
952
965
  var_name_list.extend(
953
966
  [
@@ -1074,6 +1087,22 @@ class InputFileWriter:
1074
1087
  save_path = simulation_dir / f"{var_name}.dat"
1075
1088
  self._queue_v_abs_write(np.int32, save_path, var)
1076
1089
 
1090
+ def _save_sparse_grid_params(self, simulation_dir: Path) -> None:
1091
+ """Write sparse-grid parameters when the sensor is in sparse-grid mode.
1092
+
1093
+ modX / modY / modZ and pml_thickness are only written when the sensor
1094
+ was constructed with mod_x/mod_y (i.e. sensor.is_sparse_grid is True).
1095
+ In standard coordinate/mask mode these files are not produced, preserving
1096
+ backward compatibility with older binaries that do not expect them.
1097
+ """
1098
+ if not self.sensor.is_sparse_grid:
1099
+ return
1100
+ self._queue_v_abs_write(np.int32, simulation_dir / "modX.dat", self.sensor.mod_x)
1101
+ self._queue_v_abs_write(np.int32, simulation_dir / "modY.dat", self.sensor.mod_y)
1102
+ if self.is_3d:
1103
+ self._queue_v_abs_write(np.int32, simulation_dir / "modZ.dat", self.sensor.mod_z)
1104
+ self._queue_v_abs_write(np.int32, simulation_dir / "pml_thickness.dat", self.pml_thickness)
1105
+
1077
1106
  def _save_d_params(
1078
1107
  self,
1079
1108
  simulation_dir: Path,
@@ -353,14 +353,24 @@ class PMLBuilder:
353
353
  logger.debug("building extended source for pml...done")
354
354
 
355
355
  logger.debug("building extended sensor for pml...")
356
- extended_sensor_grid_shape = tuple(
357
- s + 2 * self.num_boundary_points for s in self.sensor_org.grid_shape
358
- )
359
- self.extended_sensor = fullwave.Sensor(
360
- coords=self.sensor_org.outcoords + self.num_boundary_points,
361
- grid_shape=extended_sensor_grid_shape,
362
- sampling_modulus_time=self.sensor_org.sampling_modulus_time,
363
- )
356
+ if self.sensor_org.is_sparse_grid:
357
+ # Sparse-grid sensor: no explicit coordinates to shift.
358
+ # Pass mod values through; the binary generates positions at run time.
359
+ self.extended_sensor = fullwave.Sensor(
360
+ mod_x=self.sensor_org.mod_x,
361
+ mod_y=self.sensor_org.mod_y,
362
+ mod_z=self.sensor_org.mod_z,
363
+ sampling_modulus_time=self.sensor_org.sampling_modulus_time,
364
+ )
365
+ else:
366
+ extended_sensor_grid_shape = tuple(
367
+ s + 2 * self.num_boundary_points for s in self.sensor_org.grid_shape
368
+ )
369
+ self.extended_sensor = fullwave.Sensor(
370
+ coords=self.sensor_org.outcoords + self.num_boundary_points,
371
+ grid_shape=extended_sensor_grid_shape,
372
+ sampling_modulus_time=self.sensor_org.sampling_modulus_time,
373
+ )
364
374
  logger.debug("building extended sensor for pml...done")
365
375
  if self.is_3d:
366
376
  self.pml_mask_x, self.pml_mask_y, self.pml_mask_z = self._localize_pml_region()
@@ -1643,14 +1653,22 @@ class PMLBuilderExponentialAttenuation(PMLBuilder):
1643
1653
  w0=getattr(self.source_org, "w0", None),
1644
1654
  coords_w=incoords_w_ext,
1645
1655
  )
1646
- extended_sensor_grid_shape = tuple(
1647
- s + 2 * self.num_boundary_points for s in self.sensor_org.grid_shape
1648
- )
1649
- self.extended_sensor = fullwave.Sensor(
1650
- coords=self.sensor_org.outcoords + self.num_boundary_points,
1651
- grid_shape=extended_sensor_grid_shape,
1652
- sampling_modulus_time=self.sensor_org.sampling_modulus_time,
1653
- )
1656
+ if self.sensor_org.is_sparse_grid:
1657
+ self.extended_sensor = fullwave.Sensor(
1658
+ mod_x=self.sensor_org.mod_x,
1659
+ mod_y=self.sensor_org.mod_y,
1660
+ mod_z=self.sensor_org.mod_z,
1661
+ sampling_modulus_time=self.sensor_org.sampling_modulus_time,
1662
+ )
1663
+ else:
1664
+ extended_sensor_grid_shape = tuple(
1665
+ s + 2 * self.num_boundary_points for s in self.sensor_org.grid_shape
1666
+ )
1667
+ self.extended_sensor = fullwave.Sensor(
1668
+ coords=self.sensor_org.outcoords + self.num_boundary_points,
1669
+ grid_shape=extended_sensor_grid_shape,
1670
+ sampling_modulus_time=self.sensor_org.sampling_modulus_time,
1671
+ )
1654
1672
  logger.debug("Extended source and sensor for PML built successfully.")
1655
1673
 
1656
1674
  logger.debug("Localizing PML region...")
@@ -15,6 +15,7 @@ from fullwave.utils import (
15
15
  MemoryTempfile,
16
16
  check_functions,
17
17
  )
18
+ from fullwave.utils.signal_filter import apply_filter
18
19
 
19
20
  from .binary_manager import ensure_binary
20
21
  from .cuda_utils import get_cuda_architecture, retrieve_cuda_version
@@ -638,10 +639,86 @@ class Solver:
638
639
  error_msg = f"{path_fullwave_simulation_bin} does not exist"
639
640
  assert path_fullwave_simulation_bin.exists(), error_msg
640
641
 
642
+ @staticmethod
643
+ def _validate_filter_params(
644
+ highpass_cutoff_mhz: float | None,
645
+ bandpass_cutoff_mhz: tuple[float, float] | None,
646
+ *,
647
+ load_results: bool,
648
+ ) -> None:
649
+ """Validate high-pass / band-pass filter arguments passed to run().
650
+
651
+ Raises
652
+ ------
653
+ ValueError
654
+ If both filter options are set simultaneously, or if a filter is
655
+ requested without ``load_results=True``.
656
+
657
+ """
658
+ if highpass_cutoff_mhz is not None and bandpass_cutoff_mhz is not None:
659
+ error_msg = (
660
+ "highpass_cutoff_mhz and bandpass_cutoff_mhz cannot both be specified. "
661
+ "Use highpass_cutoff_mhz for a simple high-pass filter or "
662
+ "bandpass_cutoff_mhz for a band-pass filter."
663
+ )
664
+ logger.error(error_msg)
665
+ raise ValueError(error_msg)
666
+ if (highpass_cutoff_mhz is not None or bandpass_cutoff_mhz is not None) and (
667
+ not load_results
668
+ ):
669
+ error_msg = (
670
+ "Filtering requires load_results=True. "
671
+ "Set load_results=True or disable the filter options."
672
+ )
673
+ logger.error(error_msg)
674
+ raise ValueError(error_msg)
675
+
676
+ @staticmethod
677
+ def _apply_output_filter(
678
+ result: NDArray[np.float64],
679
+ dt: float,
680
+ highpass_cutoff_mhz: float | None,
681
+ bandpass_cutoff_mhz: tuple[float, float] | None,
682
+ ) -> NDArray[np.float64]:
683
+ """Apply the optional frequency filter to the reshaped sensor output.
684
+
685
+ Parameters
686
+ ----------
687
+ result : NDArray[np.float64]
688
+ Sensor data shaped ``[n_sensors, n_t]``.
689
+ dt : float
690
+ Grid time step in seconds.
691
+ highpass_cutoff_mhz : float | None
692
+ High-pass edge in MHz, or ``None``.
693
+ bandpass_cutoff_mhz : tuple[float, float] | None
694
+ ``(f_low_mhz, f_high_mhz)`` band-pass edges, or ``None``.
695
+
696
+ Returns
697
+ -------
698
+ NDArray[np.float64]
699
+ Filtered (or unchanged) sensor data.
700
+
701
+ """
702
+ if highpass_cutoff_mhz is not None:
703
+ logger.info("Applying high-pass filter at %.4g MHz...", highpass_cutoff_mhz)
704
+ return apply_filter(result, dt, f_low_hz=highpass_cutoff_mhz * 1e6)
705
+ if bandpass_cutoff_mhz is not None:
706
+ f_low_hz = bandpass_cutoff_mhz[0] * 1e6
707
+ f_high_hz = bandpass_cutoff_mhz[1] * 1e6
708
+ logger.info(
709
+ "Applying band-pass filter %.4g-%.4g MHz...",
710
+ bandpass_cutoff_mhz[0],
711
+ bandpass_cutoff_mhz[1],
712
+ )
713
+ return apply_filter(result, dt, f_low_hz=f_low_hz, f_high_hz=f_high_hz)
714
+ return result
715
+
641
716
  @staticmethod
642
717
  def _reshape_sensor_data(
643
718
  raw_sensor_output: NDArray[np.float64],
644
719
  sensor: fullwave.Sensor,
720
+ *,
721
+ n_t: int | None = None,
645
722
  ) -> NDArray[np.float64]:
646
723
  """Reshape the raw sensor output data.
647
724
 
@@ -651,12 +728,21 @@ class Solver:
651
728
  The raw sensor output data from the simulation. [nt*ncoordsout, 1]
652
729
  sensor: fullwave.Sensor
653
730
  The sensor object used in the simulation.
731
+ n_t: int | None
732
+ Number of time steps in the extended grid. Required for sparse-grid
733
+ sensors because n_sensors is not known at Python time.
654
734
 
655
735
  Returns
656
736
  -------
657
737
  NDArray[np.float64]: The reshaped sensor output data. [ncoordsout, nt]
658
738
 
659
739
  """
740
+ if sensor.is_sparse_grid:
741
+ if n_t is None:
742
+ msg = "n_t is required to reshape sparse-grid sensor output"
743
+ raise ValueError(msg)
744
+ n_t_recorded = -(-n_t // sensor.sampling_modulus_time) # ceiling division
745
+ return raw_sensor_output.reshape(n_t_recorded, -1).T
660
746
  return raw_sensor_output.reshape(-1, sensor.n_sensors).T
661
747
 
662
748
  def run(
@@ -670,6 +756,8 @@ class Solver:
670
756
  load_results: bool = True,
671
757
  generate_input_only: bool = False,
672
758
  release_after_write: bool = False,
759
+ highpass_cutoff_mhz: float | None = None,
760
+ bandpass_cutoff_mhz: tuple[float, float] | None = None,
673
761
  ) -> NDArray[np.float64] | Path:
674
762
  r"""Run the fullwave simulation and return the result as a NumPy array.
675
763
 
@@ -729,6 +817,18 @@ class Solver:
729
817
  If True, the memory used by the input files will be released after writing them to disk.
730
818
  This is useful when run_on_memory is True to free up memory space for the simulation
731
819
  or when the input files are large. Default is False.
820
+ highpass_cutoff_mhz : float | None
821
+ Apply a high-pass filter to the sensor recordings after the simulation.
822
+ Removes low-frequency PML drift by attenuating frequencies below this value (in MHz).
823
+ Uses a cosine (Hann) taper to avoid Gibbs ringing.
824
+ Cannot be combined with ``bandpass_cutoff_mhz``.
825
+ Requires ``load_results=True``. Default is ``None`` (no filtering).
826
+ bandpass_cutoff_mhz : tuple[float, float] | None
827
+ Apply a band-pass filter ``(f_low_mhz, f_high_mhz)`` to the sensor recordings
828
+ after the simulation. Retains only frequencies inside the specified band.
829
+ Uses cosine (Hann) tapers on both edges.
830
+ Cannot be combined with ``highpass_cutoff_mhz``.
831
+ Requires ``load_results=True``. Default is ``None`` (no filtering).
732
832
 
733
833
  Returns
734
834
  -------
@@ -746,6 +846,8 @@ class Solver:
746
846
  Static map simulations require input files to be stored on a disk.
747
847
  run_on_memory, on the other hand, removes the input files
748
848
  after the simulation is complete.
849
+ Also raised if both ``highpass_cutoff_mhz`` and ``bandpass_cutoff_mhz`` are given,
850
+ or if either filter option is set but ``load_results=False``.
749
851
 
750
852
  """
751
853
  # self._save_data_for_beamforming()
@@ -767,6 +869,12 @@ class Solver:
767
869
  logger.error(error_msg)
768
870
  raise ValueError(error_msg)
769
871
 
872
+ self._validate_filter_params(
873
+ highpass_cutoff_mhz,
874
+ bandpass_cutoff_mhz,
875
+ load_results=load_results,
876
+ )
877
+
770
878
  start_time = time.time()
771
879
  extended_medium = self.pml_builder.run(use_pml=self.use_pml)
772
880
  end_pml_builder_time = time.time()
@@ -783,30 +891,27 @@ class Solver:
783
891
  )
784
892
  logger.warning(warning_msg)
785
893
 
786
- sensor_mask: NDArray[np.bool_]
787
894
  if record_whole_domain:
788
- if self.is_3d:
789
- sensor_mask = np.zeros(
790
- (
791
- self.pml_builder.extended_grid.nx,
792
- self.pml_builder.extended_grid.ny,
793
- self.pml_builder.extended_grid.nz,
794
- ),
795
- dtype=bool,
796
- )
797
- else:
798
- sensor_mask = np.zeros(
799
- (self.pml_builder.extended_grid.nx, self.pml_builder.extended_grid.ny),
800
- dtype=bool,
801
- )
802
- sensor_mask[:, :] = True
895
+ mod_x = 1
896
+ mod_y = 1
897
+ mod_z = 1 if self.is_3d else None
898
+
803
899
  sensor = fullwave.Sensor(
804
- mask=sensor_mask,
900
+ mod_x=mod_x,
901
+ mod_y=mod_y,
902
+ mod_z=mod_z,
805
903
  sampling_modulus_time=sampling_modulus_time_whole_domain,
806
904
  )
807
905
  else:
808
906
  sensor = self.pml_builder.extended_sensor
809
907
 
908
+ # pml_thickness = PML + transition layers on each side, excluding ghost cells.
909
+ # Used by the binary to locate the interior domain when building a sparse sensor grid.
910
+ if record_whole_domain:
911
+ pml_thickness = 0
912
+ else:
913
+ pml_thickness = self.pml_builder.num_boundary_points - self.pml_builder.m_spatial_order
914
+
810
915
  start_input_file_writer_time = time.time()
811
916
  input_file_writer = InputFileWriter(
812
917
  work_dir=self.work_dir,
@@ -818,6 +923,7 @@ class Solver:
818
923
  use_exponential_attenuation=self.use_exponential_attenuation,
819
924
  use_isotropic_relaxation=self.use_isotropic_relaxation,
820
925
  release_after_write=release_after_write,
926
+ pml_thickness=pml_thickness,
821
927
  )
822
928
  simulation_dir = input_file_writer.run(
823
929
  simulation_dir_name,
@@ -850,6 +956,7 @@ class Solver:
850
956
  result = self._reshape_sensor_data(
851
957
  sim_result,
852
958
  sensor=sensor,
959
+ n_t=self.pml_builder.extended_grid.nt,
853
960
  )
854
961
  end_loading_time = time.time()
855
962
  message = (
@@ -857,7 +964,13 @@ class Solver:
857
964
  f"{end_loading_time - start_loading_time:.2e} seconds."
858
965
  )
859
966
  logger.info(message)
860
- return result
967
+
968
+ return self._apply_output_filter(
969
+ result,
970
+ self.grid.dt,
971
+ highpass_cutoff_mhz,
972
+ bandpass_cutoff_mhz,
973
+ )
861
974
  # if load_results is False, return the raw result
862
975
  # which is a list of file names
863
976
  return sim_result
@@ -1,6 +1,6 @@
1
1
  """misc utils for fullwave package."""
2
2
 
3
- from . import pulse, relaxation_parameters, signal_process
3
+ from . import pulse, relaxation_parameters, signal_filter, signal_process
4
4
  from .memory_tempfile import MemoryTempfile
5
5
  from .scatterer import (
6
6
  generate_resolution_based_scatterer,
@@ -14,5 +14,6 @@ __all__ = [
14
14
  "generate_scatterer",
15
15
  "pulse",
16
16
  "relaxation_parameters",
17
+ "signal_filter",
17
18
  "signal_process",
18
19
  ]
@@ -0,0 +1,161 @@
1
+ """FFT-based frequency-domain filtering for sensor data.
2
+
3
+ GPU backend: CuPy when available; falls back silently to NumPy.
4
+ No new hard dependencies — CuPy is already listed under the ``examples`` optional extra.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING
11
+
12
+ import numpy as np
13
+
14
+ if TYPE_CHECKING:
15
+ from numpy.typing import NDArray
16
+
17
+ logger = logging.getLogger("__main__." + __name__)
18
+
19
+ # Module-level cache to avoid repeated import overhead
20
+ _CUPY_AVAILABLE: bool | None = None
21
+
22
+
23
+ def _check_cupy() -> bool:
24
+ """Return True if CuPy is importable; result is cached after the first call."""
25
+ global _CUPY_AVAILABLE # noqa: PLW0603
26
+ if _CUPY_AVAILABLE is None:
27
+ try:
28
+ import cupy # noqa: F401
29
+
30
+ _CUPY_AVAILABLE = True
31
+ except ImportError:
32
+ _CUPY_AVAILABLE = False
33
+ return _CUPY_AVAILABLE
34
+
35
+
36
+ def _build_frequency_mask(
37
+ n_fft: int,
38
+ dt: float,
39
+ f_low_hz: float | None = None,
40
+ f_high_hz: float | None = None,
41
+ taper_ratio: float = 0.1,
42
+ ) -> NDArray[np.float64]:
43
+ """Build a frequency-domain gain mask with cosine (Hann) tapers.
44
+
45
+ Parameters
46
+ ----------
47
+ n_fft : int
48
+ FFT length (number of time samples before zero-padding, i.e. ``n_t``).
49
+ dt : float
50
+ Simulation time step in seconds.
51
+ f_low_hz : float | None
52
+ High-pass cut-off frequency in Hz. Frequencies below this value are
53
+ attenuated. The mask transitions smoothly from 0 to 1 in a window of
54
+ width ``f_low_hz * taper_ratio`` centred at ``f_low_hz``.
55
+ f_high_hz : float | None
56
+ Low-pass cut-off frequency in Hz. Frequencies above this value are
57
+ attenuated. The mask transitions smoothly from 1 to 0 in a window of
58
+ width ``f_high_hz * taper_ratio`` centred at ``f_high_hz``.
59
+ taper_ratio : float
60
+ Fractional width of each cosine taper relative to its centre frequency.
61
+ Default is 0.1 (10 %).
62
+
63
+ Returns
64
+ -------
65
+ NDArray[np.float64]
66
+ Frequency-domain gain mask of shape ``[n_fft // 2 + 1]``.
67
+
68
+ """
69
+ freqs = np.fft.rfftfreq(n_fft, d=dt)
70
+ mask = np.ones(len(freqs), dtype=np.float64)
71
+
72
+ if f_low_hz is not None:
73
+ half_width = f_low_hz * taper_ratio / 2.0
74
+ f_start = f_low_hz - half_width
75
+ f_end = f_low_hz + half_width
76
+ width = f_end - f_start # == f_low_hz * taper_ratio
77
+
78
+ in_taper = (freqs >= f_start) & (freqs <= f_end)
79
+ below_taper = freqs < f_start
80
+
81
+ mask[below_taper] = 0.0
82
+ mask[in_taper] = 0.5 * (1.0 - np.cos(np.pi * (freqs[in_taper] - f_start) / width))
83
+
84
+ if f_high_hz is not None:
85
+ half_width = f_high_hz * taper_ratio / 2.0
86
+ f_start = f_high_hz - half_width
87
+ f_end = f_high_hz + half_width
88
+ width = f_end - f_start # == f_high_hz * taper_ratio
89
+
90
+ in_taper = (freqs >= f_start) & (freqs <= f_end)
91
+ above_taper = freqs > f_end
92
+
93
+ lp_taper = np.ones(len(freqs), dtype=np.float64)
94
+ lp_taper[in_taper] = 0.5 * (1.0 + np.cos(np.pi * (freqs[in_taper] - f_start) / width))
95
+ lp_taper[above_taper] = 0.0
96
+ mask *= lp_taper
97
+
98
+ return mask
99
+
100
+
101
+ def apply_filter(
102
+ data: NDArray[np.float64],
103
+ dt: float,
104
+ f_low_hz: float | None = None,
105
+ f_high_hz: float | None = None,
106
+ taper_ratio: float = 0.1,
107
+ *,
108
+ use_gpu: bool = True,
109
+ ) -> NDArray[np.float64]:
110
+ """Apply a frequency-domain filter to sensor data.
111
+
112
+ The filter is built as a cosine-tapered gain mask (see :func:`_build_frequency_mask`).
113
+ When CuPy is available and ``use_gpu=True``, the FFT operations run on the GPU
114
+ for maximum throughput; otherwise NumPy is used transparently.
115
+
116
+ Parameters
117
+ ----------
118
+ data : NDArray[np.float64]
119
+ Sensor time traces, shape ``[n_sensors, n_t]``.
120
+ dt : float
121
+ Simulation time step in seconds.
122
+ f_low_hz : float | None
123
+ High-pass edge frequency in Hz. Pass ``None`` to skip high-passing.
124
+ f_high_hz : float | None
125
+ Low-pass edge frequency in Hz. Pass ``None`` to skip low-passing.
126
+ taper_ratio : float
127
+ Fractional taper width relative to each cut-off frequency. Default 0.1.
128
+ use_gpu : bool
129
+ If ``True`` (default), attempt to use CuPy for GPU-accelerated FFTs.
130
+ Falls back to NumPy silently if CuPy is unavailable.
131
+
132
+ Returns
133
+ -------
134
+ NDArray[np.float64]
135
+ Filtered data, same shape as ``data``.
136
+
137
+ """
138
+ n_t = data.shape[1]
139
+ mask = _build_frequency_mask(
140
+ n_t,
141
+ dt,
142
+ f_low_hz=f_low_hz,
143
+ f_high_hz=f_high_hz,
144
+ taper_ratio=taper_ratio,
145
+ )
146
+
147
+ if use_gpu and _check_cupy():
148
+ import cupy as cp
149
+
150
+ logger.debug("apply_filter: using CuPy GPU backend")
151
+ data_gpu = cp.asarray(data, dtype=cp.float64)
152
+ mask_gpu = cp.asarray(mask, dtype=cp.float64)
153
+ spec = cp.fft.rfft(data_gpu, axis=1)
154
+ spec *= mask_gpu[cp.newaxis, :]
155
+ filtered = cp.fft.irfft(spec, n=n_t, axis=1)
156
+ return cp.asnumpy(filtered)
157
+
158
+ logger.debug("apply_filter: using NumPy CPU backend")
159
+ spec = np.fft.rfft(data, axis=1)
160
+ spec *= mask[np.newaxis, :]
161
+ return np.fft.irfft(spec, n=n_t, axis=1)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fullwave25"
3
- version = "1.2.6-dev0" # Update via bump-my-version, not manually
3
+ version = "1.2.6-dev2" # Update via bump-my-version, not manually
4
4
  description = "Fullwave 2.5: Ultrasound wave propagation simulation with heterogeneous power law attenuation modelling capabilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -735,7 +735,7 @@ wheels = [
735
735
 
736
736
  [[package]]
737
737
  name = "fullwave25"
738
- version = "1.2.6.dev0"
738
+ version = "1.2.6.dev2"
739
739
  source = { editable = "." }
740
740
  dependencies = [
741
741
  { name = "joblib" },
File without changes