pyglaze 0.2.2__py3-none-any.whl → 0.3.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 CHANGED
@@ -1 +1 @@
1
- __version__ = "0.2.2"
1
+ __version__ = "0.3.0"
@@ -1,13 +1,14 @@
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 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
+ from pyglaze.helpers._types import ComplexArray, FloatArray
11
12
  from pyglaze.interpolation import ws_interpolate
12
13
 
13
14
  __all__ = ["Pulse"]
@@ -20,12 +21,10 @@ class Pulse:
20
21
  Args:
21
22
  time: The time values recorded by the lock-in amp during the scan.
22
23
  signal: The signal values recorded by the lock-in amp during the scan.
23
- signal_err: Potential errors on signal
24
24
  """
25
25
 
26
26
  time: FloatArray
27
27
  signal: FloatArray
28
- signal_err: FloatArray | None = None
29
28
 
30
29
  def __len__(self: Pulse) -> int: # noqa: D105
31
30
  return len(self.time)
@@ -38,15 +37,14 @@ class Pulse:
38
37
  return bool(
39
38
  np.array_equal(self.time, obj.time)
40
39
  and np.array_equal(self.signal, obj.signal)
41
- and np.array_equal(self.signal_err, obj.signal_err) # type: ignore[arg-type]
42
40
  )
43
41
 
44
- @cached_property
42
+ @property
45
43
  def fft(self: Pulse) -> ComplexArray:
46
44
  """Return the Fourier Transform of a signal."""
47
45
  return np.fft.rfft(self.signal, norm="forward")
48
46
 
49
- @cached_property
47
+ @property
50
48
  def frequency(self: Pulse) -> FloatArray:
51
49
  """Return the Fourier Transform sample frequencies."""
52
50
  return np.fft.rfftfreq(len(self.signal), d=self.time[1] - self.time[0])
@@ -91,6 +89,14 @@ class Pulse:
91
89
  """Time delay at the minimum value of the pulse."""
92
90
  return float(self.time[np.argmin(self.signal)])
93
91
 
92
+ @property
93
+ def energy(self: Pulse) -> float:
94
+ """Energy of the pulse.
95
+
96
+ 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.
97
+ """
98
+ return cast(float, np.trapz(self.signal * self.signal, x=self.time)) # noqa: NPY201 - trapz removed in numpy 2.0
99
+
94
100
  @classmethod
95
101
  def from_dict(
96
102
  cls: type[Pulse], d: dict[str, FloatArray | list[float] | None]
@@ -98,12 +104,9 @@ class Pulse:
98
104
  """Create a Pulse object from a dictionary.
99
105
 
100
106
  Args:
101
- d: A dictionary containing the keys 'time', 'signal' and potentially 'signal_err'.
107
+ d: A dictionary containing the keys 'time', 'signal'.
102
108
  """
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
- )
109
+ return Pulse(time=np.array(d["time"]), signal=np.array(d["signal"]))
107
110
 
108
111
  @classmethod
109
112
  def from_fft(cls: type[Pulse], time: FloatArray, fft: ComplexArray) -> Pulse:
@@ -129,10 +132,7 @@ class Pulse:
129
132
  return scans[0]
130
133
  signals = np.array([scan.signal for scan in scans])
131
134
  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)
135
+ return Pulse(scans[0].time, mean_signal)
136
136
 
137
137
  @classmethod
138
138
  def align(
@@ -142,11 +142,11 @@ class Pulse:
142
142
  wrt_max: bool = True,
143
143
  translate_to_zero: bool = True,
144
144
  ) -> list[Pulse]:
145
- """Aligns a list of scan with respect to their individual maxima or minima.
145
+ """Aligns a list of pulses with respect to the zerocrossings of their main pulse.
146
146
 
147
147
  Args:
148
148
  scans: List of scans
149
- wrt_max: Whether to align with respect to maximum. Defaults to True.
149
+ wrt_max: Whether to perform rough alignment with respect to their maximum (true) or minimum(false). Defaults to True.
150
150
  translate_to_zero: Whether to translate all scans to t[0] = 0. Defaults to True.
151
151
 
152
152
  Returns:
@@ -163,15 +163,17 @@ class Pulse:
163
163
  if translate_to_zero:
164
164
  for scan in roughly_aligned:
165
165
  scan.time = scan.time - scan.time[0]
166
+ zerocrossings = [p.estimate_zero_crossing() for p in roughly_aligned]
167
+ mean_zerocrossing = cast(float, np.mean(zerocrossings))
166
168
 
167
- ref = roughly_aligned[len(roughly_aligned) // 2]
168
- extremum = extrema[len(roughly_aligned) // 2]
169
- return _match_templates(extremum, ref, roughly_aligned)
169
+ return [
170
+ p.propagate(mean_zerocrossing - zc)
171
+ for p, zc in zip(roughly_aligned, zerocrossings)
172
+ ]
170
173
 
171
174
  @classmethod
172
175
  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)
176
+ return cls(scan.time[indices], scan.signal[indices])
175
177
 
176
178
  def cut(self: Pulse, from_time: float, to_time: float) -> Pulse:
177
179
  """Create a Pulse object by cutting out a specific section of the scan.
