Skip to content

Commit 10e0dc8

Browse files
peterhollenderebrahimebrahim
authored andcommitted
Clean up transducerarray to move elegantly handle differently-sensitive modules #439
1 parent 14cc09b commit 10e0dc8

3 files changed

Lines changed: 205 additions & 21 deletions

File tree

src/openlifu/sim/kwave_if.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def run_simulation(arr: xdc.Transducer,
141141
kgrid = get_kgrid(params.coords, dt=dt, t_end=t_end, cfl=cfl)
142142
t = np.arange(0, np.min([cycles / freq, (kgrid.Nt-np.ceil(max(delays)/kgrid.dt))*kgrid.dt]), kgrid.dt)
143143
if cycles/freq > t[-1]:
144-
cycles = int(t[-1]*freq)
144+
cycles = np.ceil(t[-1]*freq)
145145
units = [params[dim].attrs['units'] for dim in params.dims]
146146
if not all(unit == units[0] for unit in units):
147147
raise ValueError("All dimensions must have the same units")

src/openlifu/xdc/transducerarray.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@ def get_angle_from_gap(width, gap, roc):
2323
def get_roc_from_angle(width, gap, dth):
2424
return (0.5*gap + (0.5 * width * np.cos(dth))) / np.sin(dth)
2525

26+
def get_gap_from_angle(width, dth, roc):
27+
a = roc
28+
b = width/2
29+
mag = np.sqrt(a**2 + b**2)
30+
A = a/mag
31+
B = b/mag
32+
gap = 2*mag*np.sin(dth - np.arcsin(B))
33+
return gap if A >= 0 else -gap
34+
2635
@dataclass
2736
class TransducerArray(DictMixin):
2837
id: str = "transducer_array"
@@ -84,22 +93,44 @@ def to_file(self, file_path: str, compact: bool = False) -> None:
8493
f.write(json_string)
8594

8695
@staticmethod
87-
def get_concave_cylinder(trans, rows=1, cols=1, width=40, gap=0, dth=None, roc=np.inf, units="mm", id="transducer_array", name="Transducer Array", attrs: dict={}):
88-
scl = getunitconversion(units, trans.units)
96+
def get_concave_cylinder(trans, rows=1, cols=1, width=40, gap=None, dth=None, roc=None, units="mm", id="transducer_array", name="Transducer Array", attrs: dict={}):
97+
8998
modules = []
90-
if roc == np.inf:
99+
if isinstance(trans, Transducer):
100+
trans_arr = np.array([[trans]*cols for _ in range(rows)])
101+
else:
102+
trans_arr = np.array(trans).reshape(rows, cols)
103+
scl = getunitconversion(units, trans_arr[0,0].units)
104+
if gap is None:
105+
if dth is not None and roc is not None:
106+
gap = get_gap_from_angle(width, dth, roc)
107+
else:
108+
gap = 0
109+
elif dth is not None and roc is not None:
110+
raise ValueError("Invalid combination of parameters: cannot specify all of gap, dth, and roc.")
111+
112+
if dth is None:
113+
if roc is not None:
114+
dth = get_angle_from_gap(width, gap, roc)
115+
else:
116+
dth = 0
117+
roc = np.inf
118+
119+
if roc is None:
120+
if np.isclose(dth, 0.0):
121+
roc = np.inf
122+
else:
123+
roc = get_roc_from_angle(width, gap, dth)
124+
125+
if dth == 0:
91126
for i in range(rows):
92127
y = (width+gap)*(i-(rows-1)/2)*scl
93128
for j in range(cols):
94129
dx = (width+gap)*(j-(cols-1)/2)*scl
95130
M = np.array([[1,0,0,dx], [0,1,0,y], [0,0,1,0], [0,0,0,1]])
96-
trans_new = TransformedTransducer.from_transducer(trans, transform=np.linalg.inv(M))
131+
trans_new = TransformedTransducer.from_transducer(trans_arr[i,j], transform=np.linalg.inv(M))
97132
modules.append(trans_new)
98133
else:
99-
if dth is None:
100-
dth = get_angle_from_gap(width, gap, roc)
101-
elif roc is None and dth is not None and gap is not None:
102-
roc = get_roc_from_angle(width, gap, dth)
103134
for i in range(rows):
104135
y = (width+gap)*(i-(rows-1)/2)*scl
105136
for j in range(cols):
@@ -110,7 +141,7 @@ def get_concave_cylinder(trans, rows=1, cols=1, width=40, gap=0, dth=None, roc=n
110141
[0,1,0,y],
111142
[np.sin(th),0,np.cos(th),z],
112143
[0,0,0,1]])
113-
trans_new = TransformedTransducer.from_transducer(trans, transform=np.linalg.inv(M))
144+
trans_new = TransformedTransducer.from_transducer(trans_arr[i,j], transform=np.linalg.inv(M))
114145
modules.append(trans_new)
115146
return TransducerArray(modules=modules, id=id, name=name, attrs=attrs)
116147

