pyglaze 0.2.2__tar.gz → 0.4.0__tar.gz
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-0.2.2/src/pyglaze.egg-info → pyglaze-0.4.0}/PKG-INFO +3 -2
- {pyglaze-0.2.2 → pyglaze-0.4.0}/pyproject.toml +4 -4
- pyglaze-0.4.0/src/pyglaze/__init__.py +1 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/datamodels/pulse.py +178 -127
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/datamodels/waveform.py +1 -1
- pyglaze-0.4.0/src/pyglaze/device/__init__.py +6 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/device/ampcom.py +25 -199
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/device/configuration.py +7 -99
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/devtools/mock_device.py +15 -150
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/devtools/thz_pulse.py +4 -3
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/helpers/utilities.py +6 -6
- pyglaze-0.4.0/src/pyglaze/interpolation/__init__.py +3 -0
- pyglaze-0.4.0/src/pyglaze/interpolation/interpolation.py +44 -0
- pyglaze-0.4.0/src/pyglaze/py.typed +0 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/scanning/_asyncscanner.py +32 -5
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/scanning/client.py +17 -1
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/scanning/scanner.py +51 -92
- {pyglaze-0.2.2 → pyglaze-0.4.0/src/pyglaze.egg-info}/PKG-INFO +3 -2
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze.egg-info/SOURCES.txt +1 -1
- pyglaze-0.2.2/MANIFEST.in +0 -1
- pyglaze-0.2.2/src/pyglaze/__init__.py +0 -1
- pyglaze-0.2.2/src/pyglaze/device/__init__.py +0 -7
- pyglaze-0.2.2/src/pyglaze/interpolation/__init__.py +0 -3
- pyglaze-0.2.2/src/pyglaze/interpolation/interpolation.py +0 -24
- {pyglaze-0.2.2 → pyglaze-0.4.0}/LICENSE +0 -0
- /pyglaze-0.2.2/src/pyglaze/helpers/__init__.py → /pyglaze-0.4.0/MANIFEST.in +0 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/README.md +0 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/setup.cfg +0 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/datamodels/__init__.py +0 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/devtools/__init__.py +0 -0
- /pyglaze-0.2.2/src/pyglaze/py.typed → /pyglaze-0.4.0/src/pyglaze/helpers/__init__.py +0 -0
- /pyglaze-0.2.2/src/pyglaze/helpers/types.py → /pyglaze-0.4.0/src/pyglaze/helpers/_types.py +0 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/scanning/__init__.py +0 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/scanning/_exceptions.py +0 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze.egg-info/dependency_links.txt +0 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze.egg-info/requires.txt +0 -0
- {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pyglaze
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Pyglaze is a library used to operate the devices of Glaze Technologies
|
|
5
5
|
Author: GLAZE Technologies ApS
|
|
6
6
|
License: BSD 3-Clause License
|
|
@@ -43,6 +43,7 @@ Requires-Dist: pyserial>=3.5
|
|
|
43
43
|
Requires-Dist: scipy>=1.7.3
|
|
44
44
|
Requires-Dist: bitstring>=4.1.2
|
|
45
45
|
Requires-Dist: typing_extensions>=4.12.2
|
|
46
|
+
Dynamic: license-file
|
|
46
47
|
|
|
47
48
|
# Pyglaze
|
|
48
49
|
Pyglaze is a python library used to operate the devices of [Glaze Technologies](https://www.glazetech.dk/).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pyglaze"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Pyglaze is a library used to operate the devices of Glaze Technologies"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { file = "LICENSE" }
|
|
@@ -74,7 +74,7 @@ convention = "google"
|
|
|
74
74
|
]
|
|
75
75
|
|
|
76
76
|
[tool.bumpver]
|
|
77
|
-
current_version = "0.
|
|
77
|
+
current_version = "0.4.0"
|
|
78
78
|
version_pattern = "MAJOR.MINOR.PATCH[-TAG]"
|
|
79
79
|
commit_message = "BUMP VERSION {old_version} -> {new_version}"
|
|
80
80
|
tag_message = "v{new_version}"
|
|
@@ -82,8 +82,8 @@ tag_scope = "default"
|
|
|
82
82
|
pre_commit_hook = ""
|
|
83
83
|
post_commit_hook = ""
|
|
84
84
|
commit = true
|
|
85
|
-
tag =
|
|
86
|
-
push =
|
|
85
|
+
tag = false
|
|
86
|
+
push = false
|
|
87
87
|
|
|
88
88
|
[tool.bumpver.file_patterns]
|
|
89
89
|
"pyproject.toml" = [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.4.0"
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from dataclasses import dataclass
|
|
4
|
-
from
|
|
5
|
-
from typing import Literal, cast
|
|
4
|
+
from typing import TYPE_CHECKING, Callable, Literal, cast
|
|
6
5
|
|
|
7
6
|
import numpy as np
|
|
7
|
+
from scipy import optimize as opt
|
|
8
8
|
from scipy import signal
|
|
9
|
+
from scipy.stats import linregress
|
|
9
10
|
|
|
10
|
-
from pyglaze.helpers.types import ComplexArray, FloatArray
|
|
11
11
|
from pyglaze.interpolation import ws_interpolate
|
|
12
12
|
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pyglaze.helpers._types import ComplexArray, FloatArray
|
|
15
|
+
|
|
13
16
|
__all__ = ["Pulse"]
|
|
14
17
|
|
|
15
18
|
|
|
@@ -20,12 +23,10 @@ class Pulse:
|
|
|
20
23
|
Args:
|
|
21
24
|
time: The time values recorded by the lock-in amp during the scan.
|
|
22
25
|
signal: The signal values recorded by the lock-in amp during the scan.
|
|
23
|
-
signal_err: Potential errors on signal
|
|
24
26
|
"""
|
|
25
27
|
|
|
26
28
|
time: FloatArray
|
|
27
29
|
signal: FloatArray
|
|
28
|
-
signal_err: FloatArray | None = None
|
|
29
30
|
|
|
30
31
|
def __len__(self: Pulse) -> int: # noqa: D105
|
|
31
32
|
return len(self.time)
|
|
@@ -38,15 +39,14 @@ class Pulse:
|
|
|
38
39
|
return bool(
|
|
39
40
|
np.array_equal(self.time, obj.time)
|
|
40
41
|
and np.array_equal(self.signal, obj.signal)
|
|
41
|
-
and np.array_equal(self.signal_err, obj.signal_err) # type: ignore[arg-type]
|
|
42
42
|
)
|
|
43
43
|
|
|
44
|
-
@
|
|
44
|
+
@property
|
|
45
45
|
def fft(self: Pulse) -> ComplexArray:
|
|
46
46
|
"""Return the Fourier Transform of a signal."""
|
|
47
47
|
return np.fft.rfft(self.signal, norm="forward")
|
|
48
48
|
|
|
49
|
-
@
|
|
49
|
+
@property
|
|
50
50
|
def frequency(self: Pulse) -> FloatArray:
|
|
51
51
|
"""Return the Fourier Transform sample frequencies."""
|
|
52
52
|
return np.fft.rfftfreq(len(self.signal), d=self.time[1] - self.time[0])
|
|
@@ -91,6 +91,14 @@ class Pulse:
|
|
|
91
91
|
"""Time delay at the minimum value of the pulse."""
|
|
92
92
|
return float(self.time[np.argmin(self.signal)])
|
|
93
93
|
|
|
94
|
+
@property
|
|
95
|
+
def energy(self: Pulse) -> float:
|
|
96
|
+
"""Energy of the pulse.
|
|
97
|
+
|
|
98
|
+
Note that the energy is not the same as the physical energy of the pulse, but rather the integral of the square of the pulse.
|
|
99
|
+
"""
|
|
100
|
+
return cast("float", np.trapz(self.signal * self.signal, x=self.time)) # noqa: NPY201 - trapz removed in numpy 2.0
|
|
101
|
+
|
|
94
102
|
@classmethod
|
|
95
103
|
def from_dict(
|
|
96
104
|
cls: type[Pulse], d: dict[str, FloatArray | list[float] | None]
|
|
@@ -98,12 +106,9 @@ class Pulse:
|
|
|
98
106
|
"""Create a Pulse object from a dictionary.
|
|
99
107
|
|
|
100
108
|
Args:
|
|
101
|
-
d: A dictionary containing the keys 'time', 'signal'
|
|
109
|
+
d: A dictionary containing the keys 'time', 'signal'.
|
|
102
110
|
"""
|
|
103
|
-
|
|
104
|
-
return Pulse(
|
|
105
|
-
time=np.array(d["time"]), signal=np.array(d["signal"]), signal_err=err
|
|
106
|
-
)
|
|
111
|
+
return Pulse(time=np.array(d["time"]), signal=np.array(d["signal"]))
|
|
107
112
|
|
|
108
113
|
@classmethod
|
|
109
114
|
def from_fft(cls: type[Pulse], time: FloatArray, fft: ComplexArray) -> Pulse:
|
|
@@ -129,10 +134,7 @@ class Pulse:
|
|
|
129
134
|
return scans[0]
|
|
130
135
|
signals = np.array([scan.signal for scan in scans])
|
|
131
136
|
mean_signal = np.mean(signals, axis=0)
|
|
132
|
-
|
|
133
|
-
root_n_scans = np.sqrt(len(scans))
|
|
134
|
-
std_signal = np.std(signals, axis=0, ddof=1) / root_n_scans
|
|
135
|
-
return Pulse(scans[0].time, mean_signal, signal_err=std_signal)
|
|
137
|
+
return Pulse(scans[0].time, mean_signal)
|
|
136
138
|
|
|
137
139
|
@classmethod
|
|
138
140
|
def align(
|
|
@@ -142,11 +144,11 @@ class Pulse:
|
|
|
142
144
|
wrt_max: bool = True,
|
|
143
145
|
translate_to_zero: bool = True,
|
|
144
146
|
) -> list[Pulse]:
|
|
145
|
-
"""Aligns a list of
|
|
147
|
+
"""Aligns a list of pulses with respect to the zerocrossings of their main pulse.
|
|
146
148
|
|
|
147
149
|
Args:
|
|
148
150
|
scans: List of scans
|
|
149
|
-
wrt_max: Whether to
|
|
151
|
+
wrt_max: Whether to perform rough alignment with respect to their maximum (true) or minimum(false). Defaults to True.
|
|
150
152
|
translate_to_zero: Whether to translate all scans to t[0] = 0. Defaults to True.
|
|
151
153
|
|
|
152
154
|
Returns:
|
|
@@ -163,15 +165,17 @@ class Pulse:
|
|
|
163
165
|
if translate_to_zero:
|
|
164
166
|
for scan in roughly_aligned:
|
|
165
167
|
scan.time = scan.time - scan.time[0]
|
|
168
|
+
zerocrossings = [p.estimate_zero_crossing() for p in roughly_aligned]
|
|
169
|
+
mean_zerocrossing = cast("float", np.mean(zerocrossings))
|
|
166
170
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
171
|
+
return [
|
|
172
|
+
p.propagate(mean_zerocrossing - zc)
|
|
173
|
+
for p, zc in zip(roughly_aligned, zerocrossings)
|
|
174
|
+
]
|
|
170
175
|
|
|
171
176
|
@classmethod
|
|
172
177
|
def _from_slice(cls: type[Pulse], scan: Pulse, indices: slice) -> Pulse:
|
|
173
|
-
|
|
174
|
-
return cls(scan.time[indices], scan.signal[indices], err)
|
|
178
|
+
return cls(scan.time[indices], scan.signal[indices])
|
|
175
179
|
|
|
176
180
|
def cut(self: Pulse, from_time: float, to_time: float) -> Pulse:
|
|
177
181
|
"""Create a Pulse object by cutting out a specific section of the scan.
|
|
@@ -182,11 +186,18 @@ class Pulse:
|
|
|
182
186
|
"""
|
|
183
187
|
from_idx = int(np.searchsorted(self.time, from_time))
|
|
184
188
|
to_idx = int(np.searchsorted(self.time, to_time, side="right"))
|
|
185
|
-
return Pulse(
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
189
|
+
return Pulse(self.time[from_idx:to_idx], self.signal[from_idx:to_idx])
|
|
190
|
+
|
|
191
|
+
def fft_at_f(self: Pulse, f: float) -> complex:
|
|
192
|
+
"""Returns the Fourier Transform at a specific frequency.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
f: Frequency in Hz
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
complex: Fourier Transform at the given frequency
|
|
199
|
+
"""
|
|
200
|
+
return cast("complex", self.fft[np.searchsorted(self.frequency, f)])
|
|
190
201
|
|
|
191
202
|
def timeshift(self: Pulse, scale: float, offset: float = 0) -> Pulse:
|
|
192
203
|
"""Rescales and offsets the time axis as.
|
|
@@ -200,11 +211,7 @@ class Pulse:
|
|
|
200
211
|
Returns:
|
|
201
212
|
Timeshifted pulse
|
|
202
213
|
"""
|
|
203
|
-
return Pulse(
|
|
204
|
-
time=scale * (self.time + offset),
|
|
205
|
-
signal=self.signal,
|
|
206
|
-
signal_err=self.signal_err,
|
|
207
|
-
)
|
|
214
|
+
return Pulse(time=scale * (self.time + offset), signal=self.signal)
|
|
208
215
|
|
|
209
216
|
def add_white_noise(
|
|
210
217
|
self: Pulse, noise_std: float, seed: int | None = None
|
|
@@ -224,7 +231,6 @@ class Pulse:
|
|
|
224
231
|
+ np.random.default_rng(seed).normal(
|
|
225
232
|
loc=0, scale=noise_std, size=len(self)
|
|
226
233
|
),
|
|
227
|
-
signal_err=np.ones(len(self)) * noise_std,
|
|
228
234
|
)
|
|
229
235
|
|
|
230
236
|
def zeropadded(self: Pulse, n_zeros: int) -> Pulse:
|
|
@@ -242,6 +248,28 @@ class Pulse:
|
|
|
242
248
|
)
|
|
243
249
|
return Pulse(time=zeropadded_time, signal=zeropadded_signal)
|
|
244
250
|
|
|
251
|
+
def signal_at_t(self: Pulse, t: float) -> float:
|
|
252
|
+
"""Returns the signal at a specific time using Whittaker Shannon interpolation.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
t: Time in seconds
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Signal at the given time
|
|
259
|
+
"""
|
|
260
|
+
return cast("float", ws_interpolate(self.time, self.signal, np.array([t]))[0])
|
|
261
|
+
|
|
262
|
+
def subtract_mean(self: Pulse, fraction: float = 0.99) -> Pulse:
|
|
263
|
+
"""Subtracts the mean of the pulse.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
fraction: Fraction of the mean to subtract. Defaults to 0.99.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Pulse with the mean subtracted
|
|
270
|
+
"""
|
|
271
|
+
return Pulse(self.time, self.signal - fraction * np.mean(self.signal))
|
|
272
|
+
|
|
245
273
|
def tukey(
|
|
246
274
|
self: Pulse,
|
|
247
275
|
taper_length: float,
|
|
@@ -338,53 +366,52 @@ class Pulse:
|
|
|
338
366
|
20 * np.log10((abs_spectrum + offset) / ref), dtype=np.float64
|
|
339
367
|
)
|
|
340
368
|
|
|
341
|
-
def estimate_bandwidth(self: Pulse,
|
|
369
|
+
def estimate_bandwidth(self: Pulse, linear_segments: int = 1) -> float:
|
|
342
370
|
"""Estimates the bandwidth of the pulse.
|
|
343
371
|
|
|
344
|
-
|
|
372
|
+
The bandwidth is estimated by modelling the log of the pulse's spectrum above the center frequency as a constant noisefloor and n linear segments of equal size. The bandwidth is then defined as the frequency at which the noisefloor is reached.
|
|
345
373
|
|
|
346
374
|
Args:
|
|
347
|
-
|
|
375
|
+
linear_segments: Number of linear segments to fit to the spectrum. Defaults to 1.
|
|
348
376
|
|
|
349
377
|
Returns:
|
|
350
378
|
float: Estimated bandwidth in Hz
|
|
351
379
|
"""
|
|
352
|
-
return self._estimate_pulse_properties(
|
|
380
|
+
return self._estimate_pulse_properties(linear_segments)[0]
|
|
353
381
|
|
|
354
|
-
def estimate_dynamic_range(self: Pulse,
|
|
382
|
+
def estimate_dynamic_range(self: Pulse, linear_segments: int = 1) -> float:
|
|
355
383
|
"""Estimates the dynamic range of the pulse.
|
|
356
384
|
|
|
357
|
-
|
|
385
|
+
The dynamic range is estimated by modelling the log of the pulse's spectrum above the center frequency as a constant noisefloor and n linear segments of equal size. The dynamic range is then calculated as the maximum of the spectrum minus the noisefloor.
|
|
358
386
|
|
|
359
387
|
Args:
|
|
360
|
-
|
|
388
|
+
linear_segments: Number of linear segments to fit to the spectrum. Defaults to 1.
|
|
361
389
|
|
|
362
390
|
Returns:
|
|
363
391
|
float: Estimated dynamic range in dB
|
|
364
392
|
"""
|
|
365
|
-
return self._estimate_pulse_properties(
|
|
393
|
+
return self._estimate_pulse_properties(linear_segments)[1]
|
|
366
394
|
|
|
367
|
-
def estimate_avg_noise_power(self: Pulse,
|
|
395
|
+
def estimate_avg_noise_power(self: Pulse, linear_segments: int = 1) -> float:
|
|
368
396
|
"""Estimates the noise power.
|
|
369
397
|
|
|
370
|
-
Noise power is
|
|
371
|
-
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).
|
|
398
|
+
The noise power is estimated by modelling the the log of pulse's spectrum above the center frequency as a constant noisefloor and n linear segments of equal size. Noise power is then calculated as the mean of the absolute square of the spectral bins above the frequency at which the noise floor is reached.
|
|
372
399
|
|
|
373
400
|
Args:
|
|
374
|
-
|
|
401
|
+
linear_segments: Number of linear segments to fit to the spectrum. Defaults to 1.
|
|
375
402
|
|
|
376
403
|
Returns:
|
|
377
404
|
float: Estimated noise power.
|
|
378
405
|
"""
|
|
379
|
-
return self._estimate_pulse_properties(
|
|
406
|
+
return self._estimate_pulse_properties(linear_segments)[2]
|
|
380
407
|
|
|
381
|
-
def estimate_SNR(self: Pulse,
|
|
408
|
+
def estimate_SNR(self: Pulse, linear_segments: int = 1) -> FloatArray:
|
|
382
409
|
"""Estimates the signal-to-noise ratio.
|
|
383
410
|
|
|
384
|
-
Estimates the SNR, assuming white noise.
|
|
411
|
+
Estimates the SNR, assuming white noise. The noisefloor is estimated by modelling the log of the pulse's spectrum above the center frequency as a constant noisefloor and n linear segments of equal size. Noise power is then calculated as the mean of the absolute square of the spectral bins above the frequency at which the noise floor is reached. The signal power is then extrapolated above the bandwidth by fitting a second order polynomial to the spectrum above the noisefloor.
|
|
385
412
|
|
|
386
413
|
Args:
|
|
387
|
-
|
|
414
|
+
linear_segments: Number of linear segments to fit to the spectrum. Defaults to 1.
|
|
388
415
|
|
|
389
416
|
Returns:
|
|
390
417
|
float: Estimated signal-to-noise ratio.
|
|
@@ -392,7 +419,7 @@ class Pulse:
|
|
|
392
419
|
# Get spectrum between maximum and noisefloor
|
|
393
420
|
_from = np.argmax(self.spectrum_dB())
|
|
394
421
|
_to = np.searchsorted(
|
|
395
|
-
self.frequency, self.estimate_bandwidth(
|
|
422
|
+
self.frequency, self.estimate_bandwidth(linear_segments=linear_segments)
|
|
396
423
|
)
|
|
397
424
|
x = self.frequency[_from:_to]
|
|
398
425
|
y = self.spectrum_dB()[_from:_to]
|
|
@@ -402,7 +429,7 @@ class Pulse:
|
|
|
402
429
|
|
|
403
430
|
# Combine signal before spectrum maximum with interpolated values
|
|
404
431
|
y_values = cast(
|
|
405
|
-
FloatArray,
|
|
432
|
+
"FloatArray",
|
|
406
433
|
np.concatenate(
|
|
407
434
|
[
|
|
408
435
|
self.spectrum_dB()[:_from],
|
|
@@ -411,10 +438,16 @@ class Pulse:
|
|
|
411
438
|
),
|
|
412
439
|
)
|
|
413
440
|
signal_power = 10 ** (y_values / 10) * self.maximum_spectral_density**2
|
|
414
|
-
return signal_power / self.estimate_avg_noise_power(
|
|
441
|
+
return signal_power / self.estimate_avg_noise_power(
|
|
442
|
+
linear_segments=linear_segments
|
|
443
|
+
)
|
|
415
444
|
|
|
416
445
|
def estimate_peak_to_peak(
|
|
417
|
-
self: Pulse,
|
|
446
|
+
self: Pulse,
|
|
447
|
+
delay_tolerance: float | None = None,
|
|
448
|
+
strategy: Callable[
|
|
449
|
+
[FloatArray, FloatArray, FloatArray], FloatArray
|
|
450
|
+
] = ws_interpolate,
|
|
418
451
|
) -> float:
|
|
419
452
|
"""Estimates the peak-to-peak value of the pulse.
|
|
420
453
|
|
|
@@ -422,6 +455,7 @@ class Pulse:
|
|
|
422
455
|
|
|
423
456
|
Args:
|
|
424
457
|
delay_tolerance: Tolerance for peak detection. Defaults to None.
|
|
458
|
+
strategy: Interpolation strategy. Defaults to Whittaker-Shannon interpolation
|
|
425
459
|
|
|
426
460
|
Returns:
|
|
427
461
|
float: Estimated peak-to-peak value.
|
|
@@ -433,10 +467,10 @@ class Pulse:
|
|
|
433
467
|
msg = "Tolerance must be smaller than the time spacing of the pulse."
|
|
434
468
|
raise ValueError(msg)
|
|
435
469
|
|
|
436
|
-
max_estimate =
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
470
|
+
max_estimate = strategy(
|
|
471
|
+
self.time,
|
|
472
|
+
self.signal,
|
|
473
|
+
np.linspace(
|
|
440
474
|
self.delay_at_max - self.dt,
|
|
441
475
|
self.delay_at_max + self.dt,
|
|
442
476
|
num=1 + int(self.dt / delay_tolerance),
|
|
@@ -444,10 +478,10 @@ class Pulse:
|
|
|
444
478
|
),
|
|
445
479
|
)
|
|
446
480
|
|
|
447
|
-
min_estimate =
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
481
|
+
min_estimate = strategy(
|
|
482
|
+
self.time,
|
|
483
|
+
self.signal,
|
|
484
|
+
np.linspace(
|
|
451
485
|
self.delay_at_min - self.dt,
|
|
452
486
|
self.delay_at_min + self.dt,
|
|
453
487
|
num=1 + int(self.dt / delay_tolerance),
|
|
@@ -455,7 +489,7 @@ class Pulse:
|
|
|
455
489
|
),
|
|
456
490
|
)
|
|
457
491
|
|
|
458
|
-
return cast(float, np.max(max_estimate) - np.min(min_estimate))
|
|
492
|
+
return cast("float", np.max(max_estimate) - np.min(min_estimate))
|
|
459
493
|
|
|
460
494
|
def estimate_zero_crossing(self: Pulse) -> float:
|
|
461
495
|
"""Estimates the zero crossing of the pulse between the maximum and minimum value.
|
|
@@ -473,7 +507,21 @@ class Pulse:
|
|
|
473
507
|
# To find the zero crossing, solve 0 = s1 + a * (t - t1) for t: t = t1 - s1 / a
|
|
474
508
|
t1, s1 = self.time[idx], self.signal[idx]
|
|
475
509
|
a = (self.signal[idx + 1] - self.signal[idx]) / self.dt
|
|
476
|
-
return cast(float, t1 - s1 / a)
|
|
510
|
+
return cast("float", t1 - s1 / a)
|
|
511
|
+
|
|
512
|
+
def propagate(self: Pulse, time: float) -> Pulse:
|
|
513
|
+
"""Propagates the pulse in time by a given amount.
|
|
514
|
+
|
|
515
|
+
Args:
|
|
516
|
+
time: Time in seconds to propagate the pulse by
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
Pulse: Propagated pulse
|
|
520
|
+
"""
|
|
521
|
+
return Pulse.from_fft(
|
|
522
|
+
time=self.time,
|
|
523
|
+
fft=self.fft * np.exp(-1j * 2 * np.pi * self.frequency * time),
|
|
524
|
+
)
|
|
477
525
|
|
|
478
526
|
def to_native_dict(self: Pulse) -> dict[str, list[float] | None]:
|
|
479
527
|
"""Converts the Pulse object to a native dictionary.
|
|
@@ -481,79 +529,82 @@ class Pulse:
|
|
|
481
529
|
Returns:
|
|
482
530
|
Native dictionary representation of the Pulse object.
|
|
483
531
|
"""
|
|
484
|
-
return {
|
|
485
|
-
"time": list(self.time),
|
|
486
|
-
"signal": list(self.signal),
|
|
487
|
-
"signal_err": None if self.signal_err is None else list(self.signal_err),
|
|
488
|
-
}
|
|
532
|
+
return {"time": list(self.time), "signal": list(self.signal)}
|
|
489
533
|
|
|
490
534
|
def _get_min_or_max_idx(self: Pulse, *, wrt_max: bool) -> int:
|
|
491
535
|
return int(np.argmax(self.signal)) if wrt_max else int(np.argmin(self.signal))
|
|
492
536
|
|
|
493
537
|
def _estimate_pulse_properties(
|
|
494
|
-
self: Pulse,
|
|
538
|
+
self: Pulse, linear_segments: int
|
|
495
539
|
) -> tuple[float, float, float]:
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
avg_noise_power = np.mean(abs_spectrum[
|
|
540
|
+
mean_substracted = self.subtract_mean()
|
|
541
|
+
argmax = np.argmax(np.abs(mean_substracted.fft))
|
|
542
|
+
freqs = mean_substracted.frequency[argmax:]
|
|
543
|
+
abs_spectrum = np.abs(mean_substracted.fft[argmax:])
|
|
544
|
+
bw_idx_estimate = _estimate_bw_idx(freqs, abs_spectrum, linear_segments)
|
|
545
|
+
avg_noise_power = np.mean(abs_spectrum[bw_idx_estimate:] ** 2)
|
|
502
546
|
noisefloor = np.sqrt(avg_noise_power)
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
# to an index in the original array
|
|
507
|
-
cutoff_idx = noisefloor_idx_estimate - np.searchsorted(
|
|
508
|
-
np.flip(abs_spectrum[: noisefloor_idx_estimate + 1]),
|
|
509
|
-
noisefloor,
|
|
510
|
-
side="right",
|
|
547
|
+
bandwidth = freqs[bw_idx_estimate]
|
|
548
|
+
dynamic_range_dB = 20 * np.log10(
|
|
549
|
+
mean_substracted.maximum_spectral_density / noisefloor
|
|
511
550
|
)
|
|
512
|
-
bandwidth = freqs[cutoff_idx]
|
|
513
|
-
dynamic_range_dB = 20 * np.log10(self.maximum_spectral_density / noisefloor)
|
|
514
551
|
return bandwidth, dynamic_range_dB, avg_noise_power
|
|
515
552
|
|
|
516
553
|
|
|
517
|
-
def
|
|
518
|
-
|
|
519
|
-
) -> list[Pulse]:
|
|
520
|
-
# corresponds to a template of length 8 - chosen as a compromise between speed and accuracy
|
|
521
|
-
window_size = 4
|
|
522
|
-
ref_slice = slice(extremum - window_size, extremum + window_size)
|
|
554
|
+
def _estimate_bw_idx(x: FloatArray, y: FloatArray, segments: int) -> int:
|
|
555
|
+
"""Estimate the noise floor of a spectrum.
|
|
523
556
|
|
|
524
|
-
|
|
525
|
-
|
|
557
|
+
Args:
|
|
558
|
+
x: Frequency values
|
|
559
|
+
y: Spectrum values
|
|
560
|
+
segments: Number of linear segments to fit
|
|
526
561
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
slice(extremum - window_size + i, extremum + window_size + i)
|
|
532
|
-
for i in cut_candidates
|
|
533
|
-
]
|
|
534
|
-
cuts[i_scan] = cut_candidates[
|
|
535
|
-
np.argmax(
|
|
536
|
-
[correlate(scan.signal[s], ref.signal[ref_slice]) for s in slices]
|
|
537
|
-
)
|
|
538
|
-
]
|
|
562
|
+
Returns:
|
|
563
|
+
float: Estimated noise floor
|
|
564
|
+
"""
|
|
565
|
+
target = np.log(y)
|
|
539
566
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
567
|
+
def L1(x: FloatArray, y: FloatArray) -> FloatArray:
|
|
568
|
+
return np.sum(np.abs(y - x)) # type: ignore[no-any-return]
|
|
569
|
+
|
|
570
|
+
def model(pars: list[float]) -> FloatArray:
|
|
571
|
+
idx = np.searchsorted(x, pars[0])
|
|
572
|
+
x_before = x[:idx]
|
|
573
|
+
x_after = x[idx:]
|
|
574
|
+
target_before = target[:idx]
|
|
575
|
+
target_after = target[idx:]
|
|
576
|
+
y_fit = _fit_linear_segments(x_before, target_before, segments)
|
|
577
|
+
noise = np.ones(len(x_after)) * y_fit[-1]
|
|
578
|
+
return L1(y_fit, target_before) + L1(noise, target_after)
|
|
579
|
+
|
|
580
|
+
BW_estimate = opt.minimize(
|
|
581
|
+
fun=model, x0=[x[len(x) // 2]], bounds=[(x[0], x[-1])], method="Nelder-Mead"
|
|
582
|
+
).x[0]
|
|
583
|
+
|
|
584
|
+
return cast("int", x.searchsorted(BW_estimate))
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _fit_linear_segments(x: FloatArray, y: FloatArray, n_segments: int) -> FloatArray:
|
|
588
|
+
"""Fit a pulse with a piecewise linear function.
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
x: Time values
|
|
592
|
+
y: Signal values
|
|
593
|
+
n_segments: Number of segments to fit
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
FloatArray: Fitted signal
|
|
597
|
+
"""
|
|
598
|
+
segment_indices = np.linspace(0, len(x), n_segments + 1, dtype=int)
|
|
599
|
+
y_fit = np.zeros_like(y)
|
|
600
|
+
|
|
601
|
+
for i in range(n_segments):
|
|
602
|
+
# Get the indices for this segment
|
|
603
|
+
start, end = segment_indices[i], segment_indices[i + 1]
|
|
604
|
+
x_segment = x[start:end]
|
|
605
|
+
y_segment = y[start:end]
|
|
606
|
+
|
|
607
|
+
# Fit a linear function to this segment
|
|
608
|
+
slope, intercept, _, _, _ = linregress(x_segment, y_segment)
|
|
609
|
+
y_fit[start:end] = slope * x_segment + intercept
|
|
610
|
+
return y_fit
|