fullwave25 1.2.5.dev0__tar.gz → 1.2.6.dev1__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 (70) hide show
  1. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/.bumpversion.toml +1 -1
  2. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/PKG-INFO +1 -1
  3. fullwave25-1.2.6.dev1/debug_solver_bin/fullwave2_2d_exponential_attenuation_multi_gpu_cuda124 +0 -0
  4. fullwave25-1.2.6.dev1/debug_solver_bin/fullwave2_3d_exponential_attenuation_multi_gpu_cuda124 +0 -0
  5. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/__init__.py +1 -1
  6. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/sensor.py +70 -6
  7. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/binary_manager.py +1 -1
  8. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/input_file_writer.py +46 -0
  9. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/pml_builder.py +77 -17
  10. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/solver.py +27 -17
  11. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/source.py +147 -12
  12. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/pyproject.toml +1 -1
  13. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/uv.lock +1 -1
  14. fullwave25-1.2.5.dev0/debug_solver_bin/fullwave2_2d_2_relax_multi_gpu_cuda124 +0 -0
  15. fullwave25-1.2.5.dev0/debug_solver_bin/fullwave2_2d_exponential_attenuation_multi_gpu +0 -0
  16. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/.gitignore +0 -0
  17. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/.pre-commit-config.yaml +0 -0
  18. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/.python-version +0 -0
  19. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/20260120_0844.log +0 -0
  20. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/CITATION.cff +0 -0
  21. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/CLAUDE.md +0 -0
  22. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/CONTRIBUTING.md +0 -0
  23. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/LICENSE +0 -0
  24. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/Makefile +0 -0
  25. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/README.md +0 -0
  26. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/beamformer.m +0 -0
  27. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/experiments/__init__.py +0 -0
  28. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/experiments/exp_scatterer_diameter.py +0 -0
  29. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/beamformer/__init__.py +0 -0
  30. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/beamformer/beamformer.py +0 -0
  31. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/constants/__init__.py +0 -0
  32. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/constants/material_properties.py +0 -0
  33. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/grid.py +0 -0
  34. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium.py +0 -0
  35. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium_builder/__init__.py +0 -0
  36. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium_builder/domain.py +0 -0
  37. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium_builder/medium_builder.py +0 -0
  38. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium_builder/presets/__init__.py +0 -0
  39. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium_builder/presets/data/.keep +0 -0
  40. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium_builder/presets/data/abdominal_wall/i2365f_etfw1.mat +0 -0
  41. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium_builder/presets/domain_abdominal_wall.py +0 -0
  42. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium_builder/presets/domain_background.py +0 -0
  43. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium_builder/presets/domain_scatterer.py +0 -0
  44. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium_builder/presets/domain_simple.py +0 -0
  45. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/medium_builder/presets/domain_water_gel.py +0 -0
  46. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/__init__.py +0 -0
  47. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/bins/_gpu/2d/num_relax=2/fullwave2_2d_2_relax_multi_gpu_cuda118 +0 -0
  48. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/bins/_gpu/2d/num_relax=2/fullwave2_2d_2_relax_multi_gpu_cuda124 +0 -0
  49. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/bins/_gpu/2d/num_relax=2/fullwave2_2d_2_relax_multi_gpu_cuda129 +0 -0
  50. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/bins/_gpu/2d/num_relax=2/fullwave2_2d_2_relax_multi_gpu_cuda130 +0 -0
  51. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/bins/_gpu/3d/num_relax=2/fullwave2_3d_2_relax_multi_gpu_cuda118 +0 -0
  52. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/bins/_gpu/3d/num_relax=2/fullwave2_3d_2_relax_multi_gpu_cuda124 +0 -0
  53. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/bins/_gpu/3d/num_relax=2/fullwave2_3d_2_relax_multi_gpu_cuda129 +0 -0
  54. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/bins/_gpu/3d/num_relax=2/fullwave2_3d_2_relax_multi_gpu_cuda130 +0 -0
  55. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/bins/database/relaxation_params_database_num_relax=2_20260113_0957.mat +0 -0
  56. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/cuda_utils.py +0 -0
  57. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/launcher.py +0 -0
  58. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/solver/utils.py +0 -0
  59. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/transducer.py +0 -0
  60. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/utils/__init__.py +0 -0
  61. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/utils/check_functions.py +0 -0
  62. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/utils/coordinates.py +0 -0
  63. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/utils/memory_tempfile.py +0 -0
  64. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/utils/numerical.py +0 -0
  65. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/utils/plot_utils.py +0 -0
  66. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/utils/pulse.py +0 -0
  67. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/utils/relaxation_parameters.py +0 -0
  68. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/utils/scatterer.py +0 -0
  69. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/fullwave/utils/signal_process.py +0 -0
  70. {fullwave25-1.2.5.dev0 → fullwave25-1.2.6.dev1}/ruff.toml +0 -0
