Skip to content

Commit c6a6503

Browse files
peterhollenderebrahimebrahim
authored andcommitted
Update to list of tuples form #439
1 parent 10e0dc8 commit c6a6503

4 files changed

Lines changed: 102 additions & 129 deletions

File tree

src/openlifu/xdc/element.py

Lines changed: 12 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -2,68 +2,23 @@
22

33
import copy
44
from dataclasses import dataclass, field
5-
from typing import Annotated
5+
from typing import Annotated, List
66

77
import numpy as np
88

99
from openlifu.util.annotations import OpenLIFUFieldData
1010
from openlifu.util.units import getunitconversion
1111

12-
SENS_FREQ_KEY = "freq_Hz"
13-
SENS_VALUE_KEY = "values_Pa_per_V"
1412

15-
16-
def normalize_sensitivity(sensitivity: float | dict) -> float | dict[str, list[float]]:
17-
"""Normalize sensitivity to a canonical representation.
18-
19-
Canonical frequency-dependent representation is:
20-
{"freq_Hz": [...], "values_Pa_per_V": [...]}
21-
22-
Backward-compatible legacy representation with frequency keys is accepted and
23-
converted to the canonical representation.
24-
"""
25-
if isinstance(sensitivity, dict):
26-
if SENS_FREQ_KEY in sensitivity or SENS_VALUE_KEY in sensitivity:
27-
if SENS_FREQ_KEY not in sensitivity or SENS_VALUE_KEY not in sensitivity:
28-
raise ValueError("Sensitivity dictionary must include both 'freq_Hz' and 'values_Pa_per_V'.")
29-
freqs = np.asarray(sensitivity[SENS_FREQ_KEY], dtype=np.float64).reshape(-1)
30-
values = np.asarray(sensitivity[SENS_VALUE_KEY], dtype=np.float64).reshape(-1)
31-
else:
32-
# Legacy format: {frequency_hz: sensitivity}
33-
if len(sensitivity) == 0:
34-
raise ValueError("Sensitivity dictionary must not be empty.")
35-
mapping = {float(k): float(v) for k, v in sensitivity.items()}
36-
freqs = np.array(list(mapping.keys()), dtype=np.float64)
37-
values = np.array(list(mapping.values()), dtype=np.float64)
38-
39-
if len(freqs) == 0:
40-
raise ValueError("Sensitivity frequency list must not be empty.")
41-
if len(freqs) != len(values):
42-
raise ValueError("Sensitivity frequency and value lists must have the same length.")
43-
44-
order = np.argsort(freqs)
45-
freqs = freqs[order]
46-
values = values[order]
47-
if np.any(np.diff(freqs) <= 0):
48-
raise ValueError("Sensitivity frequencies must be strictly increasing.")
49-
50-
return {
51-
SENS_FREQ_KEY: [float(f) for f in freqs],
52-
SENS_VALUE_KEY: [float(v) for v in values],
53-
}
54-
55-
return float(sensitivity)
56-
57-
58-
def sensitivity_at_frequency(sensitivity: float | dict, frequency: float) -> float:
59-
sensitivity = normalize_sensitivity(sensitivity)
60-
if isinstance(sensitivity, dict):
61-
if frequency in sensitivity[SENS_FREQ_KEY]:
62-
idx = sensitivity[SENS_FREQ_KEY].index(frequency)
63-
return float(sensitivity[SENS_VALUE_KEY][idx])
13+
def sensitivity_at_frequency(sensitivity: float | List[tuple[float, float]], frequency: float) -> float:
14+
if isinstance(sensitivity, list):
15+
freqs, values = zip(*sensitivity)
16+
freqs = np.array(freqs, dtype=np.float64)
17+
values = np.array(values, dtype=np.float64)
18+
if frequency in freqs:
19+
idx = np.where(freqs == frequency)[0][0]
20+
return float(values[idx])
6421
else:
65-
freqs = np.array(sensitivity[SENS_FREQ_KEY], dtype=np.float64)
66-
values = np.array(sensitivity[SENS_VALUE_KEY], dtype=np.float64)
6722
return float(np.interp(frequency, freqs, values, left=values[0], right=values[-1]))
6823
return float(sensitivity)
6924