@@ -182,11 +184,18 @@ class Pulse:
182
184
  """
183
185
  from_idx = int(np.searchsorted(self.time, from_time))
184
186
  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
- )
187
+ return Pulse(self.time[from_idx:to_idx], self.signal[from_idx:to_idx])
188
+
189
+ def fft_at_f(self: Pulse, f: float) -> complex:
190
+ """Returns the Fourier Transform at a specific frequency.
191
+
192
+ Args:
193
+ f: Frequency in Hz
194
+
195
+ Returns:
196
+ complex: Fourier Transform at the given frequency
197
+ """
198
+ return cast(complex, self.fft[np.searchsorted(self.frequency, f)])
190
199
 
191
200
  def timeshift(self: Pulse, scale: float, offset: float = 0) -> Pulse:
192
201
  """Rescales and offsets the time axis as.
@@ -200,11 +209,7 @@ class Pulse:
200
209
  Returns:
201
210
  Timeshifted pulse
202
211
  """
203
- return Pulse(
204
- time=scale * (self.time + offset),
205
- signal=self.signal,
206
- signal_err=self.signal_err,
207
- )
212
+ return Pulse(time=scale * (self.time + offset), signal=self.signal)
208
213
 
209
214
  def add_white_noise(
210
215
  self: Pulse, noise_std: float, seed: int | None = None
@@ -224,7 +229,6 @@ class Pulse:
224
229
  + np.random.default_rng(seed).normal(
225
230
  loc=0, scale=noise_std, size=len(self)
226
231
  ),
227
- signal_err=np.ones(len(self)) * noise_std,
228
232
  )
229
233
 
230
234
  def zeropadded(self: Pulse, n_zeros: int) -> Pulse:
@@ -242,6 +246,28 @@ class Pulse:
242
246
  )
243
247
  return Pulse(time=zeropadded_time, signal=zeropadded_signal)
244
248
 
249
+ def signal_at_t(self: Pulse, t: float) -> float:
250
+ """Returns the signal at a specific time using Whittaker Shannon interpolation.
251
+
252
+ Args:
253
+ t: Time in seconds
254
+
255
+ Returns:
256
+ Signal at the given time
257
+ """
258
+ return cast(float, ws_interpolate(self.time, self.signal, np.array([t]))[0])
259
+
260
+ def subtract_mean(self: Pulse, fraction: float = 0.99) -> Pulse:
261
+ """Subtracts the mean of the pulse.
262
+
263
+ Args:
264
+ fraction: Fraction of the mean to subtract. Defaults to 0.99.
265
+
266
+ Returns:
267
+ Pulse with the mean subtracted
268
+ """
269
+ return Pulse(self.time, self.signal - fraction * np.mean(self.signal))
270
+
245
271
  def tukey(
246
272
  self: Pulse,
247
273
  taper_length: float,
@@ -338,53 +364,52 @@ class Pulse:
338
364
  20 * np.log10((abs_spectrum + offset) / ref), dtype=np.float64
339
365
  )
340
366
 
341
- def estimate_bandwidth(self: Pulse, omega_power: int = 3) -> float:
367
+ def estimate_bandwidth(self: Pulse, linear_segments: int = 1) -> float:
342
368
  """Estimates the bandwidth of the pulse.
343
369
 
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).
370
+ 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
371
 
346
372
  Args:
347
- omega_power: power to raise omega to before estimating the bandwidth. Defaults to 3
373
+ linear_segments: Number of linear segments to fit to the spectrum. Defaults to 1.
348
374
 