@@ -1,5 +1,5 @@
1
1
  [tool.bumpversion]
2
- current_version = "1.2.5-dev0"
2
+ current_version = "1.2.6-dev1"
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.5.dev0
3
+ Version: 1.2.6.dev1
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.5-dev0"
63
+ __version__ = "1.2.6-dev1"
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.1"
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()),
@@ -169,6 +175,19 @@ class InputFileWriter:
169
175
  )
170
176
  if incoords_add is not None:
171
177
  self._queue_coords_write(simulation_dir / "icc_add.dat", incoords_add)
178
+ for _vel_suffix, _vel_attr in (("u", "u0"), ("v", "v0"), ("w", "w0")):
179
+ _signal_vel = getattr(self.source, _vel_attr, None)
180
+ _incoords_vel = getattr(self.source, f"incoords_{_vel_suffix}", None)
181
+ if _signal_vel is not None:
182
+ self._queue_ic_write(
183
+ simulation_dir / f"icmat_{_vel_suffix}.dat",
184
+ np.transpose(_signal_vel),
185
+ )
186
+ if _incoords_vel is not None:
187
+ self._queue_coords_write(
188
+ simulation_dir / f"icc_{_vel_suffix}.dat",
189
+ _incoords_vel,
190
+ )
172
191
  self._queue_coords_write(simulation_dir / "icc.dat", self.source.incoords)
173
192
  self._copy_simulation_bin_file(simulation_dir)
174
193
 
@@ -815,6 +834,7 @@ class InputFileWriter:
815
834
  self._save_step_params(simulation_dir)
816
835
  self._save_coords_params(simulation_dir)
817
836
  self._save_d_params(simulation_dir, dim)
837
+ self._save_sparse_grid_params(simulation_dir)
818
838
 
819
839
  if self.use_isotropic_relaxation:
820
840
  rename_dict = {
@@ -881,6 +901,7 @@ class InputFileWriter:
881
901
  self._save_step_params(simulation_dir)
882
902
  self._save_coords_params(simulation_dir)
883
903
  self._save_d_params(simulation_dir, dim)
904
+ self._save_sparse_grid_params(simulation_dir)
884
905
 
885
906
  def _build_symbolic_links_for_dat_files(self, src_dir: Path, dst_dir: Path) -> None:
886
907
  var_name_list = [
@@ -903,6 +924,9 @@ class InputFileWriter:
903
924
  "ncoordszero",
904
925
  "nTic",
905
926
  "modT",
927
+ "modX",
928
+ "modY",
929
+ "pml_thickness",
906
930
  "d",
907
931
  "dmap",
908
932
  "ndmap",
@@ -935,6 +959,8 @@ class InputFileWriter:
935
959
  "bpmly2",
936
960
  ],
937
961
  )
962
+ if self.is_3d:
963
+ var_name_list.append("modZ")
938
964
  if self.is_3d and not self.use_isotropic_relaxation:
