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.
Files changed (37) hide show
  1. {pyglaze-0.2.2/src/pyglaze.egg-info → pyglaze-0.4.0}/PKG-INFO +3 -2
  2. {pyglaze-0.2.2 → pyglaze-0.4.0}/pyproject.toml +4 -4
  3. pyglaze-0.4.0/src/pyglaze/__init__.py +1 -0
  4. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/datamodels/pulse.py +178 -127
  5. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/datamodels/waveform.py +1 -1
  6. pyglaze-0.4.0/src/pyglaze/device/__init__.py +6 -0
  7. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/device/ampcom.py +25 -199
  8. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/device/configuration.py +7 -99
  9. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/devtools/mock_device.py +15 -150
  10. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/devtools/thz_pulse.py +4 -3
  11. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/helpers/utilities.py +6 -6
  12. pyglaze-0.4.0/src/pyglaze/interpolation/__init__.py +3 -0
  13. pyglaze-0.4.0/src/pyglaze/interpolation/interpolation.py +44 -0
  14. pyglaze-0.4.0/src/pyglaze/py.typed +0 -0
  15. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/scanning/_asyncscanner.py +32 -5
  16. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/scanning/client.py +17 -1
  17. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/scanning/scanner.py +51 -92
  18. {pyglaze-0.2.2 → pyglaze-0.4.0/src/pyglaze.egg-info}/PKG-INFO +3 -2
  19. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze.egg-info/SOURCES.txt +1 -1
  20. pyglaze-0.2.2/MANIFEST.in +0 -1
  21. pyglaze-0.2.2/src/pyglaze/__init__.py +0 -1
  22. pyglaze-0.2.2/src/pyglaze/device/__init__.py +0 -7
  23. pyglaze-0.2.2/src/pyglaze/interpolation/__init__.py +0 -3
  24. pyglaze-0.2.2/src/pyglaze/interpolation/interpolation.py +0 -24
  25. {pyglaze-0.2.2 → pyglaze-0.4.0}/LICENSE +0 -0
  26. /pyglaze-0.2.2/src/pyglaze/helpers/__init__.py → /pyglaze-0.4.0/MANIFEST.in +0 -0
  27. {pyglaze-0.2.2 → pyglaze-0.4.0}/README.md +0 -0
  28. {pyglaze-0.2.2 → pyglaze-0.4.0}/setup.cfg +0 -0
  29. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/datamodels/__init__.py +0 -0
  30. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/devtools/__init__.py +0 -0
  31. /pyglaze-0.2.2/src/pyglaze/py.typed → /pyglaze-0.4.0/src/pyglaze/helpers/__init__.py +0 -0
  32. /pyglaze-0.2.2/src/pyglaze/helpers/types.py → /pyglaze-0.4.0/src/pyglaze/helpers/_types.py +0 -0
  33. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/scanning/__init__.py +0 -0
  34. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze/scanning/_exceptions.py +0 -0
  35. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze.egg-info/dependency_links.txt +0 -0
  36. {pyglaze-0.2.2 → pyglaze-0.4.0}/src/pyglaze.egg-info/requires.txt +0 -0
  37. {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
1
+ Metadata-Version: 2.4
2
2
  Name: pyglaze
3
- Version: 0.2.2
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.2.2"
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.2.2"
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 = true
86
- push = true
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 functools import cached_property
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
- @cached_property
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
- @cached_property
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' and potentially 'signal_err'.
109
+ d: A dictionary containing the keys 'time', 'signal'.
102
110
  """
103
- err = np.array(d["signal_err"]) if d.get("signal_err") is not None else None
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 scan with respect to their individual maxima or minima.
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 align with respect to maximum. Defaults to True.
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
- ref = roughly_aligned[len(roughly_aligned) // 2]
168
- extremum = extrema[len(roughly_aligned) // 2]
169
- return _match_templates(extremum, ref, roughly_aligned)
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
- err = scan.signal_err[indices] if scan.signal_err is not None else None
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
- self.time[from_idx:to_idx],
187
- self.signal[from_idx:to_idx],
188
- None if self.signal_err is None else self.signal_err[from_idx:to_idx],
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, omega_power: int = 3) -> float:
369
+ def estimate_bandwidth(self: Pulse, linear_segments: int = 1) -> float:
342
370
  """Estimates the bandwidth of the pulse.
343
371
 
344
- 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).
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
- omega_power: power to raise omega to before estimating the bandwidth. Defaults to 3
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(omega_power)[0]
380
+ return self._estimate_pulse_properties(linear_segments)[0]
353
381
 