tests/test_transducer.py

Lines changed: 164 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77
from helpers import dataclasses_are_equal
88

99
from openlifu.xdc import Element, Transducer, TransducerArray
10+
from openlifu.xdc.transducerarray import (
11+
get_angle_from_gap,
12+
get_gap_from_angle,
13+
get_roc_from_angle,
14+
)
1015

1116

1217
@pytest.fixture()
@@ -119,13 +124,21 @@ def test_transducer_calc_output_interpolates_dictionary_sensitivity():
119124
sensitivity={"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [1.0, 3.0]},
120125
)
121126
transducer.elements[0].sensitivity = 1.0
122-
input_signal = np.array([1.0, -1.0, 0.5], dtype=float)
127+
cycles = 3
128+
dt = 1e-7
123129

124-
output_mid = transducer.calc_output(input_signal, cycles=3, frequency=200e3, dt=1e-7)
125-
output_low = transducer.calc_output(input_signal, cycles=3, frequency=100e3, dt=1e-7)
130+
output_mid = transducer.calc_output(cycles=cycles, frequency=200e3, dt=dt)
131+
output_low = transducer.calc_output(cycles=cycles, frequency=100e3, dt=dt)
126132

127-
np.testing.assert_allclose(output_mid[0, :len(input_signal)], 2.0 * input_signal)
128-
np.testing.assert_allclose(output_low[0, :len(input_signal)], 1.0 * input_signal)
133+
n_samples_mid = int(np.round(cycles / (200e3 * dt)))
134+
n_samples_low = int(np.round(cycles / (100e3 * dt)))
135+
t_mid = np.arange(n_samples_mid) * dt
136+
t_low = np.arange(n_samples_low) * dt
137+
expected_mid = 2.0 * np.sin(2 * np.pi * 200e3 * t_mid)
138+
expected_low = 1.0 * np.sin(2 * np.pi * 100e3 * t_low)
139+
140+
np.testing.assert_allclose(output_mid[0], expected_mid)
141+
np.testing.assert_allclose(output_low[0], expected_low)
129142

130143

131144
def test_legacy_sensitivity_mapping_is_normalized_to_schema():
@@ -145,25 +158,25 @@ def test_element_calc_output_generates_signal_from_scalar_input():
145158
dt = 1e-7
146159
n_samples = int(np.round(cycles / (frequency * dt)))
147160

148-
output = element.calc_output(3.0, cycles=cycles, frequency=frequency, dt=dt)
161+
output = element.calc_output(cycles=cycles, frequency=frequency, dt=dt, amplitude=3.0)
149162
t = np.arange(n_samples) * dt
150163
expected = 2.0 * 3.0 * np.sin(2 * np.pi * frequency * t)
151164

152165
np.testing.assert_allclose(output, expected)
153166

154167

155-
def test_element_calc_output_enforces_cycles_duration_for_array_input():
168+
def test_element_calc_output_enforces_cycles_duration_for_generated_signal():
156169
element = Element(sensitivity=1.0)
157170
cycles = 1
158171
frequency = 200e3
159172
dt = 1e-6
160173
n_samples = int(np.round(cycles / (frequency * dt)))
161-
input_signal = np.arange(20, dtype=float)
162-
163-
output = element.calc_output(input_signal, cycles=cycles, frequency=frequency, dt=dt)
174+
output = element.calc_output(cycles=cycles, frequency=frequency, dt=dt)
175+
t = np.arange(n_samples) * dt
176+
expected = np.sin(2 * np.pi * frequency * t)
164177

165178
assert len(output) == n_samples
166-
np.testing.assert_allclose(output, input_signal[:n_samples])
179+
np.testing.assert_allclose(output, expected)
167180

168181

169182
def test_merge_pushes_transducer_sensitivity_into_elements():
@@ -207,3 +220,143 @@ def test_merge_rejects_mismatched_sensitivity_keys():
207220

