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 ADDED
@@ -0,0 +1 @@
1
+ __version__ = "4.2.0"
@@ -0,0 +1,4 @@
1
+ from .pulse import Pulse
2
+ from .waveform import UnprocessedWaveform
3
+
4
+ __all__ = ["Pulse", "UnprocessedWaveform"]
@@ -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