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.
@@ -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)