208221
with pytest.raises(ValueError, match="different frequency keys"):
209222
Transducer.merge([transducer_a, transducer_b], merge_mismatched_sensitivity=True)
223+
224+
225+
@pytest.mark.parametrize(
226+
("width", "dth", "roc"),
227+
[
228+
(8.0, 0.08, 25.0),
229+
(10.0, 0.12, 30.0),
230+
(12.0, 0.18, 45.0),
231+
],
232+
)
233+
def test_concave_geometry_helpers_are_mutual_inverses(width: float, dth: float, roc: float):
234+
gap = get_gap_from_angle(width, dth, roc)
235+
recovered_roc = get_roc_from_angle(width, gap, dth)
236+
recovered_dth = get_angle_from_gap(width, gap, roc)
237+
recovered_gap = get_gap_from_angle(width, recovered_dth, roc)
238+
239+
assert np.isclose(recovered_roc, roc)
240+
assert np.isclose(recovered_dth, dth)
241+
assert np.isclose(recovered_gap, gap)
242+
243+
244+
def test_get_concave_cylinder_computes_gap_from_dth_and_roc_layout_spacing():
245+
base = Transducer.gen_matrix_array(nx=1, ny=1, units="mm")
246+
width = 8.0
247+
dth = 0.12
248+
roc = 25.0
249+
array = TransducerArray.get_concave_cylinder(
250+
base,
251+
rows=2,
252+
cols=1,
253+
width=width,
254+
dth=dth,
255+
roc=roc,
256+
units="mm",
257+
)
258+
merged = array.to_transducer()
259+
positions = merged.get_positions(units="mm")
260+
261+
expected_gap = get_gap_from_angle(width, dth, roc)
262+
y_spacing = np.abs(positions[1, 1] - positions[0, 1])
263+
264+
assert np.isclose(y_spacing, width + expected_gap)
265+
266+
267+
def test_get_concave_cylinder_handles_zero_dth_without_roc():
268+
base = Transducer.gen_matrix_array(nx=1, ny=1, units="mm")
269+
width = 10.0
270+
gap = 2.0
271+
array = TransducerArray.get_concave_cylinder(
272+
base,
273+
rows=1,
274+
cols=2,
275+
width=width,
276+
gap=gap,
277+
dth=0.0,
278+
units="mm",
279+
)
280+
merged = array.to_transducer()
281+
positions = merged.get_positions(units="mm")
282+
283+
x_spacing = np.abs(positions[1, 0] - positions[0, 0])
284+
z_values = positions[:, 2]
285+
286+
assert np.isclose(x_spacing, width + gap)
287+
np.testing.assert_allclose(z_values, np.zeros_like(z_values))
288+
289+
290+
def test_get_concave_cylinder_rejects_gap_dth_roc_together():
291+
base = Transducer.gen_matrix_array(nx=1, ny=1, units="mm")
292+
with pytest.raises(ValueError, match="cannot specify all of gap, dth, and roc"):
293+
TransducerArray.get_concave_cylinder(
294+
base,
295+
rows=1,
296+
cols=2,
297+
width=10.0,
298+
gap=1.0,
299+
dth=0.2,
300+
roc=20.0,
301+
units="mm",
302+
)
303+
304+
305+
def test_transducer_calc_output_combines_frequency_dependent_sensitivities():
306+
transducer = Transducer.gen_matrix_array(
307+
nx=1,
308+
ny=1,
309+
units="mm",
310+
sensitivity={"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [2.0, 4.0]},
311+
)
312+
transducer.elements[0].sensitivity = {"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [5.0, 9.0]}
313+
314+
frequency = 200e3
315+
dt = 1e-7
316+
cycles = 3
317+
n_samples = int(np.round(cycles / (frequency * dt)))
318+
t = np.arange(n_samples) * dt
319+
expected_drive = np.sin(2 * np.pi * frequency * t)
320+
321+
output = transducer.calc_output(cycles=cycles, frequency=frequency, dt=dt)
322+
323+
np.testing.assert_allclose(output[0], 21.0 * expected_drive)
324+
325+
326+
def test_transducer_array_to_transducer_preserves_frequency_dependent_sensitivities():
327+
transducer_a = Transducer.gen_matrix_array(
328+
nx=1,
329+
ny=1,
330+
units="mm",
331+
sensitivity={"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [2.0, 4.0]},
332+
)
333+
transducer_b = Transducer.gen_matrix_array(
334+
nx=1,
335+
ny=1,
336+
units="mm",
337+
sensitivity={"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [1.0, 3.0]},
338+
)
339+
transducer_a.elements[0].sensitivity = 5.0
340+
transducer_b.elements[0].sensitivity = 7.0
341+
342+
array = TransducerArray.get_concave_cylinder(
343+
[transducer_a, transducer_b],
344+
rows=1,
345+
cols=2,
346+
width=10.0,
347+
gap=0.0,
348+
units="mm",
349+
)
350+
merged = array.to_transducer()
351+
352+
frequency = 200e3
353+
dt = 1e-7
354+
cycles = 2
355+
n_samples = int(np.round(cycles / (frequency * dt)))
356+
t = np.arange(n_samples) * dt
357+
expected_drive = np.sin(2 * np.pi * frequency * t)
358+
359+
output = merged.calc_output(cycles=cycles, frequency=frequency, dt=dt)
360+
361+
np.testing.assert_allclose(output[0], 15.0 * expected_drive)
362+
np.testing.assert_allclose(output[1], 14.0 * expected_drive)

0 commit comments

Comments
 (0)