349
375
  Returns:
350
376
  float: Estimated bandwidth in Hz
351
377
  """
352
- return self._estimate_pulse_properties(omega_power)[0]
378
+ return self._estimate_pulse_properties(linear_segments)[0]
353
379
 
354
- def estimate_dynamic_range(self: Pulse, omega_power: int = 3) -> float:
380
+ def estimate_dynamic_range(self: Pulse, linear_segments: int = 1) -> float:
355
381
  """Estimates the dynamic range of the pulse.
356
382
 
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).
383
+ 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
384
 
359
385
  Args:
360
- omega_power: power to raise omega to before estimating the dynamic range. Defaults to 3
386
+ linear_segments: Number of linear segments to fit to the spectrum. Defaults to 1.
361
387
 
362
388
  Returns:
363
389
  float: Estimated dynamic range in dB
364
390
  """
365
- return self._estimate_pulse_properties(omega_power)[1]
391
+ return self._estimate_pulse_properties(linear_segments)[1]
366
392
 
367
- def estimate_avg_noise_power(self: Pulse, omega_power: int = 3) -> float:
393
+ def estimate_avg_noise_power(self: Pulse, linear_segments: int = 1) -> float:
368
394
  """Estimates the noise power.
369
395
 
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).
396
+ 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
397
 
373
398
  Args:
374
- omega_power: power to raise omega to before estimating the noisepower. Defaults to 3
399
+ linear_segments: Number of linear segments to fit to the spectrum. Defaults to 1.
375
400
 
376
401
  Returns:
377
402
  float: Estimated noise power.
378
403
  """
379
- return self._estimate_pulse_properties(omega_power)[2]
404
+ return self._estimate_pulse_properties(linear_segments)[2]
380
405
 
381
- def estimate_SNR(self: Pulse, omega_power: int = 3) -> FloatArray:
406
+ def estimate_SNR(self: Pulse, linear_segments: int = 1) -> FloatArray:
382
407
  """Estimates the signal-to-noise ratio.
383
408
 
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.
409
+ 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
410
 
386
411
  Args:
387
- omega_power: power to raise omega to before estimating the signal-to-noise ratio. Defaults to 3
412
+ linear_segments: Number of linear segments to fit to the spectrum. Defaults to 1.
388
413
 
389
414
  Returns:
390
415
  float: Estimated signal-to-noise ratio.
@@ -392,7 +417,7 @@ class Pulse:
392
417
  # Get spectrum between maximum and noisefloor
393
418
  _from = np.argmax(self.spectrum_dB())
394
419
  _to = np.searchsorted(
395
- self.frequency, self.estimate_bandwidth(omega_power=omega_power)
420
+ self.frequency, self.estimate_bandwidth(linear_segments=linear_segments)
396
421
  )
397
422
  x = self.frequency[_from:_to]
398
423
  y = self.spectrum_dB()[_from:_to]
@@ -411,10 +436,16 @@ class Pulse:
411
436
  ),
412
437
  )
413
438
  signal_power = 10 ** (y_values / 10) * self.maximum_spectral_density**2
414
- return signal_power / self.estimate_avg_noise_power(omega_power=omega_power)
439
+ return signal_power / self.estimate_avg_noise_power(
440
+ linear_segments=linear_segments
441
+ )
415
442
 
416
443
  def estimate_peak_to_peak(
417
- self: Pulse, delay_tolerance: float | None = None
444
+ self: Pulse,
445
+ delay_tolerance: float | None = None,
446
+ strategy: Callable[
447
+ [FloatArray, FloatArray, FloatArray], FloatArray
448
+ ] = ws_interpolate,
418
449
  ) -> float:
