1010from 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+
1340def 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