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,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)
|