rota-order 0.3.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.
rota/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """ROTA: rotational order tracking and analysis.
2
+
3
+ Submodules
4
+ ----------
5
+ speed Speed references: tachometer pulses, tacholess estimation, phase,
6
+ control signals.
7
+ angle Angle-domain (computed order tracking) resampling.
8
+ maps RPM-frequency and RPM-order maps.
9
+ slices Order spectra and order slices.
10
+ extract Order extraction (Vold-Kalman, tracked harmonics), removal, residual.
11
+ resynth Order tables, order and broadband resynthesis, provenance.
12
+ synth Synthetic test signals with analytic ground truth.
13
+ metrics Validation metrics.
14
+
15
+ Conventions
16
+ -----------
17
+ Time in seconds, sampling rates in Hz, rotational speed in rev/min (rpm),
18
+ shaft angle in revolutions, vehicle speed in km/h. Signals are arrays of
19
+ shape ``(n,)`` or ``(n, ch)``; every operator acts on each channel
20
+ independently and linearly. Amplitude conventions (peak or RMS) are stated
21
+ in each docstring and selected explicitly where a choice exists.
22
+ """
23
+
24
+ __version__ = "0.3.0"
25
+
26
+ from . import angle, extract, maps, metrics, resynth, slices, speed, synth # noqa: F401
27
+
28
+ __all__ = [
29
+ "angle", "extract", "maps", "metrics", "resynth", "slices", "speed", "synth",
30
+ "__version__",
31
+ ]
rota/_broadband.py ADDED
@@ -0,0 +1,289 @@
1
+ """Broadband noise resynthesis from a spectral envelope.
2
+
3
+ :func:`synthesize_broadband` renders stationary or operating-point dependent
4
+ noise (the stochastic part of a deterministic-plus-stochastic model, Serra &
5
+ Smith 1990) from a one-sided power spectral density. The noise is built from
6
+ overlapping *grains*: each grain is the inverse FFT of a spectrum with the
7
+ magnitude of the envelope and random phases, multiplied by a window and
8
+ overlap-added (Allen & Rabiner 1977).
9
+
10
+ The window decides whether the result is free of level pumping at the grain
11
+ rate. Grains with independent random phases are uncorrelated, so they add in
12
+ *power*, not in amplitude. The window must therefore be *power
13
+ complementary*, ``sum_k w(n - k R)**2 = 1``, which the square root of the
14
+ periodic Hann window satisfies at 50 % overlap. The amplitude-COLA window
15
+ that is the right choice for a deterministic signal (``sum_k w = 1``, plain
16
+ Hann) makes the power of independent grains dip by 3 dB at every grain
17
+ boundary (ROADMAP section 6, item 11; Griffin & Lim 1984 discuss the two
18
+ conditions for signal estimation from modified short-time spectra).
19
+
20
+ Units: the output has the unit of ``sqrt(psd)`` (e.g. Pa for a PSD in
21
+ Pa^2/Hz). Amplitude convention: the output is a random signal characterised by
22
+ its PSD and RMS (``mean(y**2) = integral of psd``), not by peak amplitudes.
23
+ """
24
+
25
+ import numpy as np
26
+
27
+ __all__ = ["synthesize_broadband"]
28
+
29
+ _WINDOWS = ("sqrt_hann", "hann")
30
+ _CHUNK_ELEMENTS = 1 << 20 # complex values per block of grains (bounds memory)
31
+
32
+
33
+ def _grain_window(length, kind):
34
+ """Periodic grain window of ``length`` samples.
35
+
36
+ ``"sqrt_hann"`` is ``sin(pi m / L)``, the square root of the periodic Hann
37
+ window (power complementary at 50 % overlap); ``"hann"`` is the periodic
38
+ Hann window ``sin(pi m / L)**2`` (amplitude COLA at 50 % overlap).
39
+ """
40
+ s = np.sin(np.pi * np.arange(length) / length)
41
+ return s if kind == "sqrt_hann" else s * s
42
+
43
+
44
+ def _check_psd_inputs(psd, freqs, fs, n, control, control_axis, grain, highpass):
45
+ """Validate and normalise the arguments; return arrays and the grain length."""
46
+ fs = float(fs)
47
+ if not (np.isfinite(fs) and fs > 0):
48
+ raise ValueError("fs must be positive and finite")
49
+ if int(n) != n or n < 1:
50
+ raise ValueError("n must be a positive integer number of samples")
51
+ n = int(n)
52
+
53
+ freqs = np.asarray(freqs, dtype=float)
54
+ if freqs.ndim != 1 or freqs.size < 2:
55
+ raise ValueError("freqs must be a 1-D array of at least two frequencies")
56
+ if not np.all(np.isfinite(freqs)) or freqs[0] < 0 or np.any(np.diff(freqs) <= 0):
57
+ raise ValueError("freqs must be finite, non-negative and strictly increasing")
58
+
59
+ psd = np.asarray(psd, dtype=float)
60
+ if psd.ndim not in (1, 2):
61
+ raise ValueError("psd must have shape (n_freq,) or (n_control, n_freq)")
62
+ if psd.shape[-1] != freqs.size:
63
+ raise ValueError("psd must have one column per entry of freqs")
64
+ if not np.all(np.isfinite(psd)) or np.any(psd < 0):
65
+ raise ValueError("psd must be finite and non-negative")
66
+
67
+ if psd.ndim == 1:
68
+ if control is not None or control_axis is not None:
69
+ raise ValueError("control and control_axis apply only to a 2-D psd")
70
+ control_axis = None
71
+ control = None
72
+ else:
73
+ if control is None or control_axis is None:
74
+ raise ValueError("a 2-D psd needs control_axis and control")
75
+ control_axis = np.asarray(control_axis, dtype=float)
76
+ if control_axis.shape != (psd.shape[0],) or control_axis.size < 2:
77
+ raise ValueError("control_axis must have one entry per row of psd (at least two rows)")
78
+ if not np.all(np.isfinite(control_axis)) or np.any(np.diff(control_axis) <= 0):
79
+ raise ValueError("control_axis must be finite and strictly increasing")
80
+ control = np.asarray(control, dtype=float)
81
+ if control.shape != (n,):
82
+ raise ValueError(f"control must have shape ({n},), one value per output sample")
83
+ if not np.all(np.isfinite(control)):
84
+ raise ValueError("control contains non-finite values; regularise it first")
85
+
86
+ grain = float(grain)
87
+ if not (np.isfinite(grain) and grain > 0):
88
+ raise ValueError("grain must be positive and finite")
89
+ length = 2 * round(grain * fs / 2.0)
90
+ if length < 8:
91
+ raise ValueError("grain is shorter than 8 samples; increase grain or fs")
92
+
93
+ highpass = float(highpass)
94
+ if not (np.isfinite(highpass) and 0.0 <= highpass < fs / 2.0):
95
+ raise ValueError("highpass must lie in [0, fs/2)")
96
+ return psd, freqs, fs, n, control, control_axis, length, highpass
97
+
98
+
99
+ def _blend_rows(table, axis, c):
100
+ """Power-linear blend of the rows of ``table`` at control values ``c``.
101
+
102
+ ``table`` has shape ``(n_control, n_bins)`` on the increasing ``axis``;
103
+ values of ``c`` outside the axis are held at the end rows.
104
+ """
105
+ i = np.clip(np.searchsorted(axis, c, side="right") - 1, 0, axis.size - 2)
106
+ a = np.clip((c - axis[i]) / (axis[i + 1] - axis[i]), 0.0, 1.0)[:, None]
107
+ return (1.0 - a) * table[i] + a * table[i + 1]
108
+
109
+
110
+ def _synthesize(psd, freqs, fs, n, *, control, control_axis, grain, highpass, seed, window):
111
+ """Grain synthesis with a selectable window (``"sqrt_hann"`` or ``"hann"``).
112
+
113
+ ``"hann"`` exists only to demonstrate the pumping of an amplitude-COLA
114
+ window with independent random-phase grains (ROADMAP section 6, item 11):
115
+ the scaling is that of ``"sqrt_hann"`` and is not renormalised, so the mean
116
+ power is 1.25 dB low.
117
+ """
118
+ if window not in _WINDOWS:
119
+ raise ValueError(f"window must be one of {_WINDOWS}, got {window!r}")
120
+ psd, freqs, fs, n, control, control_axis, length, highpass = _check_psd_inputs(
121
+ psd, freqs, fs, n, control, control_axis, grain, highpass
122
+ )
123
+ rng = np.random.default_rng(seed)
124
+
125
+ hop = length // 2
126
+ n_bins = hop + 1
127
+ f_bins = np.arange(n_bins) * (fs / length)
128
+ keep = f_bins >= highpass
129
+
130
+ # Envelope on the grain's frequency bins (linear in frequency and power).
131
+ rows = np.atleast_2d(psd)
132
+ table = np.stack([np.interp(f_bins, freqs, row) for row in rows])
133
+
134
+ # Grain k starts at sample (k - 1) * hop and is centred on sample k * hop,
135
+ # so that every output sample lies in the fully overlapped region.
136
+ n_grains = -(-n // hop) + 1
137
+ if control is not None:
138
+ centres = np.minimum(np.arange(n_grains) * hop, n - 1)
139
+ grain_control = control[centres]
140
+
141
+ win = _grain_window(length, window)
142
+ # |G_k| = sqrt(psd_k * fs * L / 2) for every bin k (see the Notes of
143
+ # synthesize_broadband).
144
+ scale = np.sqrt(0.5 * fs * length)
145
+ buf = np.zeros((n_grains + 1, hop))
146
+ step = max(1, _CHUNK_ELEMENTS // n_bins)
147
+ for k0 in range(0, n_grains, step):
148
+ k1 = min(n_grains, k0 + step)
149
+ if control is None:
150
+ power = np.broadcast_to(table[0], (k1 - k0, n_bins))
151
+ else:
152
+ power = _blend_rows(table, control_axis, grain_control[k0:k1])
153
+ mag = scale * np.sqrt(power) * keep
154
+ u = rng.random((k1 - k0, n_bins))
155
+ spec = mag * np.exp(2j * np.pi * u)
156
+ # DC and Nyquist bins are real: a random sign instead of a phase.
157
+ spec[:, 0] = np.where(u[:, 0] < 0.5, mag[:, 0], -mag[:, 0])
158
+ spec[:, -1] = np.where(u[:, -1] < 0.5, mag[:, -1], -mag[:, -1])
159
+ frames = np.fft.irfft(spec, n=length, axis=1) * win
160
+ buf[k0:k1] += frames[:, :hop]
161
+ buf[k0 + 1 : k1 + 1] += frames[:, hop:]
162
+ return buf.reshape(-1)[hop : hop + n].copy()
163
+
164
+
165
+ def synthesize_broadband(
166
+ psd, freqs, fs, n, *, control=None, control_axis=None, grain=0.05, highpass=20.0, seed=None
167
+ ):
168
+ """Broadband noise with a given power spectral density, optionally operating-point dependent.
169
+
170
+ The noise is made of grains of ``grain`` seconds (rounded to an even
171
+ number of samples ``L``) at a hop of ``L/2``. Each grain is the inverse
172
+ real FFT of a spectrum whose magnitude follows ``psd`` at the grain's
173
+ control value and whose phases are independent and uniformly random;
174
+ bins below ``highpass`` are zeroed. The grains are multiplied by the
175
+ square root of the periodic Hann window and overlap-added. Because
176
+ ``sin**2 + cos**2 = 1`` this window is *power complementary* at 50 %
177
+ overlap, so the power of the independent grains adds up to a constant and
178
+ the output has no level pumping at the grain rate (ROADMAP section 6,
179
+ item 11).
180
+
181
+ With a 2-D ``psd`` the envelope depends on a control variable (engine
182
+ speed in rpm, vehicle speed in km/h, load, ...). Each grain uses the
183
+ control value at its centre sample, located between two neighbouring rows
184
+ of ``psd``; the *power* spectrum is interpolated linearly between those
185
+ rows, ``(1 - a) psd_i + a psd_{i+1}``. This is a crossfade of the two
186
+ operating points with weights ``sqrt(1 - a)`` and ``sqrt(a)`` on the
187
+ amplitude: the power stays between the two operating points (a linear
188
+ amplitude crossfade of two independent noises dips by 3 dB at the middle).
189
+ Between grains the window crossfades the power in the same
190
+ power-complementary way, so the level follows the control smoothly.
191
+
192
+ Parameters
193
+ ----------
194
+ psd : array_like, shape (n_freq,) or (n_control, n_freq)
195
+ One-sided power spectral density in unit**2/Hz, non-negative, on
196
+ ``freqs``. A 1-D array is a stationary envelope; a 2-D array holds one
197
+ envelope per operating point (row) for ``control_axis``. The envelope
198
+ is interpolated linearly in frequency and in power onto the grain's
199
+ bins and held constant beyond the ends of ``freqs`` (add zero-valued
200
+ end points to band-limit; give a dense grid for a steep envelope).
201
+ freqs : array_like, shape (n_freq,)
202
+ Strictly increasing frequencies in Hz, at least two.
203
+ fs : float
204
+ Sampling rate in Hz.
205
+ n : int
206
+ Number of output samples.
207
+ control : array_like, shape (n,), optional
208
+ Control value for every output sample, in the unit of
209
+ ``control_axis`` (rpm, km/h, ...). Required for a 2-D ``psd``. Values
210
+ outside ``control_axis`` use the first or last row (no
211
+ extrapolation).
212
+ control_axis : array_like, shape (n_control,), optional
213
+ Strictly increasing control values of the rows of ``psd`` (at least
214
+ two). Required for a 2-D ``psd``.
215
+ grain : float, default 0.05
216
+ Grain length in s. The hop is half of it, so the grain rate (the
217
+ frequency at which pumping would appear) is ``fs / hop = 2 / grain``
218
+ (40 Hz by default). The grain is rounded to an even number of samples
219
+ and must have at least 8. The frequency resolution of the envelope is
220
+ about ``1 / grain``; a shorter grain follows a changing control more
221
+ closely, a longer one resolves narrower spectral features.
222
+ highpass : float, default 20.0
223
+ Cut-off in Hz: grain bins with a centre frequency below it are set to
224
+ zero, which removes the sub-audio content whose offsets at the window
225
+ switches cause clicks (ROADMAP section 6, item 11). The cut-off
226
+ is a bin-wise zeroing at the grain's resolution ``1 / grain``; energy
227
+ within about that distance below the cut-off leaks in through the
228
+ window. 0 keeps the DC bin.
229
+ seed : int, numpy.random.Generator or None
230
+ Seed or generator for the random phases. The same integer seed and
231
+ arguments give bit-identical output; None draws fresh entropy.
232
+
233
+ Returns
234
+ -------
235
+ ndarray, shape (n,)
236
+ Noise in the unit of ``sqrt(psd)`` (e.g. Pa). It is a random signal
237
+ characterised by its PSD, not by peak amplitudes: the amplitude
238
+ convention is RMS, with ``mean(y**2)`` equal to the integral of the
239
+ (high-passed) ``psd`` over 0 to ``fs/2`` at every instant, in the
240
+ sense of the expectation over the random phases.
241
+
242
+ Raises
243
+ ------
244
+ ValueError
245
+ For inconsistent shapes, non-finite or negative ``psd``, non-increasing
246
+ ``freqs`` or ``control_axis``, a ``control`` of the wrong length or
247
+ with non-finite values, a ``grain`` shorter than 8 samples, or a
248
+ ``highpass`` outside ``[0, fs/2)``.
249
+
250
+ Notes
251
+ -----
252
+ Scaling. Let ``L`` be the grain length, ``R = L/2`` the hop, ``df = fs/L``
253
+ the bin spacing, ``f_k = k df`` and ``G_k`` the grain's spectrum
254
+ (``k = 0 .. L/2``). With ``g = irfft(G)``, Parseval gives the mean square
255
+ of a grain ``(|G_0|**2 + |G_{L/2}|**2 + 2 sum_{0<k<L/2} |G_k|**2) / L**2``.
256
+ The target is ``integral psd df ~ sum_{0<k<L/2} psd_k df + (psd_0 +
257
+ psd_{L/2}) df / 2`` (trapezoid rule; DC and Nyquist cover half a bin).
258
+ Equating the two term by term gives, for every bin including DC and
259
+ Nyquist, ``|G_k| = sqrt(psd_k fs L / 2)``. With independent uniform phases
260
+ (a random sign for the real DC and Nyquist bins) ``E[g[m]**2]`` equals that
261
+ mean square for every sample ``m`` of the grain, not only on average over
262
+ ``m``. Independent zero-mean grains add in power, so
263
+ ``E[y[n]**2] = sum_k w(n - kR)**2 E[g**2] = integral psd df`` for every
264
+ ``n``, since ``sum_k w**2 = 1``. The long-term PSD of ``y`` is the
265
+ envelope smoothed by the normalised power transfer function of the window
266
+ (width about ``1 / grain``), which leaves a locally smooth envelope
267
+ unchanged. Within a grain the magnitudes are deterministic, so the sample
268
+ values approach a Gaussian distribution only by the central limit theorem
269
+ over the bins; an envelope only a few bins wide gives a sinusoid-like
270
+ rather than a Gaussian narrow-band noise.
271
+
272
+ References
273
+ ----------
274
+ Serra, X., & Smith, J. O. (1990). Spectral modeling synthesis: A sound
275
+ analysis/synthesis system based on a deterministic plus stochastic
276
+ decomposition. Computer Music Journal, 14(4), 12-24.
277
+ Allen, J. B., & Rabiner, L. R. (1977). A unified approach to short-time
278
+ Fourier analysis and synthesis. Proceedings of the IEEE, 65(11), 1558-1564.
279
+ Griffin, D. W., & Lim, J. S. (1984). Signal estimation from modified
280
+ short-time Fourier transform. IEEE Transactions on Acoustics, Speech, and
281
+ Signal Processing, 32(2), 236-243.
282
+ Welch, P. D. (1967). The use of fast Fourier transform for the estimation
283
+ of power spectra. IEEE Transactions on Audio and Electroacoustics, 15(2),
284
+ 70-73.
285
+ """
286
+ return _synthesize(
287
+ psd, freqs, fs, n, control=control, control_axis=control_axis,
288
+ grain=grain, highpass=highpass, seed=seed, window="sqrt_hann",
289
+ )
rota/_components.py ADDED
@@ -0,0 +1,52 @@
1
+ """Result container shared by the order extractors."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ import numpy as np
6
+
7
+
8
+ @dataclass
9
+ class OrderComponents:
10
+ """Order components extracted from a signal.
11
+
12
+ For order ``k`` with carrier angle ``theta_k(t) = 2 pi orders[k] rev(t)``
13
+ (``rev`` the shaft angle in revolutions of the order's shaft, from
14
+ :func:`rota.speed.rpm_to_phase`)::
15
+
16
+ waveforms[:, k] = Re{ envelopes[:, k] * exp(1j * theta_k) }
17
+
18
+ so ``|envelopes|`` is the *peak* amplitude in the signal unit and
19
+ ``angle(envelopes)`` the phase relative to the carrier. This matches the
20
+ truth of :func:`rota.synth.synth_runup`:
21
+ ``order_amplitude * exp(1j * (order_phase - 2 pi order rev))``.
22
+
23
+ Attributes
24
+ ----------
25
+ orders : ndarray, shape (k,)
26
+ Order numbers.
27
+ shafts : ndarray of int, shape (k,)
28
+ Column of ``rpm`` each order refers to.
29
+ waveforms : ndarray, shape (n, k) or (n, k, ch)
30
+ Real order waveforms, signal unit.
31
+ envelopes : ndarray, complex, shape (n, k) or (n, k, ch)
32
+ Complex peak envelopes, signal unit.
33
+ rev : ndarray, shape (n,) or (n, n_shafts)
34
+ Shaft angle(s) in revolutions used for the carriers.
35
+ method : str
36
+ Name of the extractor.
37
+ params : dict
38
+ Parameters used (JSON-serialisable).
39
+ """
40
+
41
+ orders: np.ndarray
42
+ shafts: np.ndarray
43
+ waveforms: np.ndarray
44
+ envelopes: np.ndarray
45
+ rev: np.ndarray
46
+ method: str
47
+ params: dict = field(default_factory=dict)
48
+
49
+ @property
50
+ def total(self):
51
+ """Sum of all order waveforms, shape (n,) or (n, ch)."""
52
+ return self.waveforms.sum(axis=1)