419
450
  """Estimates the peak-to-peak value of the pulse.
420
451
 
@@ -422,6 +453,7 @@ class Pulse:
422
453
 
423
454
  Args:
424
455
  delay_tolerance: Tolerance for peak detection. Defaults to None.
456
+ strategy: Interpolation strategy. Defaults to Whittaker-Shannon interpolation
425
457
 
426
458
  Returns:
427
459
  float: Estimated peak-to-peak value.
@@ -433,10 +465,10 @@ class Pulse:
433
465
  msg = "Tolerance must be smaller than the time spacing of the pulse."
434
466
  raise ValueError(msg)
435
467
 
436
- max_estimate = ws_interpolate(
437
- times=self.time,
438
- pulse=self.signal,
439
- interp_times=np.linspace(
468
+ max_estimate = strategy(
469
+ self.time,
470
+ self.signal,
471
+ np.linspace(
440
472
  self.delay_at_max - self.dt,
441
473
  self.delay_at_max + self.dt,
442
474
  num=1 + int(self.dt / delay_tolerance),
@@ -444,10 +476,10 @@ class Pulse:
444
476
  ),
445
477
  )
446
478
 
447
- min_estimate = ws_interpolate(
448
- times=self.time,
449
- pulse=self.signal,
450
- interp_times=np.linspace(
479
+ min_estimate = strategy(
480
+ self.time,
481
+ self.signal,
482
+ np.linspace(
451
483
  self.delay_at_min - self.dt,
452
484
  self.delay_at_min + self.dt,
453
485
  num=1 + int(self.dt / delay_tolerance),
@@ -475,85 +507,102 @@ class Pulse:
475
507
  a = (self.signal[idx + 1] - self.signal[idx]) / self.dt
476
508
  return cast(float, t1 - s1 / a)
477
509
 
510
+ def propagate(self: Pulse, time: float) -> Pulse:
511
+ """Propagates the pulse in time by a given amount.
512
+
513
+ Args:
514
+ time: Time in seconds to propagate the pulse by
515
+
516
+ Returns:
517
+ Pulse: Propagated pulse
518
+ """
519
+ return Pulse.from_fft(
520
+ time=self.time,
521
+ fft=self.fft * np.exp(-1j * 2 * np.pi * self.frequency * time),
522
+ )
523
+
478
524
  def to_native_dict(self: Pulse) -> dict[str, list[float] | None]:
479
525
  """Converts the Pulse object to a native dictionary.
480
526
 
481
527
  Returns:
482
528
  Native dictionary representation of the Pulse object.
483
529
  """
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
- }
530
+ return {"time": list(self.time), "signal": list(self.signal)}
489
531
 
490
532
  def _get_min_or_max_idx(self: Pulse, *, wrt_max: bool) -> int:
491
533
  return int(np.argmax(self.signal)) if wrt_max else int(np.argmin(self.signal))
492
534
 
493
535
  def _estimate_pulse_properties(
494
- self: Pulse, omega_power: int
536
+ self: Pulse, linear_segments: int
495
537
  ) -> 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)
538
+ mean_substracted = self.subtract_mean()
539
+ argmax = np.argmax(np.abs(mean_substracted.fft))
540
+ freqs = mean_substracted.frequency[argmax:]
541
+ abs_spectrum = np.abs(mean_substracted.fft[argmax:])
542
+ bw_idx_estimate = _estimate_bw_idx(freqs, abs_spectrum, linear_segments)
543
+ avg_noise_power = np.mean(abs_spectrum[bw_idx_estimate:] ** 2)
502
544
  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",
545
+ bandwidth = freqs[bw_idx_estimate]
546
+ dynamic_range_dB = 20 * np.log10(
547
+ mean_substracted.maximum_spectral_density / noisefloor
511
548
  )
512
- bandwidth = freqs[cutoff_idx]
513
- dynamic_range_dB = 20 * np.log10(self.maximum_spectral_density / noisefloor)
514
549
  return bandwidth, dynamic_range_dB, avg_noise_power
515
550
 
516
551
 
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)
552
+ def _estimate_bw_idx(x: FloatArray, y: FloatArray, segments: int) -> int:
553
+ """Estimate the noise floor of a spectrum.
523
554
 
