Skip to main content

harmos_signal/spectrum/
fourier.rs

1//! The forward transform, the averaged estimate, and the reconstruction back.
2
3use rustfft::FftPlanner;
4use rustfft::num_complex::Complex;
5
6use super::{Band, Spectrum, Taper};
7
8/// Transforms real samples into one single-window spectrum.
9///
10/// `rate` is the sampling rate in Hz, and the answer covers `0..=rate / 2` in
11/// `samples.len() / 2 + 1` bins. The taper's coherent gain is divided back out
12/// and every bin but DC and Nyquist is doubled, so a tone landing on a bin
13/// reads its own amplitude whichever taper measured it — which is the property
14/// [`reconstruct`] inverts exactly.
15///
16/// An empty input answers an empty spectrum; there is no error to handle.
17///
18/// ```
19/// use harmos_signal::{Taper, transform};
20///
21/// let rate = 1000.0;
22/// let samples: Vec<f64> = (0..1000)
23///     .map(|i| 3.0 * (std::f64::consts::TAU * 50.0 * f64::from(i) / rate).sin())
24///     .collect();
25///
26/// let spectrum = transform(&samples, rate, Taper::Rectangular);
27/// let magnitude = harmos_signal::magnitude(&spectrum.real, &spectrum.imag);
28///
29/// // 50 Hz at a 1 Hz bin step is bin 50, and it reads the amplitude it was given.
30/// assert!((magnitude[50] - 3.0).abs() < 1e-9);
31/// ```
32#[must_use]
33pub fn transform(samples: &[f64], rate: f64, taper: Taper) -> Spectrum {
34    let length = samples.len();
35    if length == 0 || rate <= 0.0 {
36        return Spectrum {
37            step: 0.0,
38            real: Vec::new(),
39            imag: Vec::new(),
40        };
41    }
42
43    let bins = length / 2 + 1;
44    let coefficients = taper.coefficients(length);
45    // The coherent gain, summed rather than averaged: dividing the transform by
46    // it is what makes one taper's amplitude the same as another's.
47    let gain: f64 = coefficients.iter().sum();
48
49    let mut buffer: Vec<Complex<f64>> = samples
50        .iter()
51        .zip(&coefficients)
52        .map(|(sample, coefficient)| Complex {
53            re: sample * coefficient,
54            im: 0.0,
55        })
56        .collect();
57
58    FftPlanner::new()
59        .plan_fft_forward(length)
60        .process(&mut buffer);
61
62    let mut real: Vec<f64> = buffer
63        .iter()
64        .take(bins)
65        .map(|bin| bin.re / gain * 2.0)
66        .collect();
67    let mut imag: Vec<f64> = buffer
68        .iter()
69        .take(bins)
70        .map(|bin| bin.im / gain * 2.0)
71        .collect();
72
73    // A single-sided spectrum doubles every bin that has a negative-frequency
74    // twin. DC has none, and neither does Nyquist when the length is even.
75    real[0] /= 2.0;
76    imag[0] /= 2.0;
77    if length.is_multiple_of(2) && bins > 1 {
78        real[bins - 1] /= 2.0;
79        imag[bins - 1] /= 2.0;
80    }
81
82    Spectrum {
83        step: rate / length as f64,
84        real,
85        imag,
86    }
87}
88
89/// Reconstructs real samples from a spectrum, optionally keeping one band.
90///
91/// The inverse of [`transform`]: the normalization it applied is undone bin by
92/// bin, the negative frequencies are rebuilt as conjugates, and the result is
93/// the sample run the spectrum came from. Passing a [`Band`] scales each bin by
94/// that band's coefficient first, which is how a band-limited copy of a signal
95/// is produced without designing a filter.
96///
97/// The answer holds `(bins - 1) * 2` samples at the rate [`Spectrum::rate`]
98/// reports. A spectrum of fewer than two bins reconstructs to nothing.
99#[must_use]
100pub fn reconstruct(spectrum: &Spectrum, band: Option<Band>) -> Vec<f64> {
101    let bins = spectrum.real.len();
102    if bins < 2 {
103        return Vec::new();
104    }
105
106    let length = (bins - 1) * 2;
107    let mut buffer: Vec<Complex<f64>> = vec![Complex::default(); length];
108
109    for (index, (real, imag)) in spectrum.real.iter().zip(&spectrum.imag).enumerate() {
110        let coefficient = band.map_or(1.0, |band| band.coefficient(index as f64 * spectrum.step));
111        // Undo exactly what the forward transform did: DC and Nyquist were
112        // never doubled, so they are scaled by the whole length and the rest by
113        // half of it.
114        let scale = if index == 0 || index == bins - 1 {
115            length as f64
116        } else {
117            length as f64 / 2.0
118        };
119
120        buffer[index] = Complex {
121            re: real * coefficient * scale,
122            im: imag * coefficient * scale,
123        };
124    }
125
126    for index in 1..bins - 1 {
127        buffer[length - index] = buffer[index].conj();
128    }
129
130    FftPlanner::new()
131        .plan_fft_inverse(length)
132        .process(&mut buffer);
133
134    buffer
135        .iter()
136        .map(|sample| sample.re / length as f64)
137        .collect()
138}
139
140/// Welch's method: what resolution to estimate at, and how hard to average.
141///
142/// Averaging the periodograms of overlapping segments trades frequency
143/// resolution for a quieter estimate, which is the trade a noisy measurement
144/// wants and a phase-sensitive one does not. [`Welch::estimate`] is the run.
145#[derive(Clone, Copy, Debug, PartialEq)]
146pub struct Welch {
147    /// The frequency resolution to aim for, in Hz.
148    ///
149    /// One segment is `rate / resolution` samples long, so a finer resolution
150    /// buys more bins out of fewer averages.
151    pub resolution: f64,
152    /// How much of each segment the next one repeats, `0.0..1.0`.
153    pub overlap: f64,
154    /// The taper applied to every segment.
155    pub taper: Taper,
156}
157
158impl Default for Welch {
159    fn default() -> Self {
160        Self {
161            resolution: 100.0,
162            overlap: 0.5,
163            taper: Taper::Hann,
164        }
165    }
166}
167
168impl Welch {
169    /// A default estimate at a stated resolution: half overlap, Hann taper.
170    #[must_use]
171    pub fn new(resolution: f64) -> Self {
172        Self {
173            resolution,
174            ..Self::default()
175        }
176    }
177
178    /// Estimates the amplitude spectrum of `samples` sampled at `rate` Hz.
179    ///
180    /// Segments shorter than 64 samples are not worth averaging and the segment
181    /// length is clamped there; a signal too short to divide is transformed
182    /// once instead, so an estimate is always answered on the same terms.
183    ///
184    /// Phase is not part of the answer, and that is the method rather than an
185    /// omission: averaging periodograms discards it.
186    #[must_use]
187    pub fn estimate(&self, samples: &[f64], rate: f64) -> Estimate {
188        let total = samples.len();
189        if total == 0 || rate <= 0.0 {
190            return Estimate {
191                step: 0.0,
192                magnitude: Vec::new(),
193            };
194        }
195
196        let segment = ((rate / self.resolution).round() as usize).clamp(64, total);
197        let hop =
198            (((segment as f64) * (1.0 - self.overlap.clamp(0.0, 0.99))).round() as usize).max(1);
199        let segments = if total <= segment {
200            1
201        } else {
202            (total - segment) / hop + 1
203        };
204
205        if segments <= 1 || segment >= total {
206            let single = transform(samples, rate, self.taper);
207            return Estimate {
208                step: single.step,
209                magnitude: super::magnitude(&single.real, &single.imag),
210            };
211        }
212
213        let bins = segment / 2 + 1;
214        let coefficients = self.taper.coefficients(segment);
215        let gain: f64 = coefficients.iter().sum();
216
217        let mut power = vec![0.0; bins];
218        let mut planner = FftPlanner::new();
219        let plan = planner.plan_fft_forward(segment);
220
221        for index in 0..segments {
222            let start = index * hop;
223            let Some(window) = samples.get(start..start + segment) else {
224                break;
225            };
226
227            let mut buffer: Vec<Complex<f64>> = window
228                .iter()
229                .zip(&coefficients)
230                .map(|(sample, coefficient)| Complex {
231                    re: sample * coefficient,
232                    im: 0.0,
233                })
234                .collect();
235
236            plan.process(&mut buffer);
237
238            for (accumulated, bin) in power.iter_mut().zip(buffer.iter().take(bins)) {
239                *accumulated += bin.norm_sqr();
240            }
241        }
242
243        // Summed over K segments the power of a tone is K·A²·(Σw)²/4, so
244        // dividing by K·(Σw)² and doubling the root recovers A.
245        let normalizer = segments as f64 * gain.powi(2);
246        let mut magnitude: Vec<f64> = power
247            .iter()
248            .map(|power| (power / normalizer).sqrt() * 2.0)
249            .collect();
250
251        magnitude[0] /= 2.0;
252        if segment.is_multiple_of(2) && bins > 1 {
253            magnitude[bins - 1] /= 2.0;
254        }
255
256        Estimate {
257            step: rate / segment as f64,
258            magnitude,
259        }
260    }
261}
262
263/// One averaged spectral estimate: a bin step, and a magnitude per bin.
264///
265/// Deliberately not a [`Spectrum`]. Welch's method has no phase to report, and
266/// a spectrum carrying an invented zero imaginary part would reconstruct to
267/// something that was never measured.
268#[derive(Clone, Debug, PartialEq)]
269pub struct Estimate {
270    /// The frequency distance between neighbouring bins, in Hz.
271    pub step: f64,
272    /// The amplitude of each bin, from DC upwards.
273    pub magnitude: Vec<f64>,
274}
275
276#[cfg(test)]
277mod tests {
278    use std::f64::consts::TAU;
279
280    use super::*;
281    use crate::spectrum::{bins, magnitude, phase};
282
283    /// A tone of `amplitude` at `frequency` Hz, `count` samples at `rate` Hz.
284    fn tone(amplitude: f64, frequency: f64, rate: f64, count: usize) -> Vec<f64> {
285        (0..count)
286            .map(|i| amplitude * (TAU * frequency * i as f64 / rate).sin())
287            .collect()
288    }
289
290    #[test]
291    fn a_constant_signal_is_all_dc() {
292        let spectrum = transform(&[5.0; 1000], 1000.0, Taper::Rectangular);
293        let magnitude = magnitude(&spectrum.real, &spectrum.imag);
294
295        assert!((magnitude[0] - 5.0).abs() < 1e-9);
296        assert!(magnitude[1..].iter().all(|bin| *bin < 1e-9));
297    }
298
299    #[test]
300    fn an_on_bin_tone_reads_its_own_amplitude_under_every_taper() {
301        let samples = tone(10.0, 50.0, 1000.0, 1000);
302
303        for taper in [
304            Taper::Rectangular,
305            Taper::Hann,
306            Taper::Hamming,
307            Taper::Blackman,
308        ] {
309            let spectrum = transform(&samples, 1000.0, taper);
310            let peak = magnitude(&spectrum.real, &spectrum.imag)
311                .into_iter()
312                .fold(0.0_f64, f64::max);
313
314            assert!((peak - 10.0).abs() < 0.15, "{taper:?} recovered {peak}");
315        }
316    }
317
318    #[test]
319    fn a_sine_lags_a_cosine_by_a_quarter_turn() {
320        let rate = 1000.0;
321        let sine = transform(&tone(1.0, 100.0, rate, 1000), rate, Taper::Rectangular);
322        let cosine: Vec<f64> = (0..1000)
323            .map(|i| (TAU * 100.0 * f64::from(i) / rate).cos())
324            .collect();
325        let cosine = transform(&cosine, rate, Taper::Rectangular);
326
327        assert!((phase(&sine.real, &sine.imag)[100] + std::f64::consts::FRAC_PI_2).abs() < 1e-6);
328        assert!(phase(&cosine.real, &cosine.imag)[100].abs() < 1e-6);
329    }
330
331    #[test]
332    fn the_spectrum_accounts_for_every_bit_of_the_signal() {
333        // Parseval, as this crate's normalization leaves it. A single-sided
334        // spectrum doubled every bin with a negative-frequency twin, so folding
335        // the power back counts those half and DC and Nyquist whole. Three
336        // things have to agree for this to hold: the coherent-gain division,
337        // the DC and Nyquist half-scaling, and the time-domain fold.
338        let rate = 1000.0;
339        let samples: Vec<f64> = (0..1000)
340            .map(|i| {
341                let t = f64::from(i) / rate;
342                3.0 + 8.0 * (TAU * 25.0 * t).cos() + 4.0 * (TAU * 75.0 * t).sin()
343            })
344            .collect();
345
346        let spectrum = transform(&samples, rate, Taper::Rectangular);
347        let amplitude = magnitude(&spectrum.real, &spectrum.imag);
348        let nyquist = amplitude.len() - 1;
349
350        let folded: f64 = amplitude[0].powi(2)
351            + amplitude[nyquist].powi(2)
352            + amplitude[1..nyquist]
353                .iter()
354                .map(|bin| bin * bin)
355                .sum::<f64>()
356                / 2.0;
357
358        // 3² + 8²/2 + 4²/2 = 49, so the signal's rms is exactly seven.
359        let measured = crate::waveform::Summary::of(&samples)
360            .rms()
361            .expect("a thousand samples have an rms");
362
363        assert!(
364            (folded - 49.0).abs() < 1e-9,
365            "the spectrum accounted for {folded}"
366        );
367        assert!(
368            (measured - 7.0).abs() < 1e-9,
369            "the samples measured {measured}"
370        );
371        assert!(
372            (folded.sqrt() - measured).abs() < 1e-9,
373            "the two domains disagree"
374        );
375    }
376
377    #[test]
378    fn the_bin_step_and_the_rate_are_each_other() {
379        let spectrum = transform(&tone(1.0, 50.0, 1000.0, 1000), 1000.0, Taper::Hann);
380
381        assert_eq!(spectrum.real.len(), 501);
382        assert!((spectrum.step - 1.0).abs() < 1e-12);
383        assert!((spectrum.rate() - 1000.0).abs() < 1e-9);
384        assert!((bins(spectrum.step, spectrum.real.len())[500] - 500.0).abs() < 1e-9);
385    }
386
387    #[test]
388    fn a_taper_buys_lower_sidelobes_off_bin() {
389        // 47.5 Hz sits between bins, so the leakage a taper suppresses is there
390        // to suppress.
391        let samples = tone(10.0, 47.5, 1000.0, 1000);
392        let skirt = |taper| {
393            let spectrum = transform(&samples, 1000.0, taper);
394            magnitude(&spectrum.real, &spectrum.imag)
395                .into_iter()
396                .enumerate()
397                .filter(|(bin, _)| bin.abs_diff(48) > 5)
398                .map(|(_, value)| value)
399                .sum::<f64>()
400        };
401
402        assert!(skirt(Taper::Hann) < skirt(Taper::Rectangular));
403    }
404
405    #[test]
406    fn an_empty_run_transforms_to_an_empty_spectrum() {
407        let spectrum = transform(&[], 1000.0, Taper::Hann);
408
409        assert!(spectrum.real.is_empty());
410        assert!(reconstruct(&spectrum, None).is_empty());
411    }
412
413    #[test]
414    fn a_transform_and_its_reconstruction_return_the_samples() {
415        let rate = 1000.0;
416        let samples: Vec<f64> = (0..1000)
417            .map(|i| {
418                let t = f64::from(i) / rate;
419                3.0 + 8.0 * (TAU * 25.0 * t).cos() + 4.0 * (TAU * 75.0 * t).sin()
420            })
421            .collect();
422
423        let recovered = reconstruct(&transform(&samples, rate, Taper::Rectangular), None);
424
425        assert_eq!(recovered.len(), samples.len());
426        for (original, recovered) in samples.iter().zip(&recovered) {
427            assert!((original - recovered).abs() < 1e-9);
428        }
429    }
430
431    #[test]
432    fn a_band_keeps_the_component_inside_it_and_drops_the_rest() {
433        let rate = 1000.0;
434        let samples: Vec<f64> = (0..1000)
435            .map(|i| {
436                let t = f64::from(i) / rate;
437                5.0 + 10.0 * (TAU * 50.0 * t).sin() + 3.0 * (TAU * 150.0 * t).sin()
438            })
439            .collect();
440
441        let spectrum = transform(&samples, rate, Taper::Rectangular);
442        let band = Band::Rectangular {
443            start: 40.0,
444            end: 60.0,
445        };
446        let filtered = reconstruct(&spectrum, Some(band));
447
448        // What survives is the 50 Hz tone alone, whose RMS is its amplitude
449        // over root two.
450        let power: f64 = filtered.iter().map(|sample| sample * sample).sum();
451        let rms = (power / filtered.len() as f64).sqrt();
452
453        assert!(
454            (rms - 10.0 / 2.0_f64.sqrt()).abs() < 0.05,
455            "reconstructed rms {rms}"
456        );
457    }
458
459    #[test]
460    fn welch_and_a_single_window_agree_on_a_tone() {
461        let rate = 10_000.0;
462        let samples = tone(10.0, 100.0, rate, 10_000);
463
464        let single = transform(&samples, rate, Taper::Hann);
465        let single = magnitude(&single.real, &single.imag)
466            .into_iter()
467            .fold(0.0_f64, f64::max);
468        let averaged = Welch::new(50.0)
469            .estimate(&samples, rate)
470            .magnitude
471            .into_iter()
472            .fold(0.0_f64, f64::max);
473
474        assert!((single - 10.0).abs() < 2.0, "single window read {single}");
475        assert!((averaged - 10.0).abs() < 2.0, "welch read {averaged}");
476    }
477
478    #[test]
479    fn welch_finds_a_tone_a_single_window_would_bury() {
480        let rate = 10_000.0;
481        let samples: Vec<f64> = (0..10_000)
482            .map(|i| {
483                let noise = f64::from((i * 12_345 + 67_890) % 1000) / 1000.0 - 0.5;
484                10.0 * (TAU * 100.0 * f64::from(i) / rate).sin() + noise
485            })
486            .collect();
487
488        let estimate = Welch::default().estimate(&samples, rate);
489        let peak = estimate
490            .magnitude
491            .iter()
492            .enumerate()
493            .max_by(|(_, left), (_, right)| left.total_cmp(right))
494            .map(|(bin, _)| bin as f64 * estimate.step)
495            .expect("a non-empty estimate has a peak");
496
497        assert!((peak - 100.0).abs() < 5.0, "welch peaked at {peak} Hz");
498    }
499
500    #[test]
501    fn a_run_too_short_to_average_is_transformed_once() {
502        let rate = 1000.0;
503        let samples = tone(1.0, 50.0, rate, 200);
504
505        let estimate = Welch::new(1.0).estimate(&samples, rate);
506        let single = transform(&samples, rate, Taper::Hann);
507
508        assert_eq!(estimate.magnitude, magnitude(&single.real, &single.imag));
509        assert!((estimate.step - single.step).abs() < 1e-12);
510    }
511
512    #[test]
513    fn an_empty_run_estimates_to_nothing() {
514        let estimate = Welch::default().estimate(&[], 1000.0);
515
516        assert!(estimate.magnitude.is_empty());
517        assert_eq!(estimate.step, 0.0);
518    }
519}