939
965
  var_name_list.extend(
940
966
  [
@@ -1047,6 +1073,10 @@ class InputFileWriter:
1047
1073
  n_sources_add = getattr(self.source, "n_sources_add", 0)
1048
1074
  if n_sources_add > 0:
1049
1075
  var_list.append(("ncoords_add", n_sources_add))
1076
+ for _vel_suffix in ("u", "v", "w"):
1077
+ _n_vel = getattr(self.source, f"n_sources_{_vel_suffix}", 0)
1078
+ if _n_vel > 0:
1079
+ var_list.append((f"ncoords_{_vel_suffix}", _n_vel))
1050
1080
  if self.is_3d:
1051
1081
  var_list.extend(
1052
1082
  [
@@ -1057,6 +1087,22 @@ class InputFileWriter:
1057
1087
  save_path = simulation_dir / f"{var_name}.dat"
1058
1088
  self._queue_v_abs_write(np.int32, save_path, var)
1059
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
+
1060
1106
  def _save_d_params(
1061
1107
  self,
1062
1108
  simulation_dir: Path,
@@ -322,24 +322,55 @@ class PMLBuilder:
322
322
  if getattr(self.source_org, "incoords_add", None) is not None
323
323
  else None
324
324
  )
325
+ incoords_u_ext = (
326
+ self.source_org.incoords_u + self.num_boundary_points
327
+ if getattr(self.source_org, "incoords_u", None) is not None
328
+ else None
329
+ )
330
+ incoords_v_ext = (
331
+ self.source_org.incoords_v + self.num_boundary_points
332
+ if getattr(self.source_org, "incoords_v", None) is not None
333
+ else None
334
+ )
335
+ incoords_w_ext = (
336
+ self.source_org.incoords_w + self.num_boundary_points
337
+ if getattr(self.source_org, "incoords_w", None) is not None
338
+ else None
339
+ )
325
340
  self.extended_source = fullwave.Source(
326
341
  p0=self.source_org.p0,
327
342
  coords=self.source_org.incoords + self.num_boundary_points,
328
343
  grid_shape=extended_grid_shape,
329
344
  p0_additive=self.source_org.p0_additive,
330
345
  coords_additive=incoords_add_ext,
346
+ u0=getattr(self.source_org, "u0", None),
347
+ coords_u=incoords_u_ext,
348
+ v0=getattr(self.source_org, "v0", None),
349
+ coords_v=incoords_v_ext,
350
+ w0=getattr(self.source_org, "w0", None),
351
+ coords_w=incoords_w_ext,
331
352
  )
332
353
  logger.debug("building extended source for pml...done")
333
354
 
334
355
  logger.debug("building extended sensor for pml...")
335
- extended_sensor_grid_shape = tuple(
336
- s + 2 * self.num_boundary_points for s in self.sensor_org.grid_shape
337
- )
338
- self.extended_sensor = fullwave.Sensor(
339
- coords=self.sensor_org.outcoords + self.num_boundary_points,
340
- grid_shape=extended_sensor_grid_shape,
341
- sampling_modulus_time=self.sensor_org.sampling_modulus_time,
342
- )
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
+ )
343
374
  logger.debug("building extended sensor for pml...done")
344
375
  if self.is_3d:
345
376
  self.pml_mask_x, self.pml_mask_y, self.pml_mask_z = self._localize_pml_region()
@@ -1434,7 +1465,7 @@ class PMLBuilder:
1434
1465
  class PMLBuilderExponentialAttenuation(PMLBuilder):
1435
1466
  """A class to set up PML for exponential attenuation media."""
1436
1467
 
1437
- def __init__(
1468
+ def __init__( # noqa: PLR0915
1438
1469
  self,
1439
1470
  grid: fullwave.Grid,
1440
1471
  medium: fullwave.Medium,
@@ -1594,21 +1625,50 @@ class PMLBuilderExponentialAttenuation(PMLBuilder):
1594
1625
  if getattr(self.source_org, "incoords_add", None) is not None
1595
1626
  else None
1596
1627
  )
1628
+ incoords_u_ext = (
1629
+ self.source_org.incoords_u + self.num_boundary_points
1630
+ if getattr(self.source_org, "incoords_u", None) is not None
1631
+ else None
1632
+ )
1633
+ incoords_v_ext = (
1634
+ self.source_org.incoords_v + self.num_boundary_points
1635
+ if getattr(self.source_org, "incoords_v", None) is not None
1636
+ else None
1637
+ )
1638
+ incoords_w_ext = (
1639
+ self.source_org.incoords_w + self.num_boundary_points
1640
+ if getattr(self.source_org, "incoords_w", None) is not None
1641
+ else None
1642
+ )
1597
1643
  self.extended_source = fullwave.Source(
1598
1644
  p0=self.source_org.p0,
1599
1645
  coords=self.source_org.incoords + self.num_boundary_points,
1600
1646
  grid_shape=extended_grid_shape,
1601
1647
  p0_additive=self.source_org.p0_additive,
1602
1648
  coords_additive=incoords_add_ext,
1649
+ u0=getattr(self.source_org, "u0", None),
1650
+ coords_u=incoords_u_ext,
1651
+ v0=getattr(self.source_org, "v0", None),
1652
+ coords_v=incoords_v_ext,
1653
+ w0=getattr(self.source_org, "w0", None),
1654
+ coords_w=incoords_w_ext,
1603
1655
  )
1604
- extended_sensor_grid_shape = tuple(
1605
- s + 2 * self.num_boundary_points for s in self.sensor_org.grid_shape
1606
- )
1607
- self.extended_sensor = fullwave.Sensor(
1608
- coords=self.sensor_org.outcoords + self.num_boundary_points,
1609
- grid_shape=extended_sensor_grid_shape,
1610
- sampling_modulus_time=self.sensor_org.sampling_modulus_time,
1611
- )
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
+ )
1612
1672
  logger.debug("Extended source and sensor for PML built successfully.")
1613
1673
 
1614
1674
  logger.debug("Localizing PML region...")
@@ -642,6 +642,8 @@ class Solver:
642
642
  def _reshape_sensor_data(
643
643
  raw_sensor_output: NDArray[np.float64],
644
644
  sensor: fullwave.Sensor,
645
+ *,
646
+ n_t: int | None = None,
645
647
  ) -> NDArray[np.float64]:
646
648
  """Reshape the raw sensor output data.
647
649
 
@@ -651,12 +653,21 @@ class Solver:
651
653
  The raw sensor output data from the simulation. [nt*ncoordsout, 1]
652
654
  sensor: fullwave.Sensor
653
655
  The sensor object used in the simulation.
656
+ n_t: int | None
657
+ Number of time steps in the extended grid. Required for sparse-grid
658
+ sensors because n_sensors is not known at Python time.
654
659
 
655
660
  Returns
656
661
  -------
657
662
  NDArray[np.float64]: The reshaped sensor output data. [ncoordsout, nt]
658
663
 
659
664
  """
665
+ if sensor.is_sparse_grid:
666
+ if n_t is None:
667
+ msg = "n_t is required to reshape sparse-grid sensor output"
668
+ raise ValueError(msg)
669
+ n_t_recorded = -(-n_t // sensor.sampling_modulus_time) # ceiling division
670
+ return raw_sensor_output.reshape(n_t_recorded, -1).T
660
671
  return raw_sensor_output.reshape(-1, sensor.n_sensors).T
661
672
 
662
673
  def run(
@@ -783,30 +794,27 @@ class Solver:
783
794
  )
784
795
  logger.warning(warning_msg)
785
796
 
786
- sensor_mask: NDArray[np.bool_]
787
797
  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
798
+ mod_x = 1
799
+ mod_y = 1
800
+ mod_z = 1 if self.is_3d else None
801
+
803
802
  sensor = fullwave.Sensor(
804
- mask=sensor_mask,
803
+ mod_x=mod_x,
804
+ mod_y=mod_y,
805
+ mod_z=mod_z,
805
806
  sampling_modulus_time=sampling_modulus_time_whole_domain,
806
807
  )
807
808
  else:
808
809
  sensor = self.pml_builder.extended_sensor
809
810
 
811
+ # pml_thickness = PML + transition layers on each side, excluding ghost cells.
812
+ # Used by the binary to locate the interior domain when building a sparse sensor grid.
813
+ if record_whole_domain:
814
+ pml_thickness = 0
815
+ else:
816
+ pml_thickness = self.pml_builder.num_boundary_points - self.pml_builder.m_spatial_order
817
+
810
818
  start_input_file_writer_time = time.time()
811
819
  input_file_writer = InputFileWriter(
812
820
  work_dir=self.work_dir,
@@ -818,6 +826,7 @@ class Solver:
818
826
  use_exponential_attenuation=self.use_exponential_attenuation,
819
827
  use_isotropic_relaxation=self.use_isotropic_relaxation,
820
828
  release_after_write=release_after_write,
829
+ pml_thickness=pml_thickness,
821
830
  )
822
831
  simulation_dir = input_file_writer.run(
823
832
  simulation_dir_name,
@@ -850,6 +859,7 @@ class Solver:
850
859
  result = self._reshape_sensor_data(
851
860
  sim_result,
852
861
  sensor=sensor,
862
+ n_t=self.pml_builder.extended_grid.nt,
853
863
  )
854
864
  end_loading_time = time.time()
855
865
  message = (
@@ -23,6 +23,12 @@ class Source:
23
23
  grid_shape: tuple[int, ...]
24
24
  p0_additive: NDArray[np.float64] | None = None
25
25
  incoords_add: NDArray[np.int64] | None = None
26
+ u0: NDArray[np.float64] | None = None
27
+ incoords_u: NDArray[np.int64] | None = None
28
+ v0: NDArray[np.float64] | None = None
29
+ incoords_v: NDArray[np.int64] | None = None
30
+ w0: NDArray[np.float64] | None = None
31
+ incoords_w: NDArray[np.int64] | None = None
26
32
 
27
33
  def __init__( # noqa: C901 PLR0912 PLR0915
28
34
  self,
@@ -37,6 +43,16 @@ class Source:
37
43
  coords_additive: NDArray[np.int64] | None = None,
38
44
  # ---
39
45
  grid_shape: tuple[int, ...] | None = None,
46
+ # --- velocity source components ---
47
+ u0: NDArray[np.float64] | None = None,
48
+ coords_u: NDArray[np.int64] | None = None,
49
+ mask_u: NDArray[np.bool] | None = None,
50
+ v0: NDArray[np.float64] | None = None,
51
+ coords_v: NDArray[np.int64] | None = None,
52
+ mask_v: NDArray[np.bool] | None = None,
53
+ w0: NDArray[np.float64] | None = None,
54
+ coords_w: NDArray[np.int64] | None = None,
55
+ mask_w: NDArray[np.bool] | None = None,
40
56
  ) -> None:
41
57
  """Source class for Fullwave.
42
58
 
@@ -44,8 +60,9 @@ class Source:
44
60
  ----------
45
61
  p0 : NDArray[np.float64] | None
46
62
  Time-varying pressure at each source position; shape [n_sources, nt].
47
- If None, p0_additive must be provided (additive-only / soft initial condition):
48
- icmat is written as zeros and only the additive term drives the source.
63
+ If None, either p0_additive or a velocity component (u0/v0/w0) must be provided.
64
+ When omitted with a velocity-only source, the pressure node list is empty
65
+ and icmat is written as a zero-row matrix.
49
66
  mask : NDArray[np.bool] | None
50
67
  binary matrix specifying the positions of the time varying pressure source distribution
51
68
  shape: [nx, ny] for 2D, [nx, ny, nz] for 3D.
@@ -54,7 +71,7 @@ class Source:
54
71
  Coordinate array of source positions (hard source); shape [n_sources, ndim].
55
72
  Must be provided together with grid_shape.
56
73
  grid_shape : tuple[int, ...] | None
57
- Shape of the computational grid. Required when using coords input.
74
+ Shape of the computational grid. Required when using coords or velocity-only input.
58
75
  p0_additive : NDArray[np.float64] | None
59
76
  Optional additive (soft) source term; shape [n_sources_add, nt].
60
77
  When provided, positions come from coords_additive or mask_additive,
@@ -66,15 +83,36 @@ class Source:
66
83
  Coordinates for the additive source; shape [n_sources_add, ndim].
67
84
  If None and p0_additive is provided, defaults to primary incoords.
68
85
  Mutually exclusive with mask_additive.
86
+ u0 : NDArray[np.float64] | None
87
+ Time-varying velocity in the depth (x) direction; shape [n_sources_u, nt].
88
+ coords_u : NDArray[np.int64] | None
89
+ Coordinates for the u-velocity source; shape [n_sources_u, ndim].
90
+ Mutually exclusive with mask_u.
91
+ mask_u : NDArray[np.bool] | None
92
+ Boolean mask for u-velocity source positions. Mutually exclusive with coords_u.
93
+ v0 : NDArray[np.float64] | None
94
+ Time-varying velocity in the lateral (y) direction; shape [n_sources_v, nt].
95
+ coords_v : NDArray[np.int64] | None
96
+ Coordinates for the v-velocity source; shape [n_sources_v, ndim].
97
+ Mutually exclusive with mask_v.
98
+ mask_v : NDArray[np.bool] | None
99
+ Boolean mask for v-velocity source positions. Mutually exclusive with coords_v.
100
+ w0 : NDArray[np.float64] | None
101
+ Time-varying velocity in the elevational (z) direction; shape [n_sources_w, nt].
102
+ coords_w : NDArray[np.int64] | None
103
+ Coordinates for the w-velocity source; shape [n_sources_w, ndim].
104
+ Mutually exclusive with mask_w.
105
+ mask_w : NDArray[np.bool] | None
106
+ Boolean mask for w-velocity source positions. Mutually exclusive with coords_w.
69
107
 
70
108
  Raises
71
109
  ------
72
110
  ValueError
73
- If grid_shape is missing when using coords or additive-only.
111
+ If grid_shape is missing when using coords, additive-only, or velocity-only.
74
112
  If both mask and coords, or both coords_additive and mask_additive, are provided.
75
- If primary source positions cannot be resolved (need coords+grid_shape,
76
- or mask, or additive-only args).
77
- If both p0 and p0_additive are None.
113
+ If primary source positions cannot be resolved (need coords+grid_shape, mask,
114
+ additive-only args, or at least one velocity component with grid_shape).
115
+ If all of p0, p0_additive, u0, v0, and w0 are None.
78
116
  If p0 / p0_additive row counts do not match their coordinate arrays.
79
117
 
80
118
  """
@@ -110,17 +148,28 @@ class Source:
110
148
  mask_add = np.atleast_2d(mask_additive)
111
149
  self.grid_shape = mask_add.shape
112
150
  self.incoords = map_to_coords(mask_add)
151
+ elif u0 is not None or v0 is not None or w0 is not None:
152
+ # Velocity-only: no hard pressure nodes; pressure arrays will be empty
153
+ if grid_shape is None:
154
+ error_msg = "grid_shape is required for velocity-only source"
155
+ raise ValueError(error_msg)
156
+ self.grid_shape = tuple(grid_shape)
157
+ self.incoords = np.empty((0, len(self.grid_shape)), dtype=np.int64)
113
158
  else:
114
159
  error_msg = (
115
160
  "Provide (coords + grid_shape), or mask, or"
116
- " (p0_additive + (coords_additive or mask_additive))"
161
+ " (p0_additive + (coords_additive or mask_additive)), or"
162
+ " a velocity component (u0/v0/w0) with grid_shape"
117
163
  )
118
164
  raise ValueError(
119
165
  error_msg,
120
166
  )
121
167
 
122
- if p0 is None and p0_additive is None:
123
- error_msg = "At least one of p0 or p0_additive must be provided"
168
+ if p0 is None and p0_additive is None and u0 is None and v0 is None and w0 is None:
169
+ error_msg = (
170
+ "At least one of p0, p0_additive, or a velocity component"
171
+ " (u0/v0/w0) must be provided"
172
+ )
124
173
  raise ValueError(error_msg)
125
174
 
126
175
  # --- Resolve additive coords (for later use) ---
@@ -148,7 +197,7 @@ class Source:
148
197
  )
149
198
  raise ValueError(error_msg)
150
199
  self.p0_additive = np.atleast_2d(p0_additive) if p0_additive is not None else None
151
- else:
200
+ elif p0_additive is not None:
152
201
  # Additive-only: icmat written as zeros
153
202
  p0_add = np.atleast_2d(p0_additive)
154
203
  n_add = _coords_add.shape[0] if _coords_add is not None else self.incoords.shape[0]
@@ -166,6 +215,12 @@ class Source:
166
215
  dtype=np.float64,
167
216
  )
168
217
  self.p0_additive = p0_add
218
+ else:
219
+ # Velocity-only: no pressure nodes; derive nt from the first velocity signal
220
+ _first_vel = next(s for s in (u0, v0, w0) if s is not None)
221
+ nt_vel = np.atleast_2d(_first_vel).shape[1]
222
+ self.p0 = np.zeros((0, nt_vel), dtype=np.float64)
223
+ self.p0_additive = None
169
224
 
170
225
  # --- Set incoords_add for writer/binary ---
171
226
  if self.p0_additive is not None:
@@ -173,6 +228,11 @@ class Source:
173
228
  else:
174
229
  self.incoords_add = None
175
230
 
231
+ # --- Resolve velocity source components ---
232
+ self.incoords_u, self.u0 = self._resolve_velocity_component("u", u0, coords_u, mask_u)
233
+ self.incoords_v, self.v0 = self._resolve_velocity_component("v", v0, coords_v, mask_v)
234
+ self.incoords_w, self.w0 = self._resolve_velocity_component("w", w0, coords_w, mask_w)
235
+
176
236
  self.is_3d = len(self.grid_shape) == 3
177
237
  super().__init__()
178
238
  self.__post_init__()
@@ -202,11 +262,64 @@ class Source:
202
262
  )
203
263
  raise ValueError(error_msg)
204
264
 
265
+ @staticmethod
266
+ def _resolve_velocity_component(
267
+ name: str,
268
+ signal: NDArray | None,
269
+ coords: NDArray | None,
270
+ mask: NDArray | None,
271
+ ) -> tuple[NDArray | None, NDArray | None]:
272
+ """Resolve a velocity source component to (incoords, signal_arr).
273
+
274
+ Returns
275
+ -------
276
+ tuple[NDArray | None, NDArray | None]
277
+ (incoords, signal_arr) if the component is provided, else (None, None).
278
+
279
+ Raises
280
+ ------
281
+ ValueError
282
+ If coords and mask are both provided.
283
+ If signal is provided but neither coords nor mask are given.
284
+ If signal is None but coords/mask are provided.
285
+ If signal row count does not match the number of coordinate points.
286
+
287
+ """
288
+ if signal is None:
289
+ if coords is not None or mask is not None:
290
+ error_msg = f"coords_{name}/mask_{name} provided without {name}0 signal"
291
+ raise ValueError(error_msg)
292
+ return None, None
293
+ if coords is not None and mask is not None:
294
+ error_msg = f"coords_{name} and mask_{name} are mutually exclusive"
295
+ raise ValueError(error_msg)
296
+ if coords is None and mask is None:
297
+ error_msg = f"{name}0 signal provided but neither coords_{name} nor mask_{name} given"
298
+ raise ValueError(error_msg)
299
+ if coords is not None:
300
+ incoords = np.atleast_2d(coords).astype(np.int64, copy=False)
301
+ else:
302
+ incoords = map_to_coords(np.atleast_2d(mask))
303
+ signal_arr = np.atleast_2d(signal)
304
+ if signal_arr.shape[0] != incoords.shape[0]:
305
+ error_msg = (
306
+ f"{name}0 has {signal_arr.shape[0]} rows but "
307
+ f"coords_{name}/mask_{name} has {incoords.shape[0]} (must match)"
308
+ )
309
+ raise ValueError(error_msg)
310
+ return incoords, signal_arr
311
+
205
312
  def validate(self, grid_shape: NDArray[np.int64] | tuple) -> None:
206
313
  """Check if the source coordinates are consistent with the grid shape."""
207
314
  grid_shape = tuple(grid_shape) if isinstance(grid_shape, np.ndarray) else grid_shape
208
315
  assert self.grid_shape == grid_shape, f"{self.grid_shape} != {grid_shape}"
209
- assert self.n_sources > 0 or self.n_sources_add > 0, "No active source found."
316
+ assert (
317
+ self.n_sources > 0
318
+ or self.n_sources_add > 0
319
+ or self.n_sources_u > 0
320
+ or self.n_sources_v > 0
321
+ or self.n_sources_w > 0
322
+ ), "No active source found."
210
323
  logger.debug("Source validated against grid shape.")
211
324
 
212
325
  @property
@@ -246,6 +359,21 @@ class Source:
246
359
  return 0
247
360
  return self.incoords_add.shape[0]
248
361
 
362
+ @property
363
+ def n_sources_u(self) -> int:
364
+ """Return the number of velocity-u (depth) source positions."""
365
+ return self.incoords_u.shape[0] if self.incoords_u is not None else 0
366
+
367
+ @property
368
+ def n_sources_v(self) -> int:
369
+ """Return the number of velocity-v (lateral) source positions."""
370
+ return self.incoords_v.shape[0] if self.incoords_v is not None else 0
371
+
372
+ @property
373
+ def n_sources_w(self) -> int:
374
+ """Return the number of velocity-w (elevational) source positions."""
375
+ return self.incoords_w.shape[0] if self.incoords_w is not None else 0
376
+
249
377
  def plot(
250
378
  self,
251
379
  export_path: Path | str | None = Path("./temp/temp.png"),
@@ -312,6 +440,13 @@ class Source:
312
440
  lines.append(f" p0_additive shape: {self.p0_additive.shape}")
313
441
  if self.incoords_add is not None:
314
442
  lines.append(f" incoords_add: {self.incoords_add.shape[0]} points")
443
+ for comp, sig, n in (
444
+ ("u", self.u0, self.n_sources_u),
445
+ ("v", self.v0, self.n_sources_v),
446
+ ("w", self.w0, self.n_sources_w),
447
+ ):
448
+ if sig is not None:
449
+ lines.append(f" {comp}0 shape: {sig.shape} ({n} points)")
315
450
  return "\n".join(lines) + "\n"
316
451
 
317
452
  def __repr__(self) -> str:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fullwave25"
3
- version = "1.2.5-dev0" # Update via bump-my-version, not manually
3
+ version = "1.2.6-dev1" # 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.5.dev0"
738
+ version = "1.2.6.dev1"
739
739
  source = { editable = "." }
740
740
  dependencies = [
741
741
  { name = "joblib" },
File without changes