Skip to content

Commit 83c1310

Browse files
add frequency-dependent sensitivity
Addresses #439
1 parent 9f17b94 commit 83c1310

6 files changed

Lines changed: 235 additions & 116 deletions

File tree

src/openlifu/plan/solution.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,14 @@ def analyze(self,
256256
apodization = self.apodizations[focus_index]
257257
origin = self.transducer.get_effective_origin(apodizations=apodization, units=options.distance_units)
258258

259-
output_signal_Pa = self.transducer.calc_output(input_signal_V, dt, delays=self.delays[focus_index, :], apod=self.apodizations[focus_index, :])
259+
output_signal_Pa = self.transducer.calc_output(
260+
input_signal_V,
261+
cycles=self.pulse.duration * self.pulse.frequency,
262+
frequency=self.pulse.frequency,
263+
dt=dt,
264+
delays=self.delays[focus_index, :],
265+
apod=self.apodizations[focus_index, :],
266+
)
260267
p0_Pa = np.max(output_signal_Pa, axis=1)
261268

262269
mainlobe_mask = get_mask(

src/openlifu/sim/kwave_if.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def run_simulation(arr: xdc.Transducer,
156156
if _source is not None:
157157
source = _source
158158
else:
159-
source_mat = arr.calc_output(input_signal, kgrid.dt, delays, apod)
159+
source_mat = arr.calc_output(input_signal, cycles=cycles, frequency=freq, dt=kgrid.dt, delays=delays, apod=apod)
160160
if arr.crosstalk_frac != 0:
161161
# Simulate crosstalk by adding additional elements to the array for each element that
162162
# is within the crosstalk distance, with the signal scaled by the crosstalk fraction.

src/openlifu/xdc/element.py

Lines changed: 52 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,33 @@
1010
from openlifu.util.units import getunitconversion
1111

1212

13+
def sensitivity_at_frequency(sensitivity: float | dict[float, float], frequency: float) -> float:
14+
if isinstance(sensitivity, dict):
15+
freqs = np.array(list(sensitivity.keys()), dtype=np.float64)
16+
values = np.array(list(sensitivity.values()), dtype=np.float64)
17+
return float(np.interp(frequency, freqs, values, left=values[0], right=values[-1]))
18+
return float(sensitivity)
19+
20+
21+
def generate_drive_signal(input_signal, cycles: float, frequency: float, dt: float) -> np.ndarray:
22+
"""Generate a drive signal with duration constrained by cycles/frequency."""
23+
if dt <= 0:
24+
raise ValueError("dt must be positive.")
25+
if frequency <= 0:
26+
raise ValueError("frequency must be positive.")
27+
if cycles <= 0:
28+
raise ValueError("cycles must be positive.")
29+
n_samples = max(1, int(np.round(cycles / (frequency * dt))))
30+
if np.isscalar(input_signal):
31+
t = np.arange(n_samples, dtype=np.float64) * dt
32+
return float(input_signal) * np.sin(2 * np.pi * frequency * t)
33+
base = np.asarray(input_signal, dtype=np.float64).reshape(-1)
34+
drive_signal = np.zeros(n_samples, dtype=np.float64)
35+
n_copy = min(n_samples, len(base))
36+
drive_signal[:n_copy] = base[:n_copy]
37+
return drive_signal
38+
39+
1340
def matrix2xyz(matrix):
1441
x = matrix[0, 3]
1542
y = matrix[1, 3]
@@ -43,15 +70,9 @@ class Element:
4370
size: Annotated[np.ndarray, OpenLIFUFieldData("Size", "Size of the element in 2D")] = field(default_factory=lambda: np.array([1., 1.]))
4471
""" Size of the element in 2D as a numpy array [width, length]."""
4572

46-
sensitivity: Annotated[float | None, OpenLIFUFieldData("Sensitivity", "Sensitivity of the element (Pa/V)")] = None
73+
sensitivity: Annotated[float | dict[float, float], OpenLIFUFieldData("Sensitivity", "Sensitivity of the element (Pa/V), scalar or {frequency_hz: sensitivity}")] = 1.0
4774
"""Sensitivity of the element (Pa/V)"""
4875

49-
impulse_response: Annotated[np.ndarray | None, OpenLIFUFieldData("Impulse response", "Impulse response of the element")] = None
50-
"""Impulse response of the element, can be a single value or an array of values. If an array, `impulse_dt` must be set to the time step of the impulse response. Is convolved with the input signal."""
51-
52-
impulse_dt: Annotated[float | None, OpenLIFUFieldData("Impulse response timestep", """Impulse response timestep""")] = None
53-
"""Impulse response timestep. If `impulse_response` is an array, this is the time step of the impulse response."""
54-
5576
pin: Annotated[int, OpenLIFUFieldData("Pin", "Channel pin to which the element is connected")] = -1
5677
"""Channel pin to which the element is connected. 1-(64*number of modules)."""
5778

@@ -68,14 +89,18 @@ def __post_init__(self):
6889
self.size = np.array(self.size, dtype=np.float64)
6990
if self.size.shape != (2,):
7091
raise ValueError("Size must be a 2-element array.")
71-
if self.impulse_response is not None:
72-
if isinstance(self.impulse_response, int | float):
73-
self.impulse_response = np.array([self.impulse_response])
74-
self.impulse_response = np.array(self.impulse_response, dtype=np.float64)
75-
if self.impulse_response.ndim != 1:
76-
raise ValueError("Impulse response must be a 1-dimensional array.")
77-
if len(self.impulse_response)>1 and self.impulse_dt is None:
78-
raise ValueError("Impulse response timestep must be set if impulse response is an array.")
92+
if self.sensitivity is None:
93+
self.sensitivity = 1.0
94+
elif isinstance(self.sensitivity, dict):
95+
if len(self.sensitivity) == 0:
96+
raise ValueError("Sensitivity dictionary must not be empty.")
97+
mapping = {float(k): float(v) for k, v in self.sensitivity.items()}
98+
freqs = np.array(sorted(mapping.keys()), dtype=np.float64)
99+
if np.any(np.diff(freqs) <= 0):
100+
raise ValueError("Sensitivity dictionary frequencies must be strictly increasing.")
101+
self.sensitivity = {float(f): mapping[float(f)] for f in freqs}
102+
else:
103+
self.sensitivity = float(self.sensitivity)
79104

80105
@property
81106
def x(self):
@@ -141,17 +166,12 @@ def length(self):
141166
def length(self, value):
142167
self.size[1] = value
143168

144-
def calc_output(self, input_signal, dt):
145-
if self.impulse_response is None:
146-
filtered_signal = input_signal * 1
147-
elif len(self.impulse_response) == 1:
148-
filtered_signal = input_signal * self.impulse_response[0]
149-
else:
150-
impulse = self.interp_impulse_response(dt)
151-
filtered_signal = np.convolve(input_signal, impulse, mode='full')
152-
if self.sensitivity is not None:
153-
filtered_signal = filtered_signal * self.sensitivity
154-
return filtered_signal
169+
def get_sensitivity(self, frequency: float) -> float:
170+
return sensitivity_at_frequency(self.sensitivity, frequency)
171+
172+
def calc_output(self, input_signal, cycles: float, frequency: float, dt: float):
173+
drive_signal = generate_drive_signal(input_signal, cycles=cycles, frequency=frequency, dt=dt)
174+
return drive_signal * self.get_sensitivity(frequency)
155175

156176
def copy(self):
157177
return copy.deepcopy(self)
@@ -225,17 +245,6 @@ def get_angle(self, units="rad"):
225245
roll = np.degrees(self.roll)
226246
return el, az, roll
227247

228-
def interp_impulse_response(self, dt=None):
229-
if dt is None:
230-
dt = self.impulse_dt
231-
n0 = len(self.impulse_response)
232-
t0 = self.impulse_dt * np.arange(n0)
233-
t1 = np.arange(0, t0[-1] + dt, dt)
234-
impulse_response = np.interp(t1, t0, self.impulse_response)
235-
impulse_t = np.arange(len(impulse_response)) * dt
236-
impulse_t = impulse_t - np.mean(impulse_t)
237-
return impulse_response, impulse_t
238-
239248
def distance_to_point(self, point, units=None, matrix=np.eye(4)):
240249
units = self.units if units is None else units
241250
pos = np.concatenate([self.get_position(units=units), [1]])
@@ -273,10 +282,7 @@ def to_dict(self):
273282
"size": self.size.tolist(),
274283
"pin": self.pin,
275284
"units": self.units}
276-
if self.impulse_response is not None:
277-
d["impulse_response"] = self.impulse_response.tolist()
278-
if self.impulse_dt is not None:
279-
d["impulse_dt"] = self.impulse_dt
285+
d["sensitivity"] = self.sensitivity
280286
return d
281287

282288
@staticmethod
@@ -286,8 +292,9 @@ def from_dict(d):
286292
d["position"] = np.array([d.pop('x'), d.pop('y'), d.pop('z')])
287293
d["orientation"] = np.array([d.pop('az'), d.pop('el'), d.pop('roll')])
288294
d["size"] = np.array([d.pop('w'), d.pop('l')])
289-
if "impulse_response" in d and d["impulse_response"] is not None:
290-
d["impulse_response"] = np.array(d["impulse_response"])
291-
if "impulse_dt" in d and d["impulse_dt"] is not None:
292-
d["impulse_dt"] = float(d["impulse_dt"])
295+
# Backward compatibility: legacy impulse keys are ignored.
296+
d.pop("impulse_response", None)
297+
d.pop("impulse_dt", None)
298+
if "sensitivity" not in d or d["sensitivity"] is None:
299+
d["sensitivity"] = 1.0
293300
return Element(**d)

0 commit comments

Comments
 (0)