pyPRB 1.0.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.
- pyPRB/__init__.py +69 -0
- pyPRB/__version__.py +5 -0
- pyPRB/core.py +486 -0
- pyPRB/processing/__init__.py +32 -0
- pyPRB/processing/_vibration_extractor.py +339 -0
- pyPRB/processing/fft_extraction.py +230 -0
- pyPRB/processing/iq_demodulation.py +263 -0
- pyPRB/processing/least_squares_fit.py +210 -0
- pyPRB/processing/synchronous_averaging.py +321 -0
- pyprb-1.0.0.dist-info/METADATA +215 -0
- pyprb-1.0.0.dist-info/RECORD +13 -0
- pyprb-1.0.0.dist-info/WHEEL +4 -0
- pyprb-1.0.0.dist-info/licenses/License +21 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""VibrationExtractor class and rotation frequency detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from ..core import VibrationVector
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class ExtractionMethod(Protocol):
|
|
14
|
+
"""Protocol for vibration extraction methods.
|
|
15
|
+
|
|
16
|
+
Any object with an ``extract`` method matching this signature can be
|
|
17
|
+
used as the *method* argument to :class:`VibrationExtractor`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def extract(
|
|
21
|
+
self,
|
|
22
|
+
signal: np.ndarray,
|
|
23
|
+
trigger: np.ndarray,
|
|
24
|
+
sample_rate: float,
|
|
25
|
+
rotation_freq: float,
|
|
26
|
+
) -> tuple[float, float]:
|
|
27
|
+
"""Extract the fundamental (1x) vibration component.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
signal : np.ndarray
|
|
32
|
+
Response signal (acceleration, velocity, displacement, …).
|
|
33
|
+
trigger : np.ndarray
|
|
34
|
+
Trigger signal from a photoelectric probe or similar sensor
|
|
35
|
+
that produces one pulse per revolution.
|
|
36
|
+
sample_rate : float
|
|
37
|
+
Sampling rate in Hz.
|
|
38
|
+
rotation_freq : float
|
|
39
|
+
Rotation frequency in Hz.
|
|
40
|
+
|
|
41
|
+
Returns
|
|
42
|
+
-------
|
|
43
|
+
amplitude : float
|
|
44
|
+
Peak amplitude of the fundamental (1×) component.
|
|
45
|
+
phase_deg : float
|
|
46
|
+
Phase of the fundamental component in degrees.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_WINDOWS: dict[str, np.ufunc] = {
|
|
52
|
+
"hann": np.hanning,
|
|
53
|
+
"hamming": np.hamming,
|
|
54
|
+
"bartlett": np.bartlett,
|
|
55
|
+
"blackman": np.blackman,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _rfft(
|
|
60
|
+
signal: np.ndarray,
|
|
61
|
+
sample_rate: float,
|
|
62
|
+
nfft_mult: int = 1,
|
|
63
|
+
remove_dc: bool = False,
|
|
64
|
+
window: str | None = None,
|
|
65
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
66
|
+
"""Compute a scaled one-sided FFT of *signal*.
|
|
67
|
+
|
|
68
|
+
The result is scaled so that ``|H[k]|`` equals the peak amplitude
|
|
69
|
+
of the corresponding sinusoidal component.
|
|
70
|
+
|
|
71
|
+
Parameters
|
|
72
|
+
----------
|
|
73
|
+
signal : np.ndarray
|
|
74
|
+
Input time series.
|
|
75
|
+
sample_rate : float
|
|
76
|
+
Sampling rate in Hz.
|
|
77
|
+
nfft_mult : int
|
|
78
|
+
Zero-padding multiplier. The FFT length is
|
|
79
|
+
``len(signal) * nfft_mult``.
|
|
80
|
+
remove_dc : bool
|
|
81
|
+
If ``True``, subtract the mean of the signal before computing
|
|
82
|
+
the FFT to suppress the DC component.
|
|
83
|
+
window : str or None
|
|
84
|
+
Window function to apply before the FFT. Supported values are
|
|
85
|
+
``'hann'``, ``'hamming'``, ``'bartlett'``, and ``'blackman'``.
|
|
86
|
+
``None`` (default) applies no window. The amplitude scaling is
|
|
87
|
+
compensated for the coherent gain of the window so that
|
|
88
|
+
``|H[k]|`` still returns the correct peak amplitude.
|
|
89
|
+
|
|
90
|
+
Returns
|
|
91
|
+
-------
|
|
92
|
+
freq : np.ndarray
|
|
93
|
+
Frequency axis in Hz.
|
|
94
|
+
H : np.ndarray
|
|
95
|
+
Complex spectrum, scaled to peak amplitude.
|
|
96
|
+
"""
|
|
97
|
+
if not isinstance(nfft_mult, int) or nfft_mult < 1:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"nfft_mult must be a positive integer, got {nfft_mult!r}."
|
|
100
|
+
)
|
|
101
|
+
if remove_dc:
|
|
102
|
+
signal = signal - np.mean(signal)
|
|
103
|
+
|
|
104
|
+
M = len(signal)
|
|
105
|
+
coherent_gain = 1.0
|
|
106
|
+
if window is not None:
|
|
107
|
+
if window not in _WINDOWS:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"Unknown window {window!r}. "
|
|
110
|
+
f"Supported windows: {sorted(_WINDOWS)}."
|
|
111
|
+
)
|
|
112
|
+
w = _WINDOWS[window](M)
|
|
113
|
+
signal = signal * w
|
|
114
|
+
coherent_gain = float(np.mean(w))
|
|
115
|
+
|
|
116
|
+
N = M * nfft_mult
|
|
117
|
+
H = np.fft.rfft(signal, n=N) * 2 / N * nfft_mult / coherent_gain
|
|
118
|
+
freq = np.fft.rfftfreq(N, d=1 / sample_rate)
|
|
119
|
+
return freq, H
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _import_matplotlib():
|
|
123
|
+
"""Import matplotlib or raise a helpful error."""
|
|
124
|
+
try:
|
|
125
|
+
import matplotlib.pyplot as plt
|
|
126
|
+
return plt
|
|
127
|
+
except ImportError:
|
|
128
|
+
raise ImportError(
|
|
129
|
+
"matplotlib is required for plotting. "
|
|
130
|
+
"Install with: pip install pyPRB[plot]"
|
|
131
|
+
) from None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def detect_rotation_freq(
|
|
135
|
+
trigger: np.ndarray,
|
|
136
|
+
sample_rate: float,
|
|
137
|
+
max_freq: float | None = None,
|
|
138
|
+
nfft_mult: int = 1,
|
|
139
|
+
plot: bool = False,
|
|
140
|
+
) -> float:
|
|
141
|
+
"""Detect the rotation frequency from a trigger signal.
|
|
142
|
+
|
|
143
|
+
Computes a zero-padded FFT of *trigger* and returns the frequency
|
|
144
|
+
of the highest peak.
|
|
145
|
+
|
|
146
|
+
Parameters
|
|
147
|
+
----------
|
|
148
|
+
trigger : np.ndarray
|
|
149
|
+
Trigger signal from a photoelectric probe or similar sensor.
|
|
150
|
+
sample_rate : float
|
|
151
|
+
Sampling rate in Hz.
|
|
152
|
+
max_freq : float, optional
|
|
153
|
+
Upper frequency limit for the search in Hz. If ``None``
|
|
154
|
+
(default), the entire spectrum is searched.
|
|
155
|
+
nfft_mult : int
|
|
156
|
+
Zero-padding multiplier for the FFT. Higher values give finer
|
|
157
|
+
frequency resolution. Default is 1 (no zero-padding).
|
|
158
|
+
plot : bool
|
|
159
|
+
If ``True``, show a diagnostic figure with the frequency
|
|
160
|
+
spectrum and a time-domain overlay of the detected sinusoid.
|
|
161
|
+
Requires matplotlib.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
float
|
|
166
|
+
Detected rotation frequency in Hz.
|
|
167
|
+
|
|
168
|
+
Raises
|
|
169
|
+
------
|
|
170
|
+
ValueError
|
|
171
|
+
If no valid peak is found.
|
|
172
|
+
|
|
173
|
+
Examples
|
|
174
|
+
--------
|
|
175
|
+
>>> f0 = detect_rotation_freq(trigger_signal, sample_rate=10000)
|
|
176
|
+
>>> f0 = detect_rotation_freq(trigger_signal, sample_rate=10000,
|
|
177
|
+
... max_freq=50.0, nfft_mult=256)
|
|
178
|
+
"""
|
|
179
|
+
freq, H = _rfft(trigger, sample_rate, nfft_mult=nfft_mult, remove_dc=True)
|
|
180
|
+
|
|
181
|
+
mask = freq > 0
|
|
182
|
+
if max_freq is not None:
|
|
183
|
+
mask = mask & (freq <= max_freq)
|
|
184
|
+
|
|
185
|
+
if not np.any(mask):
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"No frequency bins found in the search range. "
|
|
188
|
+
"Check sample_rate and max_freq."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
magnitudes = np.abs(H[mask])
|
|
192
|
+
peak_idx = np.argmax(magnitudes)
|
|
193
|
+
f0 = float(freq[mask][peak_idx])
|
|
194
|
+
|
|
195
|
+
if plot:
|
|
196
|
+
peak_bin = H[mask][peak_idx]
|
|
197
|
+
_plot_rotation_freq(trigger, sample_rate, freq[mask], magnitudes,
|
|
198
|
+
f0, peak_bin)
|
|
199
|
+
|
|
200
|
+
return f0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _plot_rotation_freq(
|
|
204
|
+
trigger: np.ndarray,
|
|
205
|
+
sample_rate: float,
|
|
206
|
+
freq: np.ndarray,
|
|
207
|
+
magnitudes: np.ndarray,
|
|
208
|
+
f0: float,
|
|
209
|
+
peak_bin: complex,
|
|
210
|
+
n_periods: int = 5,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Plot frequency spectrum and time-domain overlay for rotation detection.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
trigger : np.ndarray
|
|
217
|
+
Raw trigger signal.
|
|
218
|
+
sample_rate : float
|
|
219
|
+
Sampling rate in Hz.
|
|
220
|
+
freq : np.ndarray
|
|
221
|
+
Frequency axis (masked region).
|
|
222
|
+
magnitudes : np.ndarray
|
|
223
|
+
FFT magnitudes (masked region).
|
|
224
|
+
f0 : float
|
|
225
|
+
Detected rotation frequency in Hz.
|
|
226
|
+
peak_bin : complex
|
|
227
|
+
Complex FFT value at the detected peak.
|
|
228
|
+
n_periods : int
|
|
229
|
+
Number of periods to show in the time-domain plot.
|
|
230
|
+
"""
|
|
231
|
+
plt = _import_matplotlib()
|
|
232
|
+
|
|
233
|
+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 6))
|
|
234
|
+
|
|
235
|
+
# -- Frequency domain --
|
|
236
|
+
ax1.plot(freq, magnitudes, color="steelblue")
|
|
237
|
+
ax1.axvline(f0, color="red", linestyle="--",
|
|
238
|
+
label=f"Detected: {f0:.2f} Hz")
|
|
239
|
+
ax1.set_xlabel("Frequency (Hz)")
|
|
240
|
+
ax1.set_ylabel("Amplitude")
|
|
241
|
+
ax1.set_title("Trigger signal spectrum")
|
|
242
|
+
ax1.legend()
|
|
243
|
+
|
|
244
|
+
# -- Time domain --
|
|
245
|
+
t_end = n_periods / f0
|
|
246
|
+
N = int(sample_rate * t_end)
|
|
247
|
+
N = min(N, len(trigger))
|
|
248
|
+
time = np.arange(N) / sample_rate
|
|
249
|
+
|
|
250
|
+
amplitude = float(np.abs(peak_bin))
|
|
251
|
+
phase_rad = float(np.angle(peak_bin))
|
|
252
|
+
reconstructed = amplitude * np.cos(2 * np.pi * f0 * time + phase_rad)
|
|
253
|
+
|
|
254
|
+
ax2.plot(time, trigger[:N], color="steelblue", label="Trigger")
|
|
255
|
+
ax2_twin = ax2.twinx()
|
|
256
|
+
ax2_twin.plot(time, reconstructed, color="red", linestyle="--",
|
|
257
|
+
label=f"Reconstructed {f0:.2f} Hz")
|
|
258
|
+
ax2.set_xlabel("Time (s)")
|
|
259
|
+
ax2.set_ylabel("Trigger amplitude")
|
|
260
|
+
ax2_twin.set_ylabel("Reconstructed amplitude")
|
|
261
|
+
ax2.set_title(f"Time domain — first {n_periods} periods")
|
|
262
|
+
ax2_twin.legend(loc="upper right")
|
|
263
|
+
ax2.legend(loc="upper left")
|
|
264
|
+
|
|
265
|
+
plt.tight_layout()
|
|
266
|
+
plt.show(block=True)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class VibrationExtractor:
|
|
270
|
+
"""Extracts vibration vectors from time-domain signals.
|
|
271
|
+
|
|
272
|
+
Orchestrates the signal processing pipeline that converts raw numpy
|
|
273
|
+
arrays (response signal + trigger signal) into
|
|
274
|
+
:class:`~pyPRB.core.VibrationVector` objects suitable for use with
|
|
275
|
+
:class:`~pyPRB.core.StaticBalancing` or
|
|
276
|
+
:class:`~pyPRB.core.DynamicBalancing`.
|
|
277
|
+
|
|
278
|
+
Parameters
|
|
279
|
+
----------
|
|
280
|
+
sample_rate : float
|
|
281
|
+
Sampling rate of the signals in Hz.
|
|
282
|
+
rotation_freq : float
|
|
283
|
+
Rotation frequency in Hz. Use :func:`detect_rotation_freq` if
|
|
284
|
+
unknown.
|
|
285
|
+
method : ExtractionMethod, optional
|
|
286
|
+
Extraction method to use. Defaults to
|
|
287
|
+
:class:`IQDemodulation` with default settings.
|
|
288
|
+
|
|
289
|
+
Examples
|
|
290
|
+
--------
|
|
291
|
+
>>> f0 = detect_rotation_freq(trigger_signal, sample_rate=10000)
|
|
292
|
+
>>> extractor = VibrationExtractor(sample_rate=10000, rotation_freq=f0)
|
|
293
|
+
>>> V_0 = extractor.get_vibration_vector(vibration_signal, trigger_signal)
|
|
294
|
+
"""
|
|
295
|
+
|
|
296
|
+
def __init__(
|
|
297
|
+
self,
|
|
298
|
+
sample_rate: float,
|
|
299
|
+
rotation_freq: float,
|
|
300
|
+
method: ExtractionMethod | None = None,
|
|
301
|
+
):
|
|
302
|
+
if sample_rate <= 0:
|
|
303
|
+
raise ValueError(f"sample_rate must be positive, got {sample_rate}.")
|
|
304
|
+
if rotation_freq <= 0:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
f"rotation_freq must be positive, got {rotation_freq}."
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
self.sample_rate = float(sample_rate)
|
|
310
|
+
self.rotation_freq = float(rotation_freq)
|
|
311
|
+
if method is None:
|
|
312
|
+
from .iq_demodulation import IQDemodulation
|
|
313
|
+
method = IQDemodulation()
|
|
314
|
+
self.method = method
|
|
315
|
+
|
|
316
|
+
def get_vibration_vector(
|
|
317
|
+
self,
|
|
318
|
+
signal: np.ndarray,
|
|
319
|
+
trigger: np.ndarray,
|
|
320
|
+
) -> VibrationVector:
|
|
321
|
+
"""Extract the fundamental vibration vector from a measurement.
|
|
322
|
+
|
|
323
|
+
Parameters
|
|
324
|
+
----------
|
|
325
|
+
signal : np.ndarray
|
|
326
|
+
Response signal (acceleration, velocity, displacement, …).
|
|
327
|
+
trigger : np.ndarray
|
|
328
|
+
Trigger signal from e.g. a photoelectric probe.
|
|
329
|
+
|
|
330
|
+
Returns
|
|
331
|
+
-------
|
|
332
|
+
VibrationVector
|
|
333
|
+
Vibration vector with amplitude and phase of the fundamental
|
|
334
|
+
(once-per-revolution) component.
|
|
335
|
+
"""
|
|
336
|
+
amplitude, phase_deg = self.method.extract(
|
|
337
|
+
signal, trigger, self.sample_rate, self.rotation_freq
|
|
338
|
+
)
|
|
339
|
+
return VibrationVector(amplitude=amplitude, phase=phase_deg)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""FFT-based vibration extraction method.
|
|
2
|
+
|
|
3
|
+
Extracts the vibration component at the rotation frequency by computing
|
|
4
|
+
the FFT of both the response and trigger signals, then deriving
|
|
5
|
+
relative phase by subtracting the trigger phase from the signal phase.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from ._vibration_extractor import _rfft, _import_matplotlib, _WINDOWS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FFTExtraction:
|
|
16
|
+
"""Extracts the vibration component at the rotation frequency via FFT.
|
|
17
|
+
|
|
18
|
+
Computes the one-sided FFT of both the response signal and the
|
|
19
|
+
trigger signal, locates the spectral peak nearest to the expected
|
|
20
|
+
rotation frequency, and extracts amplitude and phase. The relative
|
|
21
|
+
phase is obtained by subtracting the trigger phase from the signal
|
|
22
|
+
phase at the detected peak.
|
|
23
|
+
|
|
24
|
+
A window function (controlled by *window*) is applied before the
|
|
25
|
+
FFT to reduce spectral leakage. Zero-padding (controlled by
|
|
26
|
+
*nfft_mult*) improves frequency resolution.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
nfft_mult : int
|
|
31
|
+
Zero-padding multiplier for the FFT. Higher values give finer
|
|
32
|
+
frequency resolution. Default is 4.
|
|
33
|
+
freq_tolerance : float
|
|
34
|
+
Relative tolerance for finding the rotation frequency peak.
|
|
35
|
+
The search band is
|
|
36
|
+
``[rotation_freq * (1 - freq_tolerance),
|
|
37
|
+
rotation_freq * (1 + freq_tolerance)]``.
|
|
38
|
+
Default is 0.1 (±10 %).
|
|
39
|
+
window : str
|
|
40
|
+
Window function to apply before the FFT. Supported values are
|
|
41
|
+
``'hann'`` (default), ``'hamming'``, ``'bartlett'``, and
|
|
42
|
+
``'blackman'``.
|
|
43
|
+
plotting : bool
|
|
44
|
+
If ``True``, show a diagnostic spectrum plot during each call
|
|
45
|
+
to :meth:`extract`. Requires matplotlib
|
|
46
|
+
(install with ``pip install pyPRB[plot]``).
|
|
47
|
+
|
|
48
|
+
Attributes
|
|
49
|
+
----------
|
|
50
|
+
freq_ : np.ndarray or None
|
|
51
|
+
Frequency axis from the last call to :meth:`extract`.
|
|
52
|
+
H_signal_ : np.ndarray or None
|
|
53
|
+
Complex signal spectrum from the last call to :meth:`extract`.
|
|
54
|
+
H_trigger_ : np.ndarray or None
|
|
55
|
+
Complex trigger spectrum from the last call to :meth:`extract`.
|
|
56
|
+
peak_idx_ : int or None
|
|
57
|
+
Index of the detected peak in the spectrum arrays.
|
|
58
|
+
|
|
59
|
+
Examples
|
|
60
|
+
--------
|
|
61
|
+
>>> from pyPRB.processing import FFTExtraction, VibrationExtractor
|
|
62
|
+
>>> method = FFTExtraction(nfft_mult=8, freq_tolerance=0.05)
|
|
63
|
+
>>> extractor = VibrationExtractor(sample_rate=10000, rotation_freq=25.0,
|
|
64
|
+
... method=method)
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
nfft_mult: int = 4,
|
|
70
|
+
freq_tolerance: float = 0.1,
|
|
71
|
+
window: str = "hann",
|
|
72
|
+
plotting: bool = False,
|
|
73
|
+
):
|
|
74
|
+
if not isinstance(nfft_mult, int) or nfft_mult < 1:
|
|
75
|
+
raise ValueError(
|
|
76
|
+
f"nfft_mult must be a positive integer, got {nfft_mult!r}."
|
|
77
|
+
)
|
|
78
|
+
if not 0 < freq_tolerance < 1:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"freq_tolerance must be between 0 and 1, "
|
|
81
|
+
f"got {freq_tolerance}."
|
|
82
|
+
)
|
|
83
|
+
if window not in _WINDOWS:
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"Unknown window {window!r}. "
|
|
86
|
+
f"Supported windows: {sorted(_WINDOWS)}."
|
|
87
|
+
)
|
|
88
|
+
self.nfft_mult = nfft_mult
|
|
89
|
+
self.freq_tolerance = freq_tolerance
|
|
90
|
+
self.window = window
|
|
91
|
+
self.plotting = plotting
|
|
92
|
+
self.freq_: np.ndarray | None = None
|
|
93
|
+
self.H_signal_: np.ndarray | None = None
|
|
94
|
+
self.H_trigger_: np.ndarray | None = None
|
|
95
|
+
self.peak_idx_: int | None = None
|
|
96
|
+
|
|
97
|
+
def extract(
|
|
98
|
+
self,
|
|
99
|
+
signal: np.ndarray,
|
|
100
|
+
trigger: np.ndarray,
|
|
101
|
+
sample_rate: float,
|
|
102
|
+
rotation_freq: float,
|
|
103
|
+
) -> tuple[float, float]:
|
|
104
|
+
"""Extract vibration amplitude and phase at the rotation frequency.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
signal : np.ndarray
|
|
109
|
+
Response signal (acceleration, velocity, …).
|
|
110
|
+
trigger : np.ndarray
|
|
111
|
+
Trigger signal from a photoelectric probe or similar sensor
|
|
112
|
+
that produces one pulse per revolution.
|
|
113
|
+
sample_rate : float
|
|
114
|
+
Sampling rate in Hz.
|
|
115
|
+
rotation_freq : float
|
|
116
|
+
Rotation frequency in Hz.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
amplitude : float
|
|
121
|
+
Peak amplitude at the rotation frequency.
|
|
122
|
+
phase_deg : float
|
|
123
|
+
Phase at the rotation frequency in degrees, relative to
|
|
124
|
+
the trigger signal.
|
|
125
|
+
"""
|
|
126
|
+
freq, H_sig = _rfft(
|
|
127
|
+
signal, sample_rate,
|
|
128
|
+
nfft_mult=self.nfft_mult, remove_dc=True, window=self.window,
|
|
129
|
+
)
|
|
130
|
+
_, H_trig = _rfft(
|
|
131
|
+
trigger, sample_rate,
|
|
132
|
+
nfft_mult=self.nfft_mult, remove_dc=True, window=self.window,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Find peak within tolerance band
|
|
136
|
+
f_lo = rotation_freq * (1 - self.freq_tolerance)
|
|
137
|
+
f_hi = rotation_freq * (1 + self.freq_tolerance)
|
|
138
|
+
mask = (freq >= f_lo) & (freq <= f_hi)
|
|
139
|
+
|
|
140
|
+
if not np.any(mask):
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"No frequency bins in the search band "
|
|
143
|
+
f"[{f_lo:.2f}, {f_hi:.2f}] Hz. "
|
|
144
|
+
f"Check rotation_freq ({rotation_freq:.2f} Hz) and "
|
|
145
|
+
f"freq_tolerance ({self.freq_tolerance})."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
global_indices = np.where(mask)[0]
|
|
149
|
+
idx_in_mask = np.argmax(np.abs(H_sig[mask]))
|
|
150
|
+
peak_idx = global_indices[idx_in_mask]
|
|
151
|
+
|
|
152
|
+
amplitude = float(np.abs(H_sig[peak_idx]))
|
|
153
|
+
sig_phase = np.angle(H_sig[peak_idx])
|
|
154
|
+
trig_phase = np.angle(H_trig[peak_idx])
|
|
155
|
+
phase_deg = float(np.degrees(sig_phase - trig_phase))
|
|
156
|
+
|
|
157
|
+
# Store diagnostics
|
|
158
|
+
self.freq_ = freq
|
|
159
|
+
self.H_signal_ = H_sig
|
|
160
|
+
self.H_trigger_ = H_trig
|
|
161
|
+
self.peak_idx_ = int(peak_idx)
|
|
162
|
+
|
|
163
|
+
if self.plotting:
|
|
164
|
+
self._plot_spectrum(
|
|
165
|
+
freq, H_sig, H_trig, peak_idx,
|
|
166
|
+
rotation_freq, f_lo, f_hi, amplitude, phase_deg,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
return amplitude, phase_deg
|
|
170
|
+
|
|
171
|
+
@staticmethod
|
|
172
|
+
def _plot_spectrum(
|
|
173
|
+
freq: np.ndarray,
|
|
174
|
+
H_sig: np.ndarray,
|
|
175
|
+
H_trig: np.ndarray,
|
|
176
|
+
peak_idx: int,
|
|
177
|
+
rotation_freq: float,
|
|
178
|
+
f_lo: float,
|
|
179
|
+
f_hi: float,
|
|
180
|
+
amplitude: float,
|
|
181
|
+
phase_deg: float,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Plot signal and trigger spectra with the detected peak."""
|
|
184
|
+
plt = _import_matplotlib()
|
|
185
|
+
|
|
186
|
+
sig_phase_deg = float(np.degrees(np.angle(H_sig[peak_idx])))
|
|
187
|
+
trig_phase_deg = float(np.degrees(np.angle(H_trig[peak_idx])))
|
|
188
|
+
peak_freq = freq[peak_idx]
|
|
189
|
+
|
|
190
|
+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(12, 6), sharex=True)
|
|
191
|
+
|
|
192
|
+
# -- Signal spectrum --
|
|
193
|
+
ax1.plot(freq, np.abs(H_sig), color="steelblue")
|
|
194
|
+
ax1.axvspan(f_lo, f_hi, alpha=0.15, color="orange",
|
|
195
|
+
label="Search band")
|
|
196
|
+
ax1.plot(peak_freq, np.abs(H_sig[peak_idx]), "ro",
|
|
197
|
+
label=f"Peak @ {peak_freq:.2f} Hz")
|
|
198
|
+
ax1.set_ylabel("Amplitude")
|
|
199
|
+
ax1.set_title(
|
|
200
|
+
f"Signal spectrum \u2014 "
|
|
201
|
+
f"amplitude = {amplitude:.4g}, "
|
|
202
|
+
f"phase = {sig_phase_deg:.1f}\u00b0"
|
|
203
|
+
)
|
|
204
|
+
ax1.legend()
|
|
205
|
+
|
|
206
|
+
# -- Trigger spectrum --
|
|
207
|
+
ax2.plot(freq, np.abs(H_trig), color="steelblue")
|
|
208
|
+
ax2.axvspan(f_lo, f_hi, alpha=0.15, color="orange",
|
|
209
|
+
label="Search band")
|
|
210
|
+
ax2.plot(peak_freq, np.abs(H_trig[peak_idx]), "ro",
|
|
211
|
+
label=f"Peak @ {peak_freq:.2f} Hz")
|
|
212
|
+
ax2.set_xlabel("Frequency (Hz)")
|
|
213
|
+
ax2.set_ylabel("Amplitude")
|
|
214
|
+
ax2.set_title(
|
|
215
|
+
f"Trigger spectrum \u2014 "
|
|
216
|
+
f"phase = {trig_phase_deg:.1f}\u00b0"
|
|
217
|
+
)
|
|
218
|
+
ax2.legend()
|
|
219
|
+
|
|
220
|
+
# Limit x-axis to 2x rotation_freq above and below the peak
|
|
221
|
+
ax2.set_xlim(max(0, rotation_freq - 2 * rotation_freq),
|
|
222
|
+
rotation_freq + 2 * rotation_freq) # [0, 3·f0]
|
|
223
|
+
|
|
224
|
+
fig.suptitle(
|
|
225
|
+
f"FFT extraction \u2014 "
|
|
226
|
+
f"relative phase = {phase_deg:.1f}\u00b0",
|
|
227
|
+
fontweight="bold",
|
|
228
|
+
)
|
|
229
|
+
plt.tight_layout()
|
|
230
|
+
plt.show(block=True)
|