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.
- esdiva/__init__.py +142 -0
- esdiva/attenuation/__init__.py +17 -0
- esdiva/attenuation/attenuation.py +376 -0
- esdiva/beamforming/__init__.py +15 -0
- esdiva/beamforming/das.py +619 -0
- esdiva/emission/__init__.py +7 -0
- esdiva/emission/emission.py +981 -0
- esdiva/emission/sir_to_pressure.py +268 -0
- esdiva/hsir/__init__.py +9 -0
- esdiva/hsir/farfield_rect_patch.py +368 -0
- esdiva/hsir/helpers.py +201 -0
- esdiva/hsir/transducer_sir_pe_sdi.py +1266 -0
- esdiva/io/__init__.py +6 -0
- esdiva/io/hdf5.py +98 -0
- esdiva/io/rf_dataset.py +399 -0
- esdiva/plotting/__init__.py +66 -0
- esdiva/plotting/export_utils.py +174 -0
- esdiva/plotting/plane_utils.py +383 -0
- esdiva/plotting/plotting2D.py +946 -0
- esdiva/plotting/plotting3D.py +674 -0
- esdiva/plotting/plotting_pyvista.py +669 -0
- esdiva/plotting/pyvista_functions.py +284 -0
- esdiva/plotting/validators.py +61 -0
- esdiva/py.typed +0 -0
- esdiva/reception/__init__.py +6 -0
- esdiva/reception/base.py +1277 -0
- esdiva/reception/conventional.py +721 -0
- esdiva/reception/reception.py +1074 -0
- esdiva/simulation_base.py +95 -0
- esdiva/transducers/__init__.py +137 -0
- esdiva/transducers/base.py +954 -0
- esdiva/transducers/circular.py +870 -0
- esdiva/transducers/custom.py +302 -0
- esdiva/transducers/fieldii_compat.py +482 -0
- esdiva/transducers/geometry_utils.py +214 -0
- esdiva/transducers/linear.py +614 -0
- esdiva/transducers/matrix.py +424 -0
- esdiva/transducers/saved_transducers.py +72 -0
- esdiva/transducers/validators.py +306 -0
- esdiva/utilities/__init__.py +31 -0
- esdiva/utilities/bg_atlas.py +374 -0
- esdiva/utilities/helper_functions.py +752 -0
- esdiva/utilities/matlab.py +91 -0
- esdiva/utilities/phantom.py +84 -0
- esdiva/utilities/surface_subdivision.py +504 -0
- esdiva-0.1.0.dist-info/METADATA +212 -0
- esdiva-0.1.0.dist-info/RECORD +50 -0
- esdiva-0.1.0.dist-info/WHEEL +4 -0
- esdiva-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
]
|