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,263 @@
1
+ """IQ demodulation (lock-in amplification) extraction method.
2
+
3
+ Extracts the vibration component at the rotation frequency by
4
+ multiplying the signal with in-phase (cosine) and quadrature (sine)
5
+ references at the target frequency and averaging. Everything not at
6
+ the target frequency averages to zero, yielding a narrow-band estimate
7
+ of amplitude and phase.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import numpy as np
13
+
14
+ from ._vibration_extractor import _import_matplotlib
15
+
16
+
17
+ class IQDemodulation:
18
+ """Extracts the vibration component at the rotation frequency via IQ demodulation.
19
+
20
+ The signal is multiplied by cosine (in-phase) and sine (quadrature)
21
+ references at the rotation frequency and averaged over the
22
+ measurement window. The averaging acts as an extremely narrow
23
+ bandpass filter centred on the target frequency (bandwidth ≈ 1/T,
24
+ where T is the averaging time).
25
+
26
+ The relative phase is obtained by performing the same demodulation
27
+ on the trigger signal and subtracting the trigger phase from the
28
+ signal phase.
29
+
30
+ Parameters
31
+ ----------
32
+ remove_dc : bool
33
+ If ``True`` (default), subtract the mean of both signal and
34
+ trigger before demodulation. This prevents a DC offset from
35
+ leaking into the IQ estimate when the averaging window does not
36
+ span an exact integer number of periods.
37
+ trim_periods : bool
38
+ If ``True`` (default), trim the signal to the nearest integer
39
+ number of complete periods before demodulation. This
40
+ eliminates spectral leakage from partial periods and ensures
41
+ that the in-phase and quadrature averages are unbiased.
42
+ plotting : bool
43
+ If ``True``, show a diagnostic figure during each call to
44
+ :meth:`extract`. Requires matplotlib
45
+ (install with ``pip install pyPRB[plot]``).
46
+
47
+ Attributes
48
+ ----------
49
+ I_signal_ : float or None
50
+ In-phase component of the signal demodulation from the last
51
+ call to :meth:`extract`.
52
+ Q_signal_ : float or None
53
+ Quadrature component of the signal demodulation from the last
54
+ call to :meth:`extract`.
55
+ I_trigger_ : float or None
56
+ In-phase component of the trigger demodulation from the last
57
+ call to :meth:`extract`.
58
+ Q_trigger_ : float or None
59
+ Quadrature component of the trigger demodulation from the last
60
+ call to :meth:`extract`.
61
+ n_samples_used_ : int or None
62
+ Number of samples used after optional trimming from the last
63
+ call to :meth:`extract`.
64
+
65
+ Examples
66
+ --------
67
+ >>> from pyPRB.processing import IQDemodulation, VibrationExtractor
68
+ >>> method = IQDemodulation(remove_dc=True, trim_periods=True)
69
+ >>> extractor = VibrationExtractor(sample_rate=10000, rotation_freq=25.0,
70
+ ... method=method)
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ remove_dc: bool = True,
76
+ trim_periods: bool = True,
77
+ plotting: bool = False,
78
+ ):
79
+ self.remove_dc = remove_dc
80
+ self.trim_periods = trim_periods
81
+ self.plotting = plotting
82
+
83
+ # Diagnostic attributes (None until extract() is called)
84
+ self.I_signal_: float | None = None
85
+ self.Q_signal_: float | None = None
86
+ self.I_trigger_: float | None = None
87
+ self.Q_trigger_: float | None = None
88
+ self.n_samples_used_: int | None = None
89
+
90
+ def extract(
91
+ self,
92
+ signal: np.ndarray,
93
+ trigger: np.ndarray,
94
+ sample_rate: float,
95
+ rotation_freq: float,
96
+ ) -> tuple[float, float]:
97
+ """Extract vibration amplitude and phase at the rotation frequency.
98
+
99
+ Parameters
100
+ ----------
101
+ signal : np.ndarray
102
+ Response signal (acceleration, velocity, …).
103
+ trigger : np.ndarray
104
+ Trigger signal from a photoelectric probe or similar sensor
105
+ that produces one pulse per revolution.
106
+ sample_rate : float
107
+ Sampling rate in Hz.
108
+ rotation_freq : float
109
+ Rotation frequency in Hz.
110
+
111
+ Returns
112
+ -------
113
+ amplitude : float
114
+ Peak amplitude at the rotation frequency.
115
+ phase_deg : float
116
+ Phase at the rotation frequency in degrees, relative to
117
+ the trigger signal.
118
+
119
+ Raises
120
+ ------
121
+ ValueError
122
+ If *trim_periods* is ``True`` and the signal is shorter
123
+ than one complete period.
124
+ """
125
+ samples_per_period = sample_rate / rotation_freq
126
+ n = len(signal)
127
+
128
+ # Trim to integer number of periods
129
+ if self.trim_periods:
130
+ n_periods = int(n / samples_per_period)
131
+ if n_periods < 1:
132
+ raise ValueError(
133
+ f"Signal is shorter than one period at {rotation_freq} Hz "
134
+ f"({n} samples < {samples_per_period:.0f} samples/period). "
135
+ f"Provide a longer signal or set trim_periods=False."
136
+ )
137
+ n = int(round(n_periods * samples_per_period))
138
+
139
+ sig = signal[:n].copy()
140
+ trig = trigger[:n].copy()
141
+
142
+ # Remove DC
143
+ if self.remove_dc:
144
+ sig -= np.mean(sig)
145
+ trig -= np.mean(trig)
146
+
147
+ # Reference sinusoids
148
+ t = np.arange(n) / sample_rate
149
+ cos_ref = np.cos(2 * np.pi * rotation_freq * t)
150
+ sin_ref = np.sin(2 * np.pi * rotation_freq * t)
151
+
152
+ # IQ demodulation — signal
153
+ # For x(t) = A·cos(ωt + φ):
154
+ # mean(x·cos(ωt)) = A/2·cos(φ)
155
+ # mean(x·sin(ωt)) = -A/2·sin(φ)
156
+ # Negate Q so that atan2(Q, I) = atan2(sin(φ), cos(φ)) = φ.
157
+ I_sig = float(np.mean(sig * cos_ref))
158
+ Q_sig = float(-np.mean(sig * sin_ref))
159
+ amplitude = 2.0 * np.sqrt(I_sig**2 + Q_sig**2)
160
+ sig_phase = np.arctan2(Q_sig, I_sig)
161
+
162
+ # IQ demodulation — trigger
163
+ I_trig = float(np.mean(trig * cos_ref))
164
+ Q_trig = float(-np.mean(trig * sin_ref))
165
+ trig_phase = np.arctan2(Q_trig, I_trig)
166
+
167
+ # Relative phase
168
+ phase_deg = float(np.degrees(sig_phase - trig_phase))
169
+
170
+ # Store diagnostics
171
+ self.I_signal_ = I_sig
172
+ self.Q_signal_ = Q_sig
173
+ self.I_trigger_ = I_trig
174
+ self.Q_trigger_ = Q_trig
175
+ self.n_samples_used_ = n
176
+
177
+ if self.plotting:
178
+ self._plot_demodulation(
179
+ signal[:n], t, amplitude, phase_deg,
180
+ sig_phase, trig_phase, rotation_freq,
181
+ I_sig, Q_sig, I_trig, Q_trig,
182
+ )
183
+
184
+ return float(amplitude), phase_deg
185
+
186
+ @staticmethod
187
+ def _plot_demodulation(
188
+ signal: np.ndarray,
189
+ t: np.ndarray,
190
+ amplitude: float,
191
+ phase_deg: float,
192
+ sig_phase: float,
193
+ trig_phase: float,
194
+ rotation_freq: float,
195
+ I_sig: float,
196
+ Q_sig: float,
197
+ I_trig: float,
198
+ Q_trig: float,
199
+ n_periods: int = 5,
200
+ ) -> None:
201
+ """Plot time-domain overlay and phasor diagram."""
202
+ plt = _import_matplotlib()
203
+
204
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
205
+
206
+ # -- Left: time-domain overlay (first n_periods) --
207
+ t_end = n_periods / rotation_freq
208
+ n_show = min(len(t), int(np.searchsorted(t, t_end)))
209
+ if n_show == 0:
210
+ n_show = len(t)
211
+
212
+ ax1.plot(t[:n_show], signal[:n_show], color="steelblue",
213
+ label="Signal")
214
+ reconstructed = amplitude * np.cos(
215
+ 2 * np.pi * rotation_freq * t[:n_show] + sig_phase
216
+ )
217
+ # Wrap phase for display: [0, 360) then shift >180 to negative
218
+ display_phase = phase_deg % 360
219
+ if display_phase > 180:
220
+ display_phase -= 360
221
+
222
+ ax1.plot(t[:n_show], reconstructed, color="red", linestyle="--",
223
+ label=f"IQ result: {amplitude:.4g} @ {display_phase:.1f}\u00b0")
224
+ ax1.set_xlabel("Time (s)")
225
+ ax1.set_ylabel("Amplitude")
226
+ ax1.set_title(f"Time domain \u2014 first {n_periods} periods")
227
+ ax1.legend()
228
+
229
+ # -- Right: phasor diagram --
230
+ # Normalise both phasors to unit length (only phase matters here)
231
+ sig_mag = np.sqrt(I_sig**2 + Q_sig**2)
232
+ trig_mag = np.sqrt(I_trig**2 + Q_trig**2)
233
+
234
+ sig_I = I_sig / max(sig_mag, 1e-12)
235
+ sig_Q = Q_sig / max(sig_mag, 1e-12)
236
+ trig_I = I_trig / max(trig_mag, 1e-12)
237
+ trig_Q = Q_trig / max(trig_mag, 1e-12)
238
+
239
+ ax2.annotate(
240
+ "", xy=(sig_I, sig_Q), xytext=(0, 0),
241
+ arrowprops=dict(arrowstyle="->", color="steelblue", lw=2),
242
+ )
243
+ ax2.annotate(
244
+ "", xy=(trig_I, trig_Q), xytext=(0, 0),
245
+ arrowprops=dict(arrowstyle="->", color="red", lw=2),
246
+ )
247
+ ax2.plot(0, 0, color="steelblue", label="Signal phasor")
248
+ ax2.plot(0, 0, color="red", label="Trigger phasor")
249
+
250
+ ax2.set_xlim(-1.3, 1.3)
251
+ ax2.set_ylim(-1.3, 1.3)
252
+ ax2.set_aspect("equal")
253
+ ax2.axhline(0, color="gray", linewidth=0.5)
254
+ ax2.axvline(0, color="gray", linewidth=0.5)
255
+ ax2.set_xlabel("In-phase (I)")
256
+ ax2.set_ylabel("Quadrature (Q)")
257
+ ax2.set_title(f"Phasor diagram \u2014 "
258
+ f"relative phase = {display_phase:.1f}\u00b0\n"
259
+ f"(phasors normalized to unit length)")
260
+ ax2.legend()
261
+
262
+ plt.tight_layout()
263
+ plt.show(block=True)
@@ -0,0 +1,210 @@
1
+ """Least-squares sine fit extraction method.
2
+
3
+ Extracts the vibration component at the rotation frequency by fitting
4
+ a cosine–sine model to the signal via linear least squares.
5
+ Statistically optimal under Gaussian noise and handles non-integer
6
+ periods without spectral leakage.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+ from ._vibration_extractor import _import_matplotlib
14
+
15
+
16
+ class LeastSquaresFit:
17
+ """Extracts the vibration component at the rotation frequency via least-squares fit.
18
+
19
+ Fits the model ``A·cos(ωt) + B·sin(ωt) [+ C]`` to the signal using
20
+ :func:`numpy.linalg.lstsq`. The amplitude and phase are derived from
21
+ the fitted coefficients. Unlike FFT- or IQ-based methods, the
22
+ least-squares approach handles non-integer periods correctly —
23
+ there is no spectral leakage even when the measurement window does
24
+ not span an exact number of revolutions.
25
+
26
+ The relative phase is obtained by performing the same fit on the
27
+ trigger signal and subtracting the trigger phase from the signal
28
+ phase.
29
+
30
+ Parameters
31
+ ----------
32
+ fit_dc : bool
33
+ If ``True`` (default), include a constant (DC) term in the
34
+ model: ``A·cos(ωt) + B·sin(ωt) + C``. This prevents a DC
35
+ offset in the signal from biasing the amplitude estimate.
36
+ plotting : bool
37
+ If ``True``, show a diagnostic figure during each call to
38
+ :meth:`extract`. Requires matplotlib
39
+ (install with ``pip install pyPRB[plot]``).
40
+
41
+ Attributes
42
+ ----------
43
+ A_signal_ : float or None
44
+ Cosine coefficient of the signal fit from the last call to
45
+ :meth:`extract`.
46
+ B_signal_ : float or None
47
+ Sine coefficient of the signal fit from the last call to
48
+ :meth:`extract`.
49
+ A_trigger_ : float or None
50
+ Cosine coefficient of the trigger fit from the last call to
51
+ :meth:`extract`.
52
+ B_trigger_ : float or None
53
+ Sine coefficient of the trigger fit from the last call to
54
+ :meth:`extract`.
55
+ dc_signal_ : float or None
56
+ Fitted DC offset of the signal (``None`` if *fit_dc* is
57
+ ``False``).
58
+ residuals_ : np.ndarray or None
59
+ Residual vector ``signal − X @ coeffs`` from the last call to
60
+ :meth:`extract`.
61
+
62
+ Examples
63
+ --------
64
+ >>> from pyPRB.processing import LeastSquaresFit, VibrationExtractor
65
+ >>> method = LeastSquaresFit(fit_dc=True)
66
+ >>> extractor = VibrationExtractor(sample_rate=10000, rotation_freq=25.0,
67
+ ... method=method)
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ fit_dc: bool = True,
73
+ plotting: bool = False,
74
+ ):
75
+ self.fit_dc = fit_dc
76
+ self.plotting = plotting
77
+
78
+ # Diagnostic attributes (None until extract() is called)
79
+ self.A_signal_: float | None = None
80
+ self.B_signal_: float | None = None
81
+ self.A_trigger_: float | None = None
82
+ self.B_trigger_: float | None = None
83
+ self.dc_signal_: float | None = None
84
+ self.residuals_: np.ndarray | None = None
85
+
86
+ def extract(
87
+ self,
88
+ signal: np.ndarray,
89
+ trigger: np.ndarray,
90
+ sample_rate: float,
91
+ rotation_freq: float,
92
+ ) -> tuple[float, float]:
93
+ """Extract vibration amplitude and phase at the rotation frequency.
94
+
95
+ Parameters
96
+ ----------
97
+ signal : np.ndarray
98
+ Response signal (acceleration, velocity, …).
99
+ trigger : np.ndarray
100
+ Trigger signal from a photoelectric probe or similar sensor
101
+ that produces one pulse per revolution.
102
+ sample_rate : float
103
+ Sampling rate in Hz.
104
+ rotation_freq : float
105
+ Rotation frequency in Hz.
106
+
107
+ Returns
108
+ -------
109
+ amplitude : float
110
+ Peak amplitude at the rotation frequency.
111
+ phase_deg : float
112
+ Phase at the rotation frequency in degrees, relative to
113
+ the trigger signal.
114
+ """
115
+ n = len(signal)
116
+ t = np.arange(n) / sample_rate
117
+ omega_t = 2 * np.pi * rotation_freq * t
118
+
119
+ # Design matrix
120
+ cos_col = np.cos(omega_t)
121
+ sin_col = np.sin(omega_t)
122
+ if self.fit_dc:
123
+ X = np.column_stack([cos_col, sin_col, np.ones(n)])
124
+ else:
125
+ X = np.column_stack([cos_col, sin_col])
126
+
127
+ # Fit signal
128
+ coeffs_sig, _, _, _ = np.linalg.lstsq(X, signal, rcond=None)
129
+ A_sig = float(coeffs_sig[0])
130
+ B_sig = float(coeffs_sig[1])
131
+
132
+ # For x(t) = A_peak·cos(ωt + φ) = A_peak·cos(φ)·cos(ωt) - A_peak·sin(φ)·sin(ωt)
133
+ # So: A_sig = A_peak·cos(φ), B_sig = -A_peak·sin(φ)
134
+ # amplitude = sqrt(A² + B²), phase = atan2(-B, A)
135
+ amplitude = float(np.sqrt(A_sig**2 + B_sig**2))
136
+ sig_phase = np.arctan2(-B_sig, A_sig)
137
+
138
+ # Fit trigger
139
+ coeffs_trig, _, _, _ = np.linalg.lstsq(X, trigger, rcond=None)
140
+ A_trig = float(coeffs_trig[0])
141
+ B_trig = float(coeffs_trig[1])
142
+ trig_phase = np.arctan2(-B_trig, A_trig)
143
+
144
+ # Relative phase
145
+ phase_deg = float(np.degrees(sig_phase - trig_phase))
146
+
147
+ # Store diagnostics
148
+ self.A_signal_ = A_sig
149
+ self.B_signal_ = B_sig
150
+ self.A_trigger_ = A_trig
151
+ self.B_trigger_ = B_trig
152
+ self.dc_signal_ = float(coeffs_sig[2]) if self.fit_dc else None
153
+ self.residuals_ = signal - X @ coeffs_sig
154
+
155
+ if self.plotting:
156
+ self._plot_fit(
157
+ signal, t, amplitude, phase_deg, sig_phase,
158
+ rotation_freq, self.residuals_,
159
+ )
160
+
161
+ return amplitude, phase_deg
162
+
163
+ @staticmethod
164
+ def _plot_fit(
165
+ signal: np.ndarray,
166
+ t: np.ndarray,
167
+ amplitude: float,
168
+ phase_deg: float,
169
+ sig_phase: float,
170
+ rotation_freq: float,
171
+ residuals: np.ndarray,
172
+ n_periods: int = 5,
173
+ ) -> None:
174
+ """Plot time-domain overlay and residuals."""
175
+ plt = _import_matplotlib()
176
+
177
+ # Wrap phase for display
178
+ display_phase = phase_deg % 360
179
+ if display_phase > 180:
180
+ display_phase -= 360
181
+
182
+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
183
+
184
+ # -- Left: time-domain overlay (first n_periods) --
185
+ t_end = n_periods / rotation_freq
186
+ n_show = min(len(t), int(np.searchsorted(t, t_end)))
187
+ if n_show == 0:
188
+ n_show = len(t)
189
+
190
+ ax1.plot(t[:n_show], signal[:n_show], color="steelblue",
191
+ label="Signal")
192
+ reconstructed = amplitude * np.cos(
193
+ 2 * np.pi * rotation_freq * t[:n_show] + sig_phase
194
+ )
195
+ ax1.plot(t[:n_show], reconstructed, color="red", linestyle="--",
196
+ label=f"LS fit: {amplitude:.4g} @ {display_phase:.1f}\u00b0")
197
+ ax1.set_xlabel("Time (s)")
198
+ ax1.set_ylabel("Amplitude")
199
+ ax1.set_title(f"Time domain \u2014 first {n_periods} periods")
200
+ ax1.legend()
201
+
202
+ # -- Right: residuals --
203
+ ax2.plot(t, residuals, color="steelblue", linewidth=0.5)
204
+ ax2.axhline(0, color="gray", linewidth=0.5)
205
+ ax2.set_xlabel("Time (s)")
206
+ ax2.set_ylabel("Residual")
207
+ ax2.set_title(f"Residuals \u2014 std = {np.std(residuals):.4g}")
208
+
209
+ plt.tight_layout()
210
+ plt.show(block=True)