@@ -114,7 +69,7 @@ class Element:
11469
size: Annotated[np.ndarray, OpenLIFUFieldData("Size", "Size of the element in 2D")] = field(default_factory=lambda: np.array([1., 1.]))
11570
""" Size of the element in 2D as a numpy array [width, length]."""
11671

117-
sensitivity: Annotated[float | dict, OpenLIFUFieldData("Sensitivity", "Sensitivity of the element (Pa/V), scalar or {'freq_Hz':[...], 'values_Pa_per_V':[...]}")] = 1.0
72+
sensitivity: Annotated[float | List[tuple[float, float]], OpenLIFUFieldData("Sensitivity", "Sensitivity of the element (Pa/V), scalar or list of (frequency, value) tuples")] = 1.0
11873
"""Sensitivity of the element (Pa/V)"""
11974

12075
pin: Annotated[int, OpenLIFUFieldData("Pin", "Channel pin to which the element is connected")] = -1
@@ -135,7 +90,8 @@ def __post_init__(self):
13590
raise ValueError("Size must be a 2-element array.")
13691
if self.sensitivity is None:
13792
self.sensitivity = 1.0
138-
self.sensitivity = normalize_sensitivity(self.sensitivity)
93+
elif isinstance(self.sensitivity, list):
94+
self.sensitivity = [(float(f), float(v)) for f, v in self.sensitivity]
13995

14096
@property
14197
def x(self):

src/openlifu/xdc/transducer.py

Lines changed: 31 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from openlifu.xdc.element import (
1515
Element,
1616
generate_drive_signal,
17-
normalize_sensitivity,
1817
sensitivity_at_frequency,
1918
)
2019

@@ -23,38 +22,28 @@
2322

2423

2524
def _combine_sensitivities(
26-
base_sensitivity: float | dict,
27-
scale_sensitivity: float | dict,
28-
) -> float | dict[str, list[float]]:
29-
base_sensitivity = normalize_sensitivity(base_sensitivity)
30-
scale_sensitivity = normalize_sensitivity(scale_sensitivity)
31-
32-
if isinstance(base_sensitivity, dict) and isinstance(scale_sensitivity, dict):
33-
base_freqs = np.asarray(base_sensitivity["freq_Hz"], dtype=np.float64)
34-
scale_freqs = np.asarray(scale_sensitivity["freq_Hz"], dtype=np.float64)
25+
base_sensitivity: float | List[tuple[float, float]],
26+
scale_sensitivity: float | List[tuple[float, float]],
27+
) -> float | List[tuple[float, float]]:
28+
29+
if isinstance(base_sensitivity, list) and isinstance(scale_sensitivity, list):
30+
base_freqs = np.asarray([f for f, _ in base_sensitivity], dtype=np.float64)
31+
scale_freqs = np.asarray([f for f, _ in scale_sensitivity], dtype=np.float64)
3532
if not np.array_equal(base_freqs, scale_freqs):
3633
raise ValueError("Cannot combine sensitivity dictionaries with different frequency keys.")
37-
base_values = np.asarray(base_sensitivity["values_Pa_per_V"], dtype=np.float64)
38-
scale_values = np.asarray(scale_sensitivity["values_Pa_per_V"], dtype=np.float64)
39-
return {
40-
"freq_Hz": [float(f) for f in base_freqs],
41-
"values_Pa_per_V": [float(v) for v in (base_values * scale_values)],
42-
}
43-
if isinstance(base_sensitivity, dict):
34+
base_values = np.asarray([v for _, v in base_sensitivity], dtype=np.float64)
35+
scale_values = np.asarray([v for _, v in scale_sensitivity], dtype=np.float64)
36+
return [(float(f), float(v)) for f, v in zip(base_freqs, base_values * scale_values)]
37+
elif isinstance(base_sensitivity, list):
4438
factor = float(scale_sensitivity)
45-
values = np.asarray(base_sensitivity["values_Pa_per_V"], dtype=np.float64)
46-
return {
47-
"freq_Hz": [float(f) for f in base_sensitivity["freq_Hz"]],
48-
"values_Pa_per_V": [float(v) for v in (values * factor)],
49-
}
50-
if isinstance(scale_sensitivity, dict):
39+
values = np.asarray([v for _, v in base_sensitivity], dtype=np.float64)
40+
return [(float(f), float(v)) for (f, _), v in zip(base_sensitivity, values * factor)]
41+
elif isinstance(scale_sensitivity, list):
5142
factor = float(base_sensitivity)
52-
values = np.asarray(scale_sensitivity["values_Pa_per_V"], dtype=np.float64)
53-
return {
54-
"freq_Hz": [float(f) for f in scale_sensitivity["freq_Hz"]],
55-
"values_Pa_per_V": [float(v) for v in (factor * values)],
56-
}
57-
return float(base_sensitivity) * float(scale_sensitivity)
43+
values = np.asarray([v for _, v in scale_sensitivity], dtype=np.float64)
44+
return [(float(f), float(v)) for (f, _), v in zip(scale_sensitivity, factor * values)]
45+
else:
46+
return float(base_sensitivity) * float(scale_sensitivity)
5847

