pyglaze 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.
- pyglaze/__init__.py +1 -0
- pyglaze/datamodels/__init__.py +4 -0
- pyglaze/datamodels/pulse.py +551 -0
- pyglaze/datamodels/waveform.py +165 -0
- pyglaze/device/__init__.py +15 -0
- pyglaze/device/_delayunit_data/carmen-nonuniform-2023-10-20.pickle +0 -0
- pyglaze/device/_delayunit_data/g1-linearized-2023-04-04.pickle +0 -0
- pyglaze/device/_delayunit_data/g2-linearized-2023-04-04.pickle +0 -0
- pyglaze/device/_delayunit_data/g2-nonuniform-2023-04-04.pickle +0 -0
- pyglaze/device/_delayunit_data/mock_delay.pickle +0 -0
- pyglaze/device/ampcom.py +447 -0
- pyglaze/device/configuration.py +266 -0
- pyglaze/device/delayunit.py +151 -0
- pyglaze/device/identifiers.py +41 -0
- pyglaze/devtools/__init__.py +3 -0
- pyglaze/devtools/mock_device.py +367 -0
- pyglaze/devtools/thz_pulse.py +35 -0
- pyglaze/helpers/__init__.py +0 -0
- pyglaze/helpers/types.py +20 -0
- pyglaze/helpers/utilities.py +80 -0
- pyglaze/interpolation/__init__.py +3 -0
- pyglaze/interpolation/interpolation.py +24 -0
- pyglaze/py.typed +0 -0
- pyglaze/scanning/__init__.py +4 -0
- pyglaze/scanning/_asyncscanner.py +146 -0
- pyglaze/scanning/client.py +59 -0
- pyglaze/scanning/scanner.py +256 -0
- pyglaze-0.1.0.dist-info/LICENSE +28 -0
- pyglaze-0.1.0.dist-info/METADATA +82 -0
- pyglaze-0.1.0.dist-info/RECORD +32 -0
- pyglaze-0.1.0.dist-info/WHEEL +5 -0
- pyglaze-0.1.0.dist-info/top_level.txt +1 -0
pyglaze/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "4.2.0"
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from functools import cached_property
|
|
5
|
+
from typing import Literal, cast
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from scipy import signal
|
|
9
|
+
|
|
10
|
+
from pyglaze.helpers.types import ComplexArray, FloatArray
|
|
11
|
+
from pyglaze.interpolation import ws_interpolate
|
|
12
|
+
|
|
13
|
+
__all__ = ["Pulse"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Pulse:
|
|
18
|
+
"""Data class for a THz pulse. The pulse is expected to be preprocessed such that times are uniformly spaced.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
time: The time values recorded by the lock-in amp during the scan.
|
|
22
|
+
signal: The signal values recorded by the lock-in amp during the scan.
|
|
23
|
+
signal_err: Potential errors on signal
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
time: FloatArray
|
|
27
|
+
signal: FloatArray
|
|
28
|
+
signal_err: FloatArray | None = None
|
|
29
|
+
|
|
30
|
+
def __len__(self: Pulse) -> int: # noqa: D105
|
|
31
|
+
return len(self.time)
|
|
32
|
+
|
|
33
|
+
def __eq__(self: Pulse, obj: object) -> bool:
|
|
34
|
+
"""Check if two pulses are equal."""
|
|
35
|
+
if not isinstance(obj, Pulse):
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
# Check if shapes are equal before using np.all (will throw error on different shapes)
|
|
39
|
+
if obj.time.shape != self.time.shape:
|
|
40
|
+
return False
|
|
41
|
+
if obj.signal.shape != self.signal.shape:
|
|
42
|
+
return False
|
|
43
|
+
if not isinstance(obj.signal_err, type(self.signal_err)):
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
return bool(
|
|
47
|
+
np.all(obj.time == self.time)
|
|
48
|
+
and np.all(obj.signal == self.signal)
|
|
49
|
+
and np.all(obj.signal_err == self.signal_err)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
@cached_property
|
|
53
|
+
def fft(self: Pulse) -> ComplexArray:
|
|
54
|
+
"""Return the Fourier Transform of a signal."""
|
|
55
|
+
return np.fft.rfft(self.signal, norm="forward")
|
|
56
|
+
|
|
57
|
+
@cached_property
|
|
58
|
+
def frequency(self: Pulse) -> FloatArray:
|
|
59
|
+
"""Return the Fourier Transform sample frequencies."""
|
|
60
|
+
return np.fft.rfftfreq(len(self.signal), d=self.time[1] - self.time[0])
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def time_window(self: Pulse) -> float:
|
|
64
|
+
"""The scan time window size in seconds."""
|
|
65
|
+
return float(self.time[-1] - self.time[0])
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def sampling_freq(self: Pulse) -> float:
|
|
69
|
+
"""The sampling frequency in Hz of the scan."""
|
|
70
|
+
return float(1 / (self.time[1] - self.time[0]))
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def dt(self: Pulse) -> float:
|
|
74
|
+
"""Time spacing."""
|
|
75
|
+
return float(self.time[1] - self.time[0])
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def df(self: Pulse) -> float:
|
|
79
|
+
"""Frequency spacing."""
|
|
80
|
+
return float(self.frequency[1] - self.frequency[0])
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def center_frequency(self: Pulse) -> float:
|
|
84
|
+
"""The frequency of the pulse with the highest spectral desnity."""
|
|
85
|
+
return float(self.frequency[np.argmax(np.abs(self.fft))])
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def maximum_spectral_density(self: Pulse) -> float:
|
|
89
|
+
"""The maximum spectral density of the pulse."""
|
|
90
|
+
return float(np.max(np.abs(self.fft)))
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def delay_at_max(self: Pulse) -> float:
|
|
94
|
+
"""Time delay at the maximum value of the pulse."""
|
|
95
|
+
return float(self.time[np.argmax(self.signal)])
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def delay_at_min(self: Pulse) -> float:
|
|
99
|
+
"""Time delay at the minimum value of the pulse."""
|
|
100
|
+
return float(self.time[np.argmin(self.signal)])
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def from_dict(
|
|
104
|
+
cls: type[Pulse], d: dict[str, FloatArray | list[float] | None]
|
|
105
|
+
) -> Pulse:
|
|
106
|
+
"""Create a Pulse object from a dictionary.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
d: A dictionary containing the keys 'time', 'signal' and potentially 'signal_err'.
|
|
110
|
+
"""
|
|
111
|
+
err = (
|
|
112
|
+
np.array(d["signal_err"]) if type(d.get("signal_err")) is not None else None
|
|
113
|
+
)
|
|
114
|
+
return Pulse(
|
|
115
|
+
time=np.array(d["time"]), signal=np.array(d["signal"]), signal_err=err
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@classmethod
|
|
119
|
+
def from_fft(cls: type[Pulse], time: FloatArray, fft: ComplexArray) -> Pulse:
|
|
120
|
+
"""Creates a Pulse object from an array of times and a Fourier spectrum.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
time: Time series of pulse related to the Fourier spectrum
|
|
124
|
+
fft: Fourier spectrum of pulse
|
|
125
|
+
|
|
126
|
+
"""
|
|
127
|
+
sig = np.fft.irfft(fft, norm="forward", n=len(time), axis=0)
|
|
128
|
+
return cls(time, sig)
|
|
129
|
+
|
|
130
|
+
@classmethod
|
|
131
|
+
def average(cls: type[Pulse], scans: list[Pulse]) -> Pulse:
|
|
132
|
+
"""Creates a Pulse object containing the average scan from a list of scans along with uncertainties. Errors are calculated as the standard errors on the means.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
scans: List of scans to calculate average from
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
if len(scans) == 1:
|
|
139
|
+
return scans[0]
|
|
140
|
+
signals = np.array([scan.signal for scan in scans])
|
|
141
|
+
mean_signal = np.mean(signals, axis=0)
|
|
142
|
+
|
|
143
|
+
root_n_scans = np.sqrt(len(scans))
|
|
144
|
+
std_signal = np.std(signals, axis=0, ddof=1) / root_n_scans
|
|
145
|
+
return Pulse(scans[0].time, mean_signal, signal_err=std_signal)
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def align(
|
|
149
|
+
cls: type[Pulse],
|
|
150
|
+
scans: list[Pulse],
|
|
151
|
+
*,
|
|
152
|
+
wrt_max: bool = True,
|
|
153
|
+
translate_to_zero: bool = True,
|
|
154
|
+
) -> list[Pulse]:
|
|
155
|
+
"""Aligns a list of scan with respect to their individual maxima or minima.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
scans: List of scans
|
|
159
|
+
wrt_max: Whether to align with respect to maximum. Defaults to True.
|
|
160
|
+
translate_to_zero: Whether to translate all scans to t[0] = 0. Defaults to True.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
list[Pulse]: Aligned scans.
|
|
164
|
+
"""
|
|
165
|
+
extrema = [scan._get_min_or_max_idx(wrt_max=wrt_max) for scan in scans] # noqa: SLF001
|
|
166
|
+
n_before = min(extrema)
|
|
167
|
+
n_after = min(len(scan) - index - 1 for scan, index in zip(scans, extrema))
|
|
168
|
+
roughly_aligned = [
|
|
169
|
+
cls._from_slice(scan, slice(index - n_before, index + n_after))
|
|
170
|
+
for index, scan in zip(extrema, scans)
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
if translate_to_zero:
|
|
174
|
+
for scan in roughly_aligned:
|
|
175
|
+
scan.time = scan.time - scan.time[0]
|
|
176
|
+
|
|
177
|
+
ref = roughly_aligned[len(roughly_aligned) // 2]
|
|
178
|
+
extremum = extrema[len(roughly_aligned) // 2]
|
|
179
|
+
return _match_templates(extremum, ref, roughly_aligned)
|
|
180
|
+
|
|
181
|
+
@classmethod
|
|
182
|
+
def _from_slice(cls: type[Pulse], scan: Pulse, indices: slice) -> Pulse:
|
|
183
|
+
err = scan.signal_err[indices] if scan.signal_err is not None else None
|
|
184
|
+
return cls(scan.time[indices], scan.signal[indices], err)
|
|
185
|
+
|
|
186
|
+
def cut(self: Pulse, from_time: float, to_time: float) -> Pulse:
|
|
187
|
+
"""Create a Pulse object by cutting out a specific section of the scan.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
from_time: Time in seconds where cut should be made from
|
|
191
|
+
to_time: Time in seconds where cut should be made to
|
|
192
|
+
"""
|
|
193
|
+
from_idx = int(np.searchsorted(self.time, from_time))
|
|
194
|
+
to_idx = int(np.searchsorted(self.time, to_time, side="right"))
|
|
195
|
+
return Pulse(
|
|
196
|
+
self.time[from_idx:to_idx],
|
|
197
|
+
self.signal[from_idx:to_idx],
|
|
198
|
+
None if self.signal_err is None else self.signal_err[from_idx:to_idx],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def timeshift(self: Pulse, scale: float, offset: float = 0) -> Pulse:
|
|
202
|
+
"""Rescales and offsets the time axis as.
|
|
203
|
+
|
|
204
|
+
new_times = scale*(t + offset)
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
scale: Rescaling factor
|
|
208
|
+
offset: Offset. Defaults to 0.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Timeshifted pulse
|
|
212
|
+
"""
|
|
213
|
+
return Pulse(
|
|
214
|
+
time=scale * (self.time + offset),
|
|
215
|
+
signal=self.signal,
|
|
216
|
+
signal_err=self.signal_err,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def add_white_noise(
|
|
220
|
+
self: Pulse, noise_std: float, seed: int | None = None
|
|
221
|
+
) -> Pulse:
|
|
222
|
+
"""Adds Gaussian noise to each timedomain measurements with a standard deviation given by `noise_std`.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
noise_std: noise standard deviation
|
|
226
|
+
seed: Seed for the random number generator. If none, a random seed is used.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
Pulse with noise
|
|
230
|
+
"""
|
|
231
|
+
return Pulse(
|
|
232
|
+
time=self.time,
|
|
233
|
+
signal=self.signal
|
|
234
|
+
+ np.random.default_rng(seed).normal(
|
|
235
|
+
loc=0, scale=noise_std, size=len(self)
|
|
236
|
+
),
|
|
237
|
+
signal_err=np.ones(len(self)) * noise_std,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def zeropadded(self: Pulse, n_zeros: int) -> Pulse:
|
|
241
|
+
"""Returns a new, zero-padded pulse.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
n_zeros: number of zeros to add
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Zero-padded pulse
|
|
248
|
+
"""
|
|
249
|
+
zeropadded_signal = np.concatenate((np.zeros(n_zeros), self.signal))
|
|
250
|
+
zeropadded_time = np.concatenate(
|
|
251
|
+
(self.time[0] + np.arange(n_zeros, 0, -1) * -self.dt, self.time)
|
|
252
|
+
)
|
|
253
|
+
return Pulse(time=zeropadded_time, signal=zeropadded_signal)
|
|
254
|
+
|
|
255
|
+
def tukey(
|
|
256
|
+
self: Pulse,
|
|
257
|
+
taper_length: float,
|
|
258
|
+
from_time: float | None = None,
|
|
259
|
+
to_time: float | None = None,
|
|
260
|
+
) -> Pulse:
|
|
261
|
+
"""Applies a Tukey window and returns a new Pulse object - see https://en.wikipedia.org/wiki/Window_function.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
taper_length: Length in seconds of the cosine tapering length, i.e. half a cosine cycle
|
|
265
|
+
from_time: Left edge in seconds at which the window becomes 0
|
|
266
|
+
to_time: Right edge in seconds at which the window becomes 0
|
|
267
|
+
"""
|
|
268
|
+
N = len(self)
|
|
269
|
+
_to_time = to_time or self.time[-1]
|
|
270
|
+
_from_time = from_time or self.time[0]
|
|
271
|
+
_tukey_window_length = _to_time - _from_time
|
|
272
|
+
|
|
273
|
+
# NOTE: See https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.windows.tukey.html#scipy.signal.windows.tukey
|
|
274
|
+
M = int(N * _tukey_window_length / self.time_window)
|
|
275
|
+
if M > N:
|
|
276
|
+
msg = "Number of points in Tukey window cannot exceed number of points in scan"
|
|
277
|
+
raise ValueError(msg)
|
|
278
|
+
alpha = 2 * taper_length / _tukey_window_length
|
|
279
|
+
_tukey_window = signal.windows.tukey(M=M, alpha=alpha)
|
|
280
|
+
|
|
281
|
+
window = np.zeros(N)
|
|
282
|
+
from_time_idx = np.searchsorted(self.time, _from_time)
|
|
283
|
+
window[from_time_idx : M + from_time_idx] = _tukey_window
|
|
284
|
+
|
|
285
|
+
return Pulse(self.time, self.signal * window)
|
|
286
|
+
|
|
287
|
+
def derivative(self: Pulse) -> Pulse:
|
|
288
|
+
"""Calculates the derivative of the pulse.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
Pulse: New Pulse object containing the derivative
|
|
292
|
+
"""
|
|
293
|
+
return Pulse(time=self.time, signal=np.gradient(self.signal))
|
|
294
|
+
|
|
295
|
+
def downsample(self: Pulse, max_frequency: float) -> Pulse:
|
|
296
|
+
"""Downsamples the pulse by inverse Fourier transforming the spectrum cut at the supplied `max_frequency`.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
max_frequency: Maximum frequency bin after downsampling
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
Pulse: Downsampled pulse
|
|
303
|
+
"""
|
|
304
|
+
idx = np.searchsorted(self.frequency, max_frequency)
|
|
305
|
+
new_fft = self.fft[:idx]
|
|
306
|
+
new_dt = 1 / (2 * self.frequency[:idx][-1])
|
|
307
|
+
new_times = np.arange(2 * (len(new_fft) - 1)) * new_dt + self.time[0]
|
|
308
|
+
return Pulse.from_fft(time=new_times, fft=new_fft)
|
|
309
|
+
|
|
310
|
+
def filter(
|
|
311
|
+
self: Pulse,
|
|
312
|
+
filtertype: Literal["highpass", "lowpass"],
|
|
313
|
+
cutoff: float,
|
|
314
|
+
order: int,
|
|
315
|
+
) -> Pulse:
|
|
316
|
+
"""Applies a highpass filter to the signal.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
filtertype: Type of filter
|
|
320
|
+
cutoff: Frequency, where the filter response has dropped 3 dB
|
|
321
|
+
order: Order of the highpass filter
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Highpassed pulse
|
|
325
|
+
"""
|
|
326
|
+
sos = signal.butter(
|
|
327
|
+
N=order, Wn=cutoff, btype=filtertype, fs=self.sampling_freq, output="sos"
|
|
328
|
+
)
|
|
329
|
+
return Pulse(self.time, np.asarray(signal.sosfilt(sos, self.signal)))
|
|
330
|
+
|
|
331
|
+
def spectrum_dB(
|
|
332
|
+
self: Pulse, reference: float | None = None, offset_ratio: float | None = None
|
|
333
|
+
) -> FloatArray:
|
|
334
|
+
"""Calculates the spectral density in decibel.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
reference: Reference spectral amplitude. If none, the maximum of the FFT is used.
|
|
338
|
+
offset_ratio: Offset in decibel relative to the maximum of the FFT to avoid taking the logarithm of 0. If none, no offset is applied.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
FloatArray: Spectral density in decibel
|
|
342
|
+
"""
|
|
343
|
+
abs_spectrum = np.abs(self.fft)
|
|
344
|
+
offset = 0 if offset_ratio is None else offset_ratio * np.max(abs_spectrum)
|
|
345
|
+
ref = reference or np.max(abs_spectrum)
|
|
346
|
+
|
|
347
|
+
return np.asarray(
|
|
348
|
+
20 * np.log10((abs_spectrum + offset) / ref), dtype=np.float64
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def estimate_bandwidth(self: Pulse, omega_power: int = 3) -> float:
|
|
352
|
+
"""Estimates the bandwidth of the pulse.
|
|
353
|
+
|
|
354
|
+
Uses the approach described in [Algorithm for Determination of Cutoff Frequency of Noise Floor Level for Terahertz Time-Domain Signals](https://doi.org/10.1007/s10762-022-00886-y).
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
omega_power: power to raise omega to before estimating the bandwidth. Defaults to 3
|
|
358
|
+
|
|
359
|
+
Returns:
|
|
360
|
+
float: Estimated bandwidth in Hz
|
|
361
|
+
"""
|
|
362
|
+
return self._estimate_pulse_properties(omega_power)[0]
|
|
363
|
+
|
|
364
|
+
def estimate_dynamic_range(self: Pulse, omega_power: int = 3) -> float:
|
|
365
|
+
"""Estimates the dynamic range of the pulse.
|
|
366
|
+
|
|
367
|
+
Uses the approach described in [Algorithm for Determination of Cutoff Frequency of Noise Floor Level for Terahertz Time-Domain Signals](https://doi.org/10.1007/s10762-022-00886-y).
|
|
368
|
+
|
|
369
|
+
Args:
|
|
370
|
+
omega_power: power to raise omega to before estimating the dynamic range. Defaults to 3
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
float: Estimated dynamic range in dB
|
|
374
|
+
"""
|
|
375
|
+
return self._estimate_pulse_properties(omega_power)[1]
|
|
376
|
+
|
|
377
|
+
def estimate_avg_noise_power(self: Pulse, omega_power: int = 3) -> float:
|
|
378
|
+
"""Estimates the noise power.
|
|
379
|
+
|
|
380
|
+
Noise power is defined as the mean of the absolute square of the noise floor.
|
|
381
|
+
Uses the approach described in [Algorithm for Determination of Cutoff Frequency of Noise Floor Level for Terahertz Time-Domain Signals](https://doi.org/10.1007/s10762-022-00886-y).
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
omega_power: power to raise omega to before estimating the noisepower. Defaults to 3
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
float: Estimated noise power.
|
|
388
|
+
"""
|
|
389
|
+
return self._estimate_pulse_properties(omega_power)[2]
|
|
390
|
+
|
|
391
|
+
def estimate_SNR(self: Pulse, omega_power: int = 3) -> FloatArray:
|
|
392
|
+
"""Estimates the signal-to-noise ratio.
|
|
393
|
+
|
|
394
|
+
Estimates the SNR, assuming white noise. Uses the approach described in [Algorithm for Determination of Cutoff Frequency of Noise Floor Level for Terahertz Time-Domain Signals](https://doi.org/10.1007/s10762-022-00886-y) to estimate the noise power. The signal power is then extrapolated above the bandwidth by fitting a second order polynomial to the spectrum above the noisefloor.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
omega_power: power to raise omega to before estimating the signal-to-noise ratio. Defaults to 3
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
float: Estimated signal-to-noise ratio.
|
|
401
|
+
"""
|
|
402
|
+
# Get spectrum between maximum and noisefloor
|
|
403
|
+
_from = np.argmax(self.spectrum_dB())
|
|
404
|
+
_to = np.searchsorted(
|
|
405
|
+
self.frequency, self.estimate_bandwidth(omega_power=omega_power)
|
|
406
|
+
)
|
|
407
|
+
x = self.frequency[_from:_to]
|
|
408
|
+
y = self.spectrum_dB()[_from:_to]
|
|
409
|
+
|
|
410
|
+
# Fit a second order polynomial to the spectrum above the noisefloor
|
|
411
|
+
poly_fit = np.polynomial.Polynomial.fit(x, y, deg=2)
|
|
412
|
+
|
|
413
|
+
# Combine signal before spectrum maximum with interpolated values
|
|
414
|
+
y_values = cast(
|
|
415
|
+
FloatArray,
|
|
416
|
+
np.concatenate(
|
|
417
|
+
[
|
|
418
|
+
self.spectrum_dB()[:_from],
|
|
419
|
+
poly_fit(self.frequency[_from:]),
|
|
420
|
+
]
|
|
421
|
+
),
|
|
422
|
+
)
|
|
423
|
+
signal_power = 10 ** (y_values / 10) * self.maximum_spectral_density**2
|
|
424
|
+
return signal_power / self.estimate_avg_noise_power(omega_power=omega_power)
|
|
425
|
+
|
|
426
|
+
def estimate_peak_to_peak(
|
|
427
|
+
self: Pulse, delay_tolerance: float | None = None
|
|
428
|
+
) -> float:
|
|
429
|
+
"""Estimates the peak-to-peak value of the pulse.
|
|
430
|
+
|
|
431
|
+
If a delay tolerance is provided, the peak-to-peak value is estimated by interpolating the pulse at the maximum and minimum values such that the minimum and maximum values of the pulse fall within the given delay tolerance. A lower tolerance will give a more accurate estimate.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
delay_tolerance: Tolerance for peak detection. Defaults to None.
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
float: Estimated peak-to-peak value.
|
|
438
|
+
"""
|
|
439
|
+
if delay_tolerance is None:
|
|
440
|
+
return float(np.max(self.signal) - np.min(self.signal))
|
|
441
|
+
|
|
442
|
+
if delay_tolerance >= self.dt:
|
|
443
|
+
msg = "Tolerance must be smaller than the time spacing of the pulse."
|
|
444
|
+
raise ValueError(msg)
|
|
445
|
+
|
|
446
|
+
max_estimate = ws_interpolate(
|
|
447
|
+
times=self.time,
|
|
448
|
+
pulse=self.signal,
|
|
449
|
+
interp_times=np.linspace(
|
|
450
|
+
self.delay_at_max - self.dt,
|
|
451
|
+
self.delay_at_max + self.dt,
|
|
452
|
+
num=1 + int(self.dt / delay_tolerance),
|
|
453
|
+
endpoint=True,
|
|
454
|
+
),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
min_estimate = ws_interpolate(
|
|
458
|
+
times=self.time,
|
|
459
|
+
pulse=self.signal,
|
|
460
|
+
interp_times=np.linspace(
|
|
461
|
+
self.delay_at_min - self.dt,
|
|
462
|
+
self.delay_at_min + self.dt,
|
|
463
|
+
num=1 + int(self.dt / delay_tolerance),
|
|
464
|
+
endpoint=True,
|
|
465
|
+
),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
return cast(float, np.max(max_estimate) - np.min(min_estimate))
|
|
469
|
+
|
|
470
|
+
def to_native_dict(self: Pulse) -> dict[str, list[float] | None]:
|
|
471
|
+
"""Converts the Pulse object to a native dictionary.
|
|
472
|
+
|
|
473
|
+
Returns:
|
|
474
|
+
Native dictionary representation of the Pulse object.
|
|
475
|
+
"""
|
|
476
|
+
return {
|
|
477
|
+
"time": list(self.time),
|
|
478
|
+
"signal": list(self.signal),
|
|
479
|
+
"signal_err": None if self.signal_err is None else list(self.signal_err),
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
def _get_min_or_max_idx(self: Pulse, *, wrt_max: bool) -> int:
|
|
483
|
+
return int(np.argmax(self.signal)) if wrt_max else int(np.argmin(self.signal))
|
|
484
|
+
|
|
485
|
+
def _estimate_pulse_properties(
|
|
486
|
+
self: Pulse, omega_power: int
|
|
487
|
+
) -> tuple[float, float, float]:
|
|
488
|
+
argmax = np.argmax(np.abs(self.fft))
|
|
489
|
+
freqs = self.frequency[argmax:]
|
|
490
|
+
abs_spectrum = np.abs(self.fft[argmax:])
|
|
491
|
+
|
|
492
|
+
noisefloor_idx_estimate = np.argmin(abs_spectrum * freqs**omega_power)
|
|
493
|
+
avg_noise_power = np.mean(abs_spectrum[noisefloor_idx_estimate:] ** 2)
|
|
494
|
+
noisefloor = np.sqrt(avg_noise_power)
|
|
495
|
+
|
|
496
|
+
# Search for the first index, where the spectrum is above the noise floor
|
|
497
|
+
# by flipping the spectrum to get a pseudo-increasing array, then convert back
|
|
498
|
+
# to an index in the original array
|
|
499
|
+
cutoff_idx = noisefloor_idx_estimate - np.searchsorted(
|
|
500
|
+
np.flip(abs_spectrum[: noisefloor_idx_estimate + 1]),
|
|
501
|
+
noisefloor,
|
|
502
|
+
side="right",
|
|
503
|
+
)
|
|
504
|
+
bandwidth = freqs[cutoff_idx]
|
|
505
|
+
dynamic_range_dB = 20 * np.log10(self.maximum_spectral_density / noisefloor)
|
|
506
|
+
return bandwidth, dynamic_range_dB, avg_noise_power
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _match_templates(
|
|
510
|
+
extremum: int, ref: Pulse, roughly_aligned: list[Pulse]
|
|
511
|
+
) -> list[Pulse]:
|
|
512
|
+
# corresponds to a template of length 8 - chosen as a compromise between speed and accuracy
|
|
513
|
+
window_size = 4
|
|
514
|
+
ref_slice = slice(extremum - window_size, extremum + window_size)
|
|
515
|
+
|
|
516
|
+
def correlate(x1: FloatArray, x2: FloatArray) -> float:
|
|
517
|
+
return float(np.sum(x1 * x2 / (np.linalg.norm(x1) * np.linalg.norm(x2))))
|
|
518
|
+
|
|
519
|
+
cut_candidates = [-2, -1, 0, 1, 2]
|
|
520
|
+
cuts = np.empty(len(roughly_aligned), dtype=int)
|
|
521
|
+
for i_scan, scan in enumerate(roughly_aligned):
|
|
522
|
+
slices = [
|
|
523
|
+
slice(extremum - window_size + i, extremum + window_size + i)
|
|
524
|
+
for i in cut_candidates
|
|
525
|
+
]
|
|
526
|
+
cuts[i_scan] = cut_candidates[
|
|
527
|
+
np.argmax(
|
|
528
|
+
[correlate(scan.signal[s], ref.signal[ref_slice]) for s in slices]
|
|
529
|
+
)
|
|
530
|
+
]
|
|
531
|
+
|
|
532
|
+
max_cut = np.max(np.abs(cuts))
|
|
533
|
+
new_length = len(ref) - max_cut
|
|
534
|
+
aligned = []
|
|
535
|
+
for scan, cut in zip(roughly_aligned, cuts):
|
|
536
|
+
if cut >= 0:
|
|
537
|
+
aligned.append(
|
|
538
|
+
Pulse(
|
|
539
|
+
time=ref.time[:new_length],
|
|
540
|
+
signal=scan.signal[cut : cut + new_length],
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
else:
|
|
544
|
+
aligned.append(
|
|
545
|
+
Pulse(
|
|
546
|
+
time=ref.time[:new_length],
|
|
547
|
+
signal=scan.signal[cut - new_length : cut],
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
return aligned
|