524
- def correlate(x1: FloatArray, x2: FloatArray) -> float:
525
- return float(np.sum(x1 * x2 / (np.linalg.norm(x1) * np.linalg.norm(x2))))
555
+ Args:
556
+ x: Frequency values
557
+ y: Spectrum values
558
+ segments: Number of linear segments to fit
526
559
 
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
- ]
560
+ Returns:
561
+ float: Estimated noise floor
562
+ """
563
+ target = np.log(y)
539
564
 
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
565
+ def L1(x: FloatArray, y: FloatArray) -> FloatArray:
566
+ return np.sum(np.abs(y - x)) # type: ignore[no-any-return]
567
+
568
+ def model(pars: list[float]) -> FloatArray:
569
+ idx = np.searchsorted(x, pars[0])
570
+ x_before = x[:idx]
571
+ x_after = x[idx:]
572
+ target_before = target[:idx]
573
+ target_after = target[idx:]
574
+ y_fit = _fit_linear_segments(x_before, target_before, segments)
575
+ noise = np.ones(len(x_after)) * y_fit[-1]
576
+ return L1(y_fit, target_before) + L1(noise, target_after)
577
+
578
+ BW_estimate = opt.minimize(
579
+ fun=model, x0=[x[len(x) // 2]], bounds=[(x[0], x[-1])], method="Nelder-Mead"
580
+ ).x[0]
581
+
582
+ return cast(int, x.searchsorted(BW_estimate))
583
+
584
+
585
+ def _fit_linear_segments(x: FloatArray, y: FloatArray, n_segments: int) -> FloatArray:
586
+ """Fit a pulse with a piecewise linear function.
587
+
588
+ Args:
589
+ x: Time values
590
+ y: Signal values
591
+ n_segments: Number of segments to fit
592
+
593
+ Returns:
594
+ FloatArray: Fitted signal
595
+ """
596
+ segment_indices = np.linspace(0, len(x), n_segments + 1, dtype=int)
597
+ y_fit = np.zeros_like(y)
598
+
599
+ for i in range(n_segments):
600
+ # Get the indices for this segment
601
+ start, end = segment_indices[i], segment_indices[i + 1]
602
+ x_segment = x[start:end]
603
+ y_segment = y[start:end]
604
+
605
+ # Fit a linear function to this segment
606
+ slope, intercept, _, _, _ = linregress(x_segment, y_segment)
607
+ y_fit[start:end] = slope * x_segment + intercept
608
+ 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
 
@@ -1,7 +1,7 @@
1
1
  from .configuration import ForceDeviceConfiguration, Interval, LeDeviceConfiguration
2
2
 
3
3
  __all__ = [
4
- "LeDeviceConfiguration",
5
4
  "ForceDeviceConfiguration",
6
5
  "Interval",
6
+ "LeDeviceConfiguration",
7
7
  ]
pyglaze/device/ampcom.py CHANGED
@@ -29,7 +29,7 @@ if TYPE_CHECKING:
29
29
  LeMockDevice,
30
30
  MockDevice,
31
31
  )
32
- from pyglaze.helpers.types import FloatArray
32
+ from pyglaze.helpers._types import FloatArray
33
33
 
34
34
 
35
35
  class DeviceComError(Exception):
@@ -4,7 +4,7 @@ from typing import cast
4
4
 
5
5
  import numpy as np
6
6
 
7
- from pyglaze.helpers.types import FloatArray
7
+ from pyglaze.helpers._types import FloatArray
8
8
 
9
9
 
10
10
  def gaussian_derivative_pulse(
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Callable, cast
8
8
  import serial
9
9
  import serial.tools.list_ports
10
10
 
11
- from pyglaze.helpers.types import P, T
11
+ from pyglaze.helpers._types import P, T
12
12
 
13
13
  if TYPE_CHECKING:
14
14
  import logging
@@ -64,11 +64,11 @@ class _BackoffRetry:
64
64
  raise
65
65
  except Exception as e: # noqa: BLE001
66
66
  self._log(
67
- f"{func.__name__} failed {tries+1} time(s) with: '{e}'. Trying again"
67
+ f"{func.__name__} failed {tries + 1} time(s) with: '{e}'. Trying again"
68
68
  )
69
69
  backoff = min(self.backoff_base * 2**tries, self.max_backoff)
70
70
  time.sleep(backoff)
71
- self._log(f"{func.__name__}: Last try ({tries+2}).")
71
+ self._log(f"{func.__name__}: Last try ({tries + 2}).")
72
72
  return cast(T, func(*args, **kwargs))
73
73
 
74
74
  return wrapper
@@ -1,3 +1,3 @@
1
- from .interpolation import ws_interpolate
1
+ from .interpolation import cubic_spline_interpolate, ws_interpolate
2
2
 
3
- __all__ = ["ws_interpolate"]
3
+ __all__ = ["cubic_spline_interpolate", "ws_interpolate"]
@@ -1,6 +1,9 @@
1
+ from typing import cast
2
+
1
3
  import numpy as np
4
+ from scipy.interpolate import CubicSpline
2
5
 
3
- from pyglaze.helpers.types import FloatArray
6
+ from pyglaze.helpers._types import FloatArray
4
7
 
5
8
 
6
9
  def ws_interpolate(
@@ -21,4 +24,21 @@ def ws_interpolate(
21
24
  # times must be zero-centered for formula to work
22
25
  sinc = np.sinc((interp_times[:, np.newaxis] - times[0] - dt * _range) / dt)
23
26
 
24
- return np.asarray(np.sum(pulse * sinc, axis=1))
27
+ return cast(FloatArray, np.sum(pulse * sinc, axis=1))
28
+
29
+
30
+ def cubic_spline_interpolate(
31
+ times: FloatArray, pulse: FloatArray, interp_times: FloatArray
32
+ ) -> FloatArray:
33
+ """Performs cubic spline interpolation at the supplied times given a pulse.
34
+
35
+ Args:
36
+ times: Sampling times
37
+ pulse: A sampled pulse satisfying the Nyquist criterion
38
+ interp_times: Array of times at which to interpolate
39
+
40
+ Returns:
41
+ FloatArray: Interpolated values
42
+ """
43
+ spline = CubicSpline(times, pulse, bc_type="natural")
44
+ return cast(FloatArray, spline(interp_times))
@@ -87,10 +87,8 @@ class _AsyncScanner:
87
87
 
88
88
  return [self._get_scan().waveform for _ in range(n_pulses)]
89
89
 
90
- def get_next(self: _AsyncScanner, averaged_over_n: int = 1) -> UnprocessedWaveform:
91
- return UnprocessedWaveform.average(
92
- [self._get_scan().waveform for _ in range(averaged_over_n)]
93
- )
90
+ def get_next(self: _AsyncScanner) -> UnprocessedWaveform:
91
+ return self._get_scan().waveform
94
92
 
95
93
  def _get_scan(self: _AsyncScanner) -> _TimestampedWaveform:
96
94
  try:
@@ -16,7 +16,7 @@ from pyglaze.device.configuration import (
16
16
  from pyglaze.scanning._exceptions import ScanError
17
17
 
18
18
  if TYPE_CHECKING:
19
- from pyglaze.helpers.types import FloatArray
19
+ from pyglaze.helpers._types import FloatArray
20
20
 
21
21
  TConfig = TypeVar("TConfig", bound=DeviceConfiguration)
22
22
 
@@ -258,10 +258,13 @@ def _scanner_factory(config: DeviceConfiguration) -> _ScannerImplementation:
258
258
 
259
259
  class _LockinPhaseEstimator:
260
260
  def __init__(
261
- self: _LockinPhaseEstimator, r_threshold_for_update: float = 2.0
261
+ self: _LockinPhaseEstimator,
262
+ r_threshold_for_update: float = 2.0,
263
+ theta_threshold_for_adjustment: float = 1.0,
262
264
  ) -> None:
263
- self.phase_estimate: float | None = None
264
265
  self.r_threshold_for_update = r_threshold_for_update
266
+ self.theta_threshold_for_adjustment = theta_threshold_for_adjustment
267
+ self.phase_estimate: float | None = None
265
268
  self._radius_of_est: float | None = None
266
269
 
267
270
  def update_estimate(
@@ -274,7 +277,11 @@ class _LockinPhaseEstimator:
274
277
  self._set_estimates(theta_at_max, r_max)
275
278
  return
276
279
 
277
- if r_max > self.r_threshold_for_update * self._radius_of_est:
280
+ if r_max > self.r_threshold_for_update * self._radius_of_est or (
281
+ r_max > self._radius_of_est
282
+ and abs(theta_at_max - self.phase_estimate)
283
+ < self.theta_threshold_for_adjustment
284
+ ):
278
285
  self._set_estimates(theta_at_max, r_max)
279
286
 
280
287
  def _set_estimates(
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.2
2
2
  Name: pyglaze
3
- Version: 0.2.2
3
+ Version: 0.3.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
@@ -38,11 +38,11 @@ Project-URL: Issues, https://github.com/GlazeTech/pyglaze/issues
38
38
  Requires-Python: <3.13,>=3.9
39
39
  Description-Content-Type: text/markdown
40
40
  License-File: LICENSE
41
- Requires-Dist: numpy <2.0.0,>=1.26.4
42
- Requires-Dist: pyserial >=3.5
43
- Requires-Dist: scipy >=1.7.3
44
- Requires-Dist: bitstring >=4.1.2
45
- Requires-Dist: typing-extensions >=4.12.2
41
+ Requires-Dist: numpy<2.0.0,>=1.26.4
42
+ Requires-Dist: pyserial>=3.5
43
+ Requires-Dist: scipy>=1.7.3
44
+ Requires-Dist: bitstring>=4.1.2
45
+ Requires-Dist: typing_extensions>=4.12.2
46
46
 
47
47
  # Pyglaze
48
48
  Pyglaze is a python library used to operate the devices of [Glaze Technologies](https://www.glazetech.dk/).
@@ -0,0 +1,26 @@
1
+ pyglaze/__init__.py,sha256=VrXpHDu3erkzwl_WXrqINBm9xWkcyUy53IQOj042dOs,22
2
+ pyglaze/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ pyglaze/datamodels/__init__.py,sha256=DJLByl2C7pC4RM4Uh6PW-McM5RIGBjcopzGywCKhSlI,111
4
+ pyglaze/datamodels/pulse.py,sha256=YnfwOkTyKUH-HFZAg2qpwe7kh8rb0B4HilZDFDdY36Q,22511
5
+ pyglaze/datamodels/waveform.py,sha256=T0wV7saJNPowwQs518VLypul-p1bg_1REPTflw8UNzM,5810
6
+ pyglaze/device/__init__.py,sha256=Y__HNJTPTAxLB_IpbLD4h2K9mDkI1_l4Ruc9mONH-3A,177
7
+ pyglaze/device/ampcom.py,sha256=FFrxAlLQbkslnlvNhIR5sd3xouMoUlMeodicZhYtgwg,17466
8
+ pyglaze/device/configuration.py,sha256=gh_eerX8TdXx3LnFxHieJqOpfDfE9cV6Xgm5WYVnvO0,7994
9
+ pyglaze/devtools/__init__.py,sha256=9EW20idoaZv_5GuSgDmfpTPjfCZ-Rl27EV3oJebmwnQ,90
10
+ pyglaze/devtools/mock_device.py,sha256=Fz0ZCRIslokBz6gFEztv1-V1UJhdTDqujl7-l8seX7U,14394
11
+ pyglaze/devtools/thz_pulse.py,sha256=C3O7s93rFc9xRC4KIqUwDWYsud8xZDP0FsE7-bNSWbo,924
12
+ pyglaze/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ pyglaze/helpers/_types.py,sha256=p9xSAP5Trr1FcCWl7ynCWqDOUZKgMQYzMUXSwDpAKHg,599
14
+ pyglaze/helpers/utilities.py,sha256=9JEaD2KzHXO1olFNCECMI6-sdX9bizkN7vvsc1dGlf8,2428
15
+ pyglaze/interpolation/__init__.py,sha256=BZU-mIAO7XxrPGG7tsUks-ghbylOaIANJGIYrtF5Y_Y,126
16
+ pyglaze/interpolation/interpolation.py,sha256=qxcK7zvQmEM2qiOoTs4b2Qs9U_QiGIEe16Z059eu6RA,1341
17
+ pyglaze/scanning/__init__.py,sha256=uCBaeDTufOrC9KWf30ICqcmvFg_YT85olb3M9jkvZRg,99
18
+ pyglaze/scanning/_asyncscanner.py,sha256=pdE4w1QlIzpSxdl5GB7IB0x4FjuhQeqPSs9yIuVQF6E,5203
19
+ pyglaze/scanning/_exceptions.py,sha256=vS28Dijj76jVuF6cSDBKqM9SQIa9rbIyUaHF-RA3PyM,213
20
+ pyglaze/scanning/client.py,sha256=3qrQStkeLQzCeu4yMHJ_ENLGQ7E5GMc4CP9J55rk-ug,1817
21
+ pyglaze/scanning/scanner.py,sha256=0e8jXhzY27BckwT7ii1PdpYE8JK4tD1DZPj3YmQqM7E,9506
22
+ pyglaze-0.3.0.dist-info/LICENSE,sha256=LCP3sGBX7LxuQopcjeug1fW4tngWCHF4zB7QCgB28xM,1504
23
+ pyglaze-0.3.0.dist-info/METADATA,sha256=TbEOazvaA3HP1RE5nUQ5rNQxqVlt_-g7px2B_mItpV0,3493
24
+ pyglaze-0.3.0.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
25
+ pyglaze-0.3.0.dist-info/top_level.txt,sha256=X7d5rqVVuWNmtK4-Uh4sgOLlqye8vaHZOr5RYba0REo,8
26
+ pyglaze-0.3.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (75.2.0)
2
+ Generator: setuptools (75.8.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,26 +0,0 @@
1
- pyglaze/__init__.py,sha256=m6kyaNpwBcP1XYcqrelX2oS3PJuOnElOcRdBa9pEb8c,22
2
- pyglaze/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
- pyglaze/datamodels/__init__.py,sha256=DJLByl2C7pC4RM4Uh6PW-McM5RIGBjcopzGywCKhSlI,111
4
- pyglaze/datamodels/pulse.py,sha256=BoW_GDvkwEpn_UYUFPoUfi5E_oCnvv8cU4nZKG7Ph2U,20911
5
- pyglaze/datamodels/waveform.py,sha256=n31DhJHFeBNZ3hHqQUiCGXssm5Dc8wV6tGPkhmFYB4Q,5809
6
- pyglaze/device/__init__.py,sha256=5RjCHuFKMi9g2KLUkxixO9hNpAgkUBcOURNTuhAdoUk,177
7
- pyglaze/device/ampcom.py,sha256=9smeYg5-3accfYWRg3K2wg1tGfirnWmPxPa4IYSvyRI,17465
8
- pyglaze/device/configuration.py,sha256=gh_eerX8TdXx3LnFxHieJqOpfDfE9cV6Xgm5WYVnvO0,7994
9
- pyglaze/devtools/__init__.py,sha256=9EW20idoaZv_5GuSgDmfpTPjfCZ-Rl27EV3oJebmwnQ,90
10
- pyglaze/devtools/mock_device.py,sha256=Fz0ZCRIslokBz6gFEztv1-V1UJhdTDqujl7-l8seX7U,14394
11
- pyglaze/devtools/thz_pulse.py,sha256=xp-T9psdOrUMtSUFu8HEwQJVu_aMixJdZHtg_BCVu_k,923
12
- pyglaze/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- pyglaze/helpers/types.py,sha256=p9xSAP5Trr1FcCWl7ynCWqDOUZKgMQYzMUXSwDpAKHg,599
14
- pyglaze/helpers/utilities.py,sha256=n_x9Tqm305MUorS29O6CoJM8Mi4apo2bsN_odrRaVAw,2423
15
- pyglaze/interpolation/__init__.py,sha256=WCxHPsiI7zvJykp-jfytoEbO4Tla-YIF6A7fjDfcDvU,72
16
- pyglaze/interpolation/interpolation.py,sha256=rQWzPD7W8TXETps7VZI0gcfAOCWO8pGL1HhhBnyxaMw,735
17
- pyglaze/scanning/__init__.py,sha256=uCBaeDTufOrC9KWf30ICqcmvFg_YT85olb3M9jkvZRg,99
18
- pyglaze/scanning/_asyncscanner.py,sha256=blnpdKBieSrUyTqmm1wZtBqp7X66WE-sFae_PpimFNU,5314
19
- pyglaze/scanning/_exceptions.py,sha256=vS28Dijj76jVuF6cSDBKqM9SQIa9rbIyUaHF-RA3PyM,213
20
- pyglaze/scanning/client.py,sha256=3qrQStkeLQzCeu4yMHJ_ENLGQ7E5GMc4CP9J55rk-ug,1817
21
- pyglaze/scanning/scanner.py,sha256=5KYVUboK4f8DWp-qRHWbvdtQ0x46AIdnCkquAO4vQOI,9205
22
- pyglaze-0.2.2.dist-info/LICENSE,sha256=LCP3sGBX7LxuQopcjeug1fW4tngWCHF4zB7QCgB28xM,1504
23
- pyglaze-0.2.2.dist-info/METADATA,sha256=fZcMCJSLAWa1ZrZDV6CPQ0jdGjhQLt5jBJ9srEgYjQM,3498
24
- pyglaze-0.2.2.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91
25
- pyglaze-0.2.2.dist-info/top_level.txt,sha256=X7d5rqVVuWNmtK4-Uh4sgOLlqye8vaHZOr5RYba0REo,8
26
- pyglaze-0.2.2.dist-info/RECORD,,
File without changes