esdiva 0.1.0__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.
Files changed (50) hide show
  1. esdiva/__init__.py +142 -0
  2. esdiva/attenuation/__init__.py +17 -0
  3. esdiva/attenuation/attenuation.py +376 -0
  4. esdiva/beamforming/__init__.py +15 -0
  5. esdiva/beamforming/das.py +619 -0
  6. esdiva/emission/__init__.py +7 -0
  7. esdiva/emission/emission.py +981 -0
  8. esdiva/emission/sir_to_pressure.py +268 -0
  9. esdiva/hsir/__init__.py +9 -0
  10. esdiva/hsir/farfield_rect_patch.py +368 -0
  11. esdiva/hsir/helpers.py +201 -0
  12. esdiva/hsir/transducer_sir_pe_sdi.py +1266 -0
  13. esdiva/io/__init__.py +6 -0
  14. esdiva/io/hdf5.py +98 -0
  15. esdiva/io/rf_dataset.py +399 -0
  16. esdiva/plotting/__init__.py +66 -0
  17. esdiva/plotting/export_utils.py +174 -0
  18. esdiva/plotting/plane_utils.py +383 -0
  19. esdiva/plotting/plotting2D.py +946 -0
  20. esdiva/plotting/plotting3D.py +674 -0
  21. esdiva/plotting/plotting_pyvista.py +669 -0
  22. esdiva/plotting/pyvista_functions.py +284 -0
  23. esdiva/plotting/validators.py +61 -0
  24. esdiva/py.typed +0 -0
  25. esdiva/reception/__init__.py +6 -0
  26. esdiva/reception/base.py +1277 -0
  27. esdiva/reception/conventional.py +721 -0
  28. esdiva/reception/reception.py +1074 -0
  29. esdiva/simulation_base.py +95 -0
  30. esdiva/transducers/__init__.py +137 -0
  31. esdiva/transducers/base.py +954 -0
  32. esdiva/transducers/circular.py +870 -0
  33. esdiva/transducers/custom.py +302 -0
  34. esdiva/transducers/fieldii_compat.py +482 -0
  35. esdiva/transducers/geometry_utils.py +214 -0
  36. esdiva/transducers/linear.py +614 -0
  37. esdiva/transducers/matrix.py +424 -0
  38. esdiva/transducers/saved_transducers.py +72 -0
  39. esdiva/transducers/validators.py +306 -0
  40. esdiva/utilities/__init__.py +31 -0
  41. esdiva/utilities/bg_atlas.py +374 -0
  42. esdiva/utilities/helper_functions.py +752 -0
  43. esdiva/utilities/matlab.py +91 -0
  44. esdiva/utilities/phantom.py +84 -0
  45. esdiva/utilities/surface_subdivision.py +504 -0
  46. esdiva-0.1.0.dist-info/METADATA +212 -0
  47. esdiva-0.1.0.dist-info/RECORD +50 -0
  48. esdiva-0.1.0.dist-info/WHEEL +4 -0
  49. esdiva-0.1.0.dist-info/entry_points.txt +2 -0
  50. esdiva-0.1.0.dist-info/licenses/LICENSE +28 -0