5948
@dataclass
6049
class Transducer:
@@ -95,8 +84,8 @@ class Transducer:
9584
The units of this transform are assumed to be the native units of the transducer, the `Transducer.units` field.
9685
"""
9786

98-
sensitivity: Annotated[float | dict, OpenLIFUFieldData("Sensitivity", "Sensitivity of the transducer (Pa/V), scalar or {'freq_Hz':[...], 'values_Pa_per_V':[...]}")] = 1.0
99-
"""Sensitivity of the transducer (Pa/V), scalar or frequency-dependent dictionary."""
87+
sensitivity: Annotated[float | List[tuple[float, float]], OpenLIFUFieldData("Sensitivity", "Sensitivity of the transducer (Pa/V), scalar or list of (frequency, value) tuples")] = 1.0
88+
"""Sensitivity of the transducer (Pa/V), scalar or frequency-dependent list of tuples."""
10089

10190
crosstalk_frac: Annotated[float, OpenLIFUFieldData("Crosstalk fraction", "Fraction of the signal that leaks into other elements due to crosstalk")] = 0.0
10291
"""Fraction of the signal that leaks into other elements due to crosstalk"""
@@ -115,21 +104,19 @@ def __post_init__(self):
115104
element.rescale(self.units)
116105
if self.sensitivity is None:
117106
self.sensitivity = 1.0
118-
self.sensitivity = normalize_sensitivity(self.sensitivity)
119-
120-
def get_sensitivity(self, frequency: float) -> float:
121-
return sensitivity_at_frequency(self.sensitivity, frequency)
107+
elif isinstance(self.sensitivity, list):
108+
self.sensitivity = [(float(f), float(v)) for f, v in self.sensitivity]
122109

123110
def calc_output(self, cycles: float, frequency: float, dt: float, delays: np.ndarray = None, apod: np.ndarray = None, amplitude: float = 1.0) -> np.ndarray:
124111
if delays is None:
125112
delays = np.zeros(self.numelements())
126113
if apod is None:
127114
apod = np.ones(self.numelements())
128115
drive_signal = generate_drive_signal(cycles=cycles, frequency=frequency, dt=dt, amplitude=amplitude)
129-
base_output = drive_signal * self.get_sensitivity(frequency)
116+
base_output = drive_signal * sensitivity_at_frequency(self.sensitivity, frequency)
130117
outputs = [
131118
np.concatenate(
132-
[np.zeros(int(delay / dt)), a * element.get_sensitivity(frequency) * base_output],
119+
[np.zeros(int(delay / dt)), a * sensitivity_at_frequency(element.sensitivity, frequency) * base_output],
133120
axis=0,
134121
)
135122
for element, delay, a, in zip(self.elements, delays, apod)
@@ -260,22 +247,20 @@ def merge(list_of_transducers:List[Transducer], offset_pins:bool=False, offset_i
260247
array_copies = [arr.copy() for arr in list_of_transducers]
261248
dict_key_sets = set()
262249
for array in array_copies:
263-
array.sensitivity = normalize_sensitivity(array.sensitivity)
264-
if isinstance(array.sensitivity, dict):
265-
dict_key_sets.add(tuple(array.sensitivity["freq_Hz"]))
250+
if isinstance(array.sensitivity, list):
251+
dict_key_sets.add(tuple(f for f, _ in array.sensitivity))
266252
for el in array.elements:
267-
el.sensitivity = normalize_sensitivity(el.sensitivity)
268-
if isinstance(el.sensitivity, dict):
269-
dict_key_sets.add(tuple(el.sensitivity["freq_Hz"]))
253+
if isinstance(el.sensitivity, list):
254+
dict_key_sets.add(tuple(f for f, _ in el.sensitivity))
270255
if len(dict_key_sets) > 1:
271256
raise ValueError("Cannot merge sensitivities with different frequency keys.")
272257

273258
sensitivity_signatures = []
274259
for array in array_copies:
275-
if isinstance(array.sensitivity, dict):
260+
if isinstance(array.sensitivity, list):
276261
sensitivity_signatures.append((
277-
tuple(array.sensitivity["freq_Hz"]),
278-
tuple(array.sensitivity["values_Pa_per_V"]),
262+
tuple(f for f, _ in array.sensitivity),
263+
tuple(v for _, v in array.sensitivity),
279264
))
280265
else:
281266
sensitivity_signatures.append(float(array.sensitivity))

src/openlifu/xdc/util.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -300,10 +300,7 @@ def report_to_matrix_dict(report_df: pd.DataFrame, focal_gain_lut=FOCAL_GAIN_LUT
300300
freq_df["Frequency"] = freq_df['Item'].apply(lambda x: float(re.search(r"(?<=^PNP \()\d+(?= kHz\)$)", x).group(0)))
301301
freq_df['focal_gain'] = freq_df['Frequency'].apply(lambda f: focal_gain_lut.interp(f0=f*1e3, crosstalk=matrix_dict['crosstalk_frac']).item())
302302
freq_df['Sensitivity'] = freq_df['PNP'].astype(float)*1e6/freq_df['focal_gain']/voltage
303-
matrix_dict['sensitivity'] = {
304-
'freq_Hz': [float(f * 1e3) for f in freq_df["Frequency"]],
305-
'values_Pa_per_V': [float(sens) for sens in freq_df['Sensitivity']],
306-
}
303+
matrix_dict['sensitivity'] = [(f*1e3, sens) for f, sens in zip(freq_df["Frequency"], freq_df['Sensitivity'])]
307304
matrix_dict['id'] = matrix_dict['id'].format(sn=sn.lower())
308305
matrix_dict['name'] = matrix_dict['name'].format(sn=sn)
309306
return matrix_dict

tests/test_transducer.py

Lines changed: 58 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ def test_transducer_calc_output_interpolates_dictionary_sensitivity():
121121
nx=1,
122122
ny=1,
123123
units="mm",
124-
sensitivity={"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [1.0, 3.0]},
124+
sensitivity=[(100e3, 1.0), (300e3, 3.0)],
125125
)
126126
transducer.elements[0].sensitivity = 1.0
127127
cycles = 3
@@ -141,16 +141,6 @@ def test_transducer_calc_output_interpolates_dictionary_sensitivity():
141141
np.testing.assert_allclose(output_low[0], expected_low)
142142

143143

144-
def test_legacy_sensitivity_mapping_is_normalized_to_schema():
145-
transducer = Transducer(
146-
sensitivity={"100000.0": 1.0, "300000.0": 3.0},
147-
)
148-
assert transducer.sensitivity == {
149-
"freq_Hz": [100000.0, 300000.0],
150-
"values_Pa_per_V": [1.0, 3.0],
151-
}
152-
153-
154144
def test_element_calc_output_generates_signal_from_scalar_input():
155145
element = Element(sensitivity=2.0)
156146
cycles = 4
@@ -184,39 +174,39 @@ def test_merge_pushes_transducer_sensitivity_into_elements():
184174
nx=1,
185175
ny=1,
186176
units="mm",
187-
sensitivity={"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [2.0, 4.0]},
177+
sensitivity=[(100e3, 2.0), (300e3, 4.0)],
188178
)
189179
transducer_b = Transducer.gen_matrix_array(
190180
nx=1,
191181
ny=1,
192182
units="mm",
193-
sensitivity={"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [3.0, 6.0]},
183+
sensitivity=[(100e3, 3.0), (300e3, 6.0)],
194184
)
195185
transducer_a.elements[0].sensitivity = 5.0
196186
transducer_b.elements[0].sensitivity = 7.0
197187

198188
merged = Transducer.merge([transducer_a, transducer_b], merge_mismatched_sensitivity=True)
199189

200190
assert merged.sensitivity == 1.0
201-
assert merged.elements[0].sensitivity == {"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [10.0, 20.0]}
202-
assert merged.elements[1].sensitivity == {"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [21.0, 42.0]}
191+
assert merged.elements[0].sensitivity == [(100e3, 10.0),(300e3, 20.0)]
192+
assert merged.elements[1].sensitivity == [(100e3, 21.0),(300e3, 42.0)]
203193

204194

205195
def test_merge_rejects_mismatched_sensitivity_keys():
206196
transducer_a = Transducer.gen_matrix_array(
207197
nx=1,
208198
ny=1,
209199
units="mm",
210-
sensitivity={"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [2.0, 4.0]},
200+
sensitivity=[(100e3, 2.0), (300e3, 4.0)],
211201
)
212202
transducer_b = Transducer.gen_matrix_array(
213203
nx=1,
214204
ny=1,
215205
units="mm",
216-
sensitivity={"freq_Hz": [100e3, 400e3], "values_Pa_per_V": [3.0, 6.0]},
206+
sensitivity=[(100e3, 2.0), (300e3, 4.0)],
217207
)
218-
transducer_a.elements[0].sensitivity = {"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [5.0, 7.0]}
219-
transducer_b.elements[0].sensitivity = {"freq_Hz": [100e3, 400e3], "values_Pa_per_V": [11.0, 13.0]}
208+
transducer_a.elements[0].sensitivity = [(100e3, 5.0), (300e3, 7.0)]
209+
transducer_b.elements[0].sensitivity = [(100e3, 11.0), (400e3, 13.0)]
220210

221211
with pytest.raises(ValueError, match="different frequency keys"):
222212
Transducer.merge([transducer_a, transducer_b], merge_mismatched_sensitivity=True)
@@ -307,9 +297,9 @@ def test_transducer_calc_output_combines_frequency_dependent_sensitivities():
307297
nx=1,
308298
ny=1,
309299
units="mm",
310-
sensitivity={"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [2.0, 4.0]},
300+
sensitivity=[(100e3, 2.0), (300e3, 4.0)],
311301
)
312-
transducer.elements[0].sensitivity = {"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [5.0, 9.0]}
302+
transducer.elements[0].sensitivity = [(100e3, 5.0), (300e3, 9.0)]
313303

314304
frequency = 200e3
315305
dt = 1e-7
@@ -328,13 +318,13 @@ def test_transducer_array_to_transducer_preserves_frequency_dependent_sensitivit
328318
nx=1,
329319
ny=1,
330320
units="mm",
331-
sensitivity={"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [2.0, 4.0]},
321+
sensitivity=[(100e3, 2.0), (300e3, 4.0)],
332322
)
333323
transducer_b = Transducer.gen_matrix_array(
334324
nx=1,
335325
ny=1,
336326
units="mm",
337-
sensitivity={"freq_Hz": [100e3, 300e3], "values_Pa_per_V": [1.0, 3.0]},
327+
sensitivity=[(100e3, 1.0), (300e3, 3.0)],
338328
)
339329
transducer_a.elements[0].sensitivity = 5.0
340330
transducer_b.elements[0].sensitivity = 7.0
@@ -360,3 +350,48 @@ def test_transducer_array_to_transducer_preserves_frequency_dependent_sensitivit
360350

361351
np.testing.assert_allclose(output[0], 15.0 * expected_drive)
362352
np.testing.assert_allclose(output[1], 14.0 * expected_drive)
353+
354+
355+
def test_element_sensitivity_from_json_is_list_of_tuples():
356+
"""Sensitivity read from a JSON dict (list-of-lists) is converted to List[tuple[float, float]]."""
357+
d = {
358+
"index": 1,
359+
"position": [0.0, 0.0, 0.0],
360+
"orientation": [0.0, 0.0, 0.0],
361+
"size": [1.0, 1.0],
362+
"pin": 1,
363+
"units": "mm",
364+
"sensitivity": [[100e3, 1.0], [300e3, 3.0]], # JSON encodes tuples as lists
365+
}
366+
element = Element.from_dict(d)
367+
assert isinstance(element.sensitivity, list)
368+
assert all(isinstance(pair, tuple) for pair in element.sensitivity)
369+
assert all(isinstance(f, float) and isinstance(v, float) for f, v in element.sensitivity)
370+
assert element.sensitivity == [(100e3, 1.0), (300e3, 3.0)]
371+
372+
373+
def test_transducer_sensitivity_from_json_is_list_of_tuples():
374+
"""Transducer-level sensitivity survives a to_json/from_json round-trip as List[tuple[float, float]]."""
375+
transducer = Transducer.gen_matrix_array(
376+
nx=1,
377+
ny=1,
378+
units="mm",
379+
sensitivity=[(100e3, 2.0), (300e3, 4.0)],
380+
)
381+
reconstructed = Transducer.from_json(transducer.to_json())
382+
assert isinstance(reconstructed.sensitivity, list)
383+
assert all(isinstance(pair, tuple) for pair in reconstructed.sensitivity)
384+
assert all(isinstance(f, float) and isinstance(v, float) for f, v in reconstructed.sensitivity)
385+
assert reconstructed.sensitivity == [(100e3, 2.0), (300e3, 4.0)]
386+
387+
388+
def test_element_in_transducer_sensitivity_from_json_is_list_of_tuples():
389+
"""Element-level sensitivity inside a Transducer survives a to_json/from_json round-trip as List[tuple[float, float]]."""
390+
transducer = Transducer.gen_matrix_array(nx=1, ny=1, units="mm")
391+
transducer.elements[0].sensitivity = [(100e3, 5.0), (300e3, 9.0)]
392+
reconstructed = Transducer.from_json(transducer.to_json())
393+
el_sensitivity = reconstructed.elements[0].sensitivity
394+
assert isinstance(el_sensitivity, list)
395+
assert all(isinstance(pair, tuple) for pair in el_sensitivity)
396+
assert all(isinstance(f, float) and isinstance(v, float) for f, v in el_sensitivity)
397+
assert el_sensitivity == [(100e3, 5.0), (300e3, 9.0)]

0 commit comments

Comments
 (0)