354
- def estimate_dynamic_range(self: Pulse, omega_power: int = 3) -> float:
382
+ def estimate_dynamic_range(self: Pulse, linear_segments: int = 1) -> float:
355
383
  """Estimates the dynamic range of the pulse.
356
384
 
357
- 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).
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
- omega_power: power to raise omega to before estimating the dynamic range. Defaults to 3
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(omega_power)[1]
393
+ return self._estimate_pulse_properties(linear_segments)[1]
366
394
 
367
- def estimate_avg_noise_power(self: Pulse, omega_power: int = 3) -> float:
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 defined as the mean of the absolute square of the noise floor.
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
- omega_power: power to raise omega to before estimating the noisepower. Defaults to 3
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(omega_power)[2]
406
+ return self._estimate_pulse_properties(linear_segments)[2]
380
407
 
381
- def estimate_SNR(self: Pulse, omega_power: int = 3) -> FloatArray:
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. 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.
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
- omega_power: power to raise omega to before estimating the signal-to-noise ratio. Defaults to 3
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(omega_power=omega_power)
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(omega_power=omega_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, delay_tolerance: float | None = None
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 = ws_interpolate(
437
- times=self.time,
438
- pulse=self.signal,
439
- interp_times=np.linspace(
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 = ws_interpolate(
448
- times=self.time,
449
- pulse=self.signal,
450
- interp_times=np.linspace(
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, omega_power: int
538
+ self: Pulse, linear_segments: int
495
539
  ) -> tuple[float, float, float]:
496
- argmax = np.argmax(np.abs(self.fft))
497
- freqs = self.frequency[argmax:]
498
- abs_spectrum = np.abs(self.fft[argmax:])
499
-
500
- noisefloor_idx_estimate = np.argmin(abs_spectrum * freqs**omega_power)
501
- avg_noise_power = np.mean(abs_spectrum[noisefloor_idx_estimate:] ** 2)
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
- # Search for the first index, where the spectrum is above the noise floor
505
- # by flipping the spectrum to get a pseudo-increasing array, then convert back
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 _match_templates(
518
- extremum: int, ref: Pulse, roughly_aligned: list[Pulse]
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
- def correlate(x1: FloatArray, x2: FloatArray) -> float:
525
- return float(np.sum(x1 * x2 / (np.linalg.norm(x1) * np.linalg.norm(x2))))
557
+ Args:
558
+ x: Frequency values
559
+ y: Spectrum values
560
+ segments: Number of linear segments to fit
526
561
 
527
- cut_candidates = [-2, -1, 0, 1, 2]
528
- cuts = np.empty(len(roughly_aligned), dtype=int)
529
- for i_scan, scan in enumerate(roughly_aligned):
530
- slices = [
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
- max_cut = np.max(np.abs(cuts))
541
- new_length = len(ref) - max_cut
542
- aligned = []
543
- for scan, cut in zip(roughly_aligned, cuts):
544
- if cut >= 0:
545
- aligned.append(
546
- Pulse(
547
- time=ref.time[:new_length],
548
- signal=scan.signal[cut : cut + new_length],
549
- )
550
- )
551
- else:
552
- aligned.append(
553
- Pulse(
554
- time=ref.time[:new_length],
555
- signal=scan.signal[cut - new_length : cut],
556
- )
557
- )
558
-
559
- return aligned
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
@@ -11,7 +11,7 @@ from .pulse import Pulse
11
11
  if TYPE_CHECKING:
12
12
  from datetime import datetime
13
13
 
14
- from pyglaze.helpers.types import FloatArray
14
+ from pyglaze.helpers._types import FloatArray
15
15
 
16
16
  __all__ = ["UnprocessedWaveform"]
17
17
 
@@ -0,0 +1,6 @@
1
+ from .configuration import Interval, LeDeviceConfiguration
2
+
3
+ __all__ = [
4
+ "Interval",
5
+ "LeDeviceConfiguration",
6
+ ]