esdiva/__init__.py ADDED
@@ -0,0 +1,142 @@
1
+ """Acoustic field simulator based on the spatial impulse response method (eSDIva)."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ import esdiva.attenuation as attenuation
6
+ import esdiva.beamforming as beamforming
7
+ import esdiva.emission as emission
8
+ import esdiva.plotting as plotting
9
+ import esdiva.reception as reception
10
+ import esdiva.transducers as transducers
11
+ import esdiva.utilities as utilities
12
+ from esdiva.beamforming import DAS_focused_scanline, envelope_db
13
+ from esdiva.emission import Emission
14
+ from esdiva.plotting import plot2D_pressure_slices
15
+ from esdiva.reception import Reception, ReceptionConventional
16
+ from esdiva.utilities import align_to_common_time, to_dB
17
+
18
+
19
+ # backward-compat alias (old signature was x, y, z, p; new is p, x, y, z)
20
+ def plot_pressure_planes(x, y, z, pressure_field, **kwargs):
21
+ """Plot pressure planes (deprecated, use `plot2D_pressure_slices`).
22
+
23
+ Parameters
24
+ ----------
25
+ x : numpy.ndarray
26
+ Lateral coordinates.
27
+ y : numpy.ndarray
28
+ Elevation coordinates.
29
+ z : numpy.ndarray
30
+ Axial coordinates.
31
+ pressure_field : numpy.ndarray
32
+ Pressure data.
33
+ **kwargs
34
+ Forwarded to `plot2D_pressure_slices`.
35
+
36
+ Returns
37
+ -------
38
+ None
39
+ No return value; displays the plot.
40
+ """
41
+ return plot2D_pressure_slices(pressure_field, x=x, y=y, z=z, **kwargs)
42
+
43
+
44
+ __all__ = [
45
+ "Emission",
46
+ "Reception",
47
+ "ReceptionConventional",
48
+ "attenuation",
49
+ "beamforming",
50
+ "DAS_focused_scanline",
51
+ "emission",
52
+ "envelope_db",
53
+ "reception",
54
+ "transducers",
55
+ "utilities",
56
+ "plotting",
57
+ "plot2D_pressure_slices",
58
+ "align_to_common_time",
59
+ "plot_pressure_planes",
60
+ "to_dB",
61
+ ]
62
+
63
+ try:
64
+ __version__ = version("esdiva")
65
+ except PackageNotFoundError:
66
+ __version__ = "0.1.0"
67
+
68
+
69
+ def main() -> None:
70
+ """Greet the user and run a small timed demo field, then show it in 3-D.
71
+
72
+ Entry point for ``uv run esdiva`` / the ``esdiva`` console script. Prints a
73
+ banner, focuses a 17x17 matrix array at 3 mm, computes one monochromatic
74
+ (CW) pressure volume around the focus, reports how long the SIR took, and
75
+ opens a PyVista window with the transducer mesh and the normalized pressure
76
+ volume — a few-second "it works" that shows the 3-D field for new users.
77
+ """
78
+ import time
79
+
80
+ import numpy as np
81
+ import pyvista as pv
82
+
83
+ from .plotting import add_pressure_vol, add_transducer_mesh, create_3Dvol_mesh
84
+
85
+ banner = rf"""
86
+ ___ ____ ____ ___ __ _
87
+ / _ \ / ___| | _ \ |_ _| __ __ / _` |
88
+ | __/ \___ \ | | | | | | \ \ / / | (_| |
89
+ \___| ___) | | |_| | | | \ V / \__,_|
90
+ |____/ |____/ |___| \_/ v{__version__}
91
+
92
+ Efficient Sparse Delta Integration for Vectorized Acoustics.
93
+ A friendly, fast, exact ultrasound field simulator.
94
+ """
95
+ print(banner)
96
+
97
+ # 17x17 matrix array at 10 MHz, geometrically focused at 3 mm depth.
98
+ probe = transducers.MatrixArrayTransducer(
99
+ n_elements_x=17,
100
+ n_elements_y=17,
101
+ element_width_mm=0.2,
102
+ element_height_mm=0.2,
103
+ kerf_x_mm=0.05,
104
+ kerf_y_mm=0.05,
105
+ no_sub_x=2,
106
+ no_sub_y=2,
107
+ frequency_Hz=10e6,
108
+ )
109
+ focus_mm = np.array([0, 0, 3])
110
+ probe.compute_delays(focus_mm=focus_mm)
111
+ probe.compute_apodization(focus_mm=focus_mm, FoverD=1.0)
112
+
113
+ # Coarse grid around the focus (0.5 x 0.5 x 1.5 mm box) — kept coarse so the
114
+ # volume renders in seconds; drop the step sizes for a finer field.
115
+ field_points = {
116
+ "x_extent": [focus_mm[0] - 0.5, focus_mm[0] + 0.5],
117
+ "y_extent": [focus_mm[1] - 0.5, focus_mm[1] + 0.5],
118
+ "z_extent": [focus_mm[2] - 1.5, focus_mm[2] + 1.5],
119
+ "dx": 0.03,
120
+ "dy": 0.03,
121
+ "dz": 0.05,
122
+ }
123
+
124
+ print("Simulating a CW field at 10 MHz around the 3 mm focus ...")
125
+ sim = emission.Emission(probe, monochromatic=True)
126
+ t0 = time.perf_counter()
127
+ p, coords = sim(field_points, method="auto")
128
+ dt = time.perf_counter() - t0
129
+ print(f" done in {dt:.2f} s (peak |p| = {float(np.abs(p).max()):.3g})")
130
+ print(" opening the 3-D field - close the window to exit.")
131
+
132
+ # Build the transducer + normalized-pressure meshes and render both.
133
+ tx_mesh = probe.get_mesh()
134
+ pressure_mesh = create_3Dvol_mesh(
135
+ p / p.max(), coords["x"], coords["y"], coords["z"], scalars="Pressure"
136
+ )
137
+ plotter = pv.Plotter(window_size=(700, 700), notebook=False)
138
+ plotter = add_pressure_vol(pressure_mesh, plotter=plotter, ambient=0.6)
139
+ plotter = add_transducer_mesh(tx_mesh, plotter=plotter, ambient=1.0)
140
+ plotter.add_axes(label_size=(0.1, 0.1))
141
+ plotter.camera.up = (0, 0, -1)
142
+ plotter.show()
@@ -0,0 +1,17 @@
1
+ """Causal power-law attenuation transfer functions and distance utilities."""
2
+
3
+ from .attenuation import (
4
+ causal_attenuation_tf,
5
+ compute_attenuation_distances,
6
+ compute_reception_distances,
7
+ convert_alpha0_to_nepers,
8
+ reduce_patch_distances_to_element,
9
+ )
10
+
11
+ __all__ = [
12
+ "causal_attenuation_tf",
13
+ "compute_attenuation_distances",
14
+ "compute_reception_distances",
15
+ "convert_alpha0_to_nepers",
16
+ "reduce_patch_distances_to_element",
17
+ ]
@@ -0,0 +1,376 @@
1
+ """Causal power-law attenuation transfer functions and distance helpers.
2
+
3
+ Standalone module — no dependency on Emission or Reception. Both import
4
+ from here.
5
+
6
+ Models frequency-dependent tissue attenuation as a power law ``α(ω) = α₀·|ω|^y``
7
+ together with its causal Kramers-Kronig phase dispersion (Szabo 1994; Holm 2019), so
8
+ the transfer function both damps and correctly delays each frequency — a non-causal
9
+ amplitude-only form would produce pre-echo precursors.
10
+
11
+ Notes
12
+ -----
13
+ **Unit convention**
14
+
15
+ * User-facing: ``alpha0`` in dB/(MHz^y·cm) — matches clinical literature.
16
+ * Internal: ``alpha0`` in Np/(Hz^y·m) — convert via ``convert_alpha0_to_nepers``.
17
+
18
+ **Attenuation model**
19
+
20
+ Causal power-law (Szabo 1994, Holm 2019). Always includes Kramers-Kronig
21
+ dispersion — cost is zero, accuracy is strictly better than non-causal.
22
+
23
+ Frequency convention: formulas use linear frequency f [Hz], not angular
24
+ frequency omega. The ``causal_attenuation_tf`` unit conversion uses
25
+ f-convention (no 2pi factor in the exponent), matching the dB/(MHz^y·cm)
26
+ user unit.
27
+ """
28
+
29
+ import numpy as np
30
+ from numba import njit
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Unit conversion
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ def convert_alpha0_to_nepers(alpha0_dB: float, y: float) -> float:
39
+ """Convert attenuation coefficient from dB/(MHz^y·cm) to Np/(Hz^y·m).
40
+
41
+ Parameters
42
+ ----------
43
+ alpha0_dB : float
44
+ Attenuation in dB/(MHz^y·cm) (user-facing unit).
45
+ y : float
46
+ Power-law exponent.
47
+
48
+ Returns
49
+ -------
50
+ float
51
+ Attenuation coefficient in Np/(Hz^y·m).
52
+
53
+ Notes
54
+ -----
55
+ Conversion:
56
+ ``alpha0_neper = alpha0_dB * 100 / (20 * log10(e) * 1e6^y)``
57
+
58
+ * ``100``: 1/cm → 1/m (100 cm = 1 m).
59
+ * ``20 * log10(e) ≈ 8.686``: dB → Np (1 Np = 8.686 dB).
60
+ * ``(1e6)^y``: MHz^y → Hz^y.
61
+ """
62
+ return float(alpha0_dB) * 100.0 / (20.0 * np.log10(np.e) * (1e6 ** float(y)))
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Core transfer function
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def causal_attenuation_tf(
71
+ freqs_hz: np.ndarray,
72
+ distances_m: np.ndarray,
73
+ alpha0_dB: float,
74
+ y: float,
75
+ f0_hz: float,
76
+ ) -> np.ndarray:
77
+ """Causal power-law attenuation transfer function H_att(f, d).
78
+
79
+ Absorption and Kramers–Kronig dispersion combined.
80
+
81
+ General case (y ≠ 1):
82
+
83
+ .. code-block:: text
84
+
85
+ H(f, d) = exp(-α₀ |f|^y d) · exp(-j α₀ |f|^y tan(yπ/2) d)
86
+
87
+ Special case (y = 1, O'Donnell 1981):
88
+
89
+ .. code-block:: text
90
+
91
+ H(f, d) = exp(-α₀ |f| d) · exp(-j (2α₀/π) f ln(|f|/f₀) d)
92
+
93
+ Where α₀ is in Np/(Hz^y·m) (converted internally from dB/(MHz^y·cm)).
94
+
95
+ Parameters
96
+ ----------
97
+ freqs_hz : (N_freq,) ndarray
98
+ Frequency array in Hz (e.g. from ``numpy.fft.rfftfreq``).
99
+ distances_m : ndarray, shape (...)
100
+ Propagation distances in metres. Any leading shape is accepted —
101
+ the function broadcasts to return ``(..., N_freq)``.
102
+ alpha0_dB : float
103
+ Attenuation coefficient in dB/(MHz^y·cm).
104
+ Pass ``0`` or ``None`` for no attenuation (returns all-ones array).
105
+ y : float
106
+ Power-law exponent (tissue: 1.0–1.3).
107
+ f0_hz : float
108
+ Reference frequency in Hz (transducer centre frequency). Used only
109
+ for the y = 1 logarithmic dispersion term.
110
+
111
+ Returns
112
+ -------
113
+ numpy.ndarray
114
+ Attenuation transfer function H, shape ``(..., N_freq)``,
115
+ complex128. ``|H| <= 1``.
116
+
117
+ Notes
118
+ -----
119
+ * DC (f = 0): H = 1 (no attenuation at zero frequency — correct limit).
120
+ * ``alpha0_dB = 0``: H = 1 everywhere (identity).
121
+ """
122
+ if alpha0_dB is None or alpha0_dB == 0:
123
+ freqs = np.asarray(freqs_hz, dtype=np.float64)
124
+ dist = np.asarray(distances_m, dtype=np.float64)
125
+ return np.ones((*dist.shape, freqs.shape[0]), dtype=np.complex128)
126
+
127
+ alpha0 = convert_alpha0_to_nepers(float(alpha0_dB), float(y))
128
+ freqs = np.asarray(freqs_hz, dtype=np.float64) # (N_freq,)
129
+ dist = np.asarray(distances_m, dtype=np.float64) # (...)
130
+ freq_abs = np.abs(freqs) # (N_freq,)
131
+
132
+ # Broadcast: dist[..., np.newaxis] * freq_term[np.newaxis, ...]
133
+ dist_e = dist[..., np.newaxis] # (..., 1)
134
+
135
+ if abs(float(y) - 1.0) < 1e-10:
136
+ # Special case y = 1: logarithmic K-K dispersion (O'Donnell 1981).
137
+ f0 = float(f0_hz)
138
+ with np.errstate(divide="ignore", invalid="ignore"):
139
+ log_ratio = np.where(freq_abs > 0.0, np.log(freq_abs / f0), 0.0)
140
+ absorption = np.exp(-alpha0 * freq_abs * dist_e)
141
+ # f * ln(|f|/f0) → 0 at DC (handled by where above when freq=0 → log_ratio=0)
142
+ phase = -(2.0 * alpha0 / np.pi) * freqs * log_ratio * dist_e
143
+ H = absorption * np.exp(1j * phase)
144
+ else:
145
+ # General case y ≠ 1: Szabo 1994.
146
+ freq_pow_y = freq_abs ** float(y) # (N_freq,)
147
+ absorption = np.exp(-alpha0 * freq_pow_y * dist_e) # (..., N_freq)
148
+ tan_term = np.tan(float(y) * np.pi / 2.0)
149
+ # sign(f) ensures causal dispersion for negative-frequency components.
150
+ phase = -alpha0 * np.sign(freqs) * freq_pow_y * tan_term * dist_e
151
+ H = absorption * np.exp(1j * phase)
152
+
153
+ return H # complex128, shape (..., N_freq)
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Scalar njit form (one frequency, one path) — shared with the SDI kernels
158
+ # ---------------------------------------------------------------------------
159
+
160
+
161
+ @njit(inline="always")
162
+ def _causal_atten_factor(omega, dist, alpha0_np, y, tan_y, f0_hz, y_is_one):
163
+ """Causal power-law attenuation H_att for one angular frequency over one path.
164
+
165
+ Scalar twin of `causal_attenuation_tf`, written for the Numba SDI kernels so the
166
+ analytic one-way spectrum can attenuate each patch's contribution *per path* as it
167
+ is summed (the patch-to-point distance is already in hand). Same physics: a
168
+ band-damping magnitude times a Kramers-Kronig phase that keeps the pulse causal.
169
+
170
+ The formulas are stated in linear frequency ``f = ω/2π`` (Hz), matching
171
+ `causal_attenuation_tf`. With absorption coefficient ``α₀`` in Np/(Hz^y·m):
172
+
173
+ y ≠ 1 : H = exp(−α₀|f|^y d) · exp(−j α₀ sign(f) |f|^y tan(yπ/2) d)
174
+ y = 1 : H = exp(−α₀|f| d) · exp(−j (2α₀/π) f ln(|f|/f₀) d) (O'Donnell 1981)
175
+
176
+ Parameters
177
+ ----------
178
+ omega : float
179
+ Angular frequency 2πf (rad/s).
180
+ dist : float
181
+ Propagation path length d (metres).
182
+ alpha0_np : float
183
+ Absorption coefficient in Np/(Hz^y·m) (already converted from dB/(MHz^y·cm)
184
+ with `convert_alpha0_to_nepers`).
185
+ y : float
186
+ Power-law exponent.
187
+ tan_y : float
188
+ Precomputed ``tan(yπ/2)`` (ignored on the y = 1 branch; pass any value).
189
+ f0_hz : float
190
+ Reference frequency f₀ (Hz) for the y = 1 logarithmic dispersion.
191
+ y_is_one : bool
192
+ True selects the y = 1 logarithmic-dispersion branch.
193
+
194
+ Returns
195
+ -------
196
+ complex
197
+ Complex attenuation factor (``|H| ≤ 1``); 1 at f = 0.
198
+ """
199
+ f = omega / (2.0 * np.pi)
200
+ f_abs = abs(f)
201
+ if f_abs == 0.0:
202
+ return complex(1.0, 0.0)
203
+ if y_is_one:
204
+ absorption = np.exp(-alpha0_np * f_abs * dist)
205
+ phase = -(2.0 * alpha0_np / np.pi) * f * np.log(f_abs / f0_hz) * dist
206
+ else:
207
+ f_pow_y = f_abs**y
208
+ absorption = np.exp(-alpha0_np * f_pow_y * dist)
209
+ sign_f = 1.0 if f > 0.0 else -1.0
210
+ phase = -alpha0_np * sign_f * f_pow_y * tan_y * dist
211
+ return absorption * complex(np.cos(phase), np.sin(phase))
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # Distance computation helpers
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ def compute_attenuation_distances(
220
+ field_points_m: np.ndarray,
221
+ transducer_center_m: np.ndarray,
222
+ patch_centers_m: np.ndarray | None = None,
223
+ mode: str = "per_point",
224
+ ) -> np.ndarray:
225
+ """Propagation distance for attenuation.
226
+
227
+ Parameters
228
+ ----------
229
+ field_points_m : (P, 3) ndarray
230
+ Field point coordinates in metres.
231
+ transducer_center_m : (3,) ndarray
232
+ Transducer geometric centre in metres.
233
+ patch_centers_m : (M, 3) ndarray or None
234
+ Patch centre coordinates in metres. Required when ``mode="per_patch"``.
235
+ mode : {"per_point", "per_patch"}
236
+ Distance model:
237
+
238
+ * ``"per_point"`` (fast, approximate): ``d_p = |r_p - r_tx_center|``,
239
+ shape ``(P,)``.
240
+ * ``"per_patch"`` (accurate near-field): ``d_{pm} = |r_p - r_m|``,
241
+ shape ``(P, M)``.
242
+
243
+ Returns
244
+ -------
245
+ numpy.ndarray
246
+ Distances in metres. Shape ``(P,)`` for ``per_point``, ``(P, M)``
247
+ for ``per_patch``.
248
+
249
+ Raises
250
+ ------
251
+ ValueError
252
+ If ``mode="per_patch"`` and ``patch_centers_m`` is None, or if
253
+ ``mode`` is unknown.
254
+ """
255
+ field_points_m = np.asarray(field_points_m, dtype=np.float64) # (P, 3)
256
+ transducer_center_m = np.asarray(transducer_center_m, dtype=np.float64) # (3,)
257
+
258
+ if mode == "per_point":
259
+ return np.linalg.norm(field_points_m - transducer_center_m, axis=1) # (P,)
260
+
261
+ if mode == "per_patch":
262
+ if patch_centers_m is None:
263
+ raise ValueError("patch_centers_m required for mode='per_patch'.")
264
+ patch_centers_m = np.asarray(patch_centers_m, dtype=np.float64) # (M, 3)
265
+ # (P, M, 3) → norm → (P, M)
266
+ diff = field_points_m[:, np.newaxis, :] - patch_centers_m[np.newaxis, :, :]
267
+ return np.linalg.norm(diff, axis=2)
268
+
269
+ raise ValueError(f"mode must be 'per_point' or 'per_patch', got '{mode}'.")
270
+
271
+
272
+ def reduce_patch_distances_to_element(
273
+ distances_pm: np.ndarray,
274
+ sub_el_idx: np.ndarray,
275
+ n_elements: int,
276
+ reduce: str = "mean",
277
+ ) -> np.ndarray:
278
+ """Reduce per-patch distances (P, M) to per-element distances (P, E).
279
+
280
+ Parameters
281
+ ----------
282
+ distances_pm : (P, M) ndarray
283
+ Per-patch distances from ``compute_attenuation_distances(mode='per_patch')``.
284
+ sub_el_idx : (M,) int32 ndarray
285
+ Patch-to-element index from ``compute_sub_elem_attributes``.
286
+ n_elements : int
287
+ Total number of elements E.
288
+ reduce : {"mean", "min", "max"}
289
+ Reduction strategy over patches belonging to the same element.
290
+
291
+ * ``"mean"``: average distance (good when patches cluster tightly).
292
+ * ``"min"``: minimum distance (conservative — least attenuation).
293
+ * ``"max"``: maximum distance (most attenuation).
294
+
295
+ Returns
296
+ -------
297
+ numpy.ndarray
298
+ One representative distance per field-point / element pair,
299
+ shape ``(P, E)``.
300
+
301
+ Raises
302
+ ------
303
+ ValueError
304
+ If ``reduce`` is not one of the supported strategies.
305
+ """
306
+ distances_pm = np.asarray(distances_pm, dtype=np.float64) # (P, M)
307
+ sub_el_idx = np.asarray(sub_el_idx, dtype=np.int32) # (M,)
308
+ P = distances_pm.shape[0]
309
+ result = np.zeros((P, n_elements), dtype=np.float64)
310
+
311
+ if reduce not in ("mean", "min", "max"):
312
+ raise ValueError(f"reduce must be 'mean', 'min', or 'max', got '{reduce}'.")
313
+
314
+ for e in range(n_elements):
315
+ mask = sub_el_idx == e
316
+ if not mask.any():
317
+ continue
318
+ d_e = distances_pm[:, mask] # (P, count_e)
319
+ if reduce == "mean":
320
+ result[:, e] = d_e.mean(axis=1)
321
+ elif reduce == "min":
322
+ result[:, e] = d_e.min(axis=1)
323
+ else:
324
+ result[:, e] = d_e.max(axis=1)
325
+
326
+ return result # (P, E)
327
+
328
+
329
+ def compute_reception_distances(
330
+ scatterer_positions_m: np.ndarray,
331
+ tx_center_m: np.ndarray,
332
+ rx_element_centers_m: np.ndarray,
333
+ ) -> np.ndarray:
334
+ """Round-trip distances for per-element Reception attenuation.
335
+
336
+ Two-path model:
337
+ ``d_total[p, e] = |r_s_p - r_tx| + |r_s_p - r_rx_e|``
338
+
339
+ TX path is isotropic (same for all RX elements).
340
+ RX path is per-element (each element receives from a different distance).
341
+
342
+ Parameters
343
+ ----------
344
+ scatterer_positions_m : (P, 3) ndarray
345
+ Scatterer positions in metres.
346
+ tx_center_m : (3,) ndarray
347
+ TX transducer geometric centre in metres.
348
+ rx_element_centers_m : (E_rx, 3) ndarray
349
+ RX element centre positions in metres (one per element).
350
+
351
+ Returns
352
+ -------
353
+ numpy.ndarray
354
+ Total round-trip distance per scatterer-element pair (metres),
355
+ shape ``(P, E_rx)``. Feed directly into ``causal_attenuation_tf``
356
+ to get H_att of shape ``(P, E_rx, N_freq)``.
357
+ """
358
+ scatterer_positions_m = np.asarray(
359
+ scatterer_positions_m, dtype=np.float64
360
+ ) # (P, 3)
361
+ tx_center_m = np.asarray(tx_center_m, dtype=np.float64) # (3,)
362
+ rx_element_centers_m = np.asarray(
363
+ rx_element_centers_m, dtype=np.float64
364
+ ) # (E_rx, 3)
365
+
366
+ # TX path: (P,)
367
+ d_tx = np.linalg.norm(scatterer_positions_m - tx_center_m, axis=1)
368
+
369
+ # RX path: (P, E_rx)
370
+ diff = (
371
+ scatterer_positions_m[:, np.newaxis, :] # (P, 1, 3)
372
+ - rx_element_centers_m[np.newaxis, :, :] # (1, E_rx, 3)
373
+ ) # (P, E_rx, 3)
374
+ d_rx = np.linalg.norm(diff, axis=2) # (P, E_rx)
375
+
376
+ return d_tx[:, np.newaxis] + d_rx # (P, E_rx)
@@ -0,0 +1,15 @@
1
+ """Post-processing beamforming functions for eSDIva RF data."""
2
+
3
+ from .das import (
4
+ DAS_focused_scanline,
5
+ das_rca_volume,
6
+ das_volume,
7
+ envelope_db,
8
+ )
9
+
10
+ __all__ = [
11
+ "DAS_focused_scanline",
12
+ "das_rca_volume",
13
+ "das_volume",
14
+ "envelope_db",
15
+ ]