1use rustfft::FftPlanner;
4use rustfft::num_complex::Complex;
5
6use super::{Band, Spectrum, Taper};
7
8#[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 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 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#[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 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#[derive(Clone, Copy, Debug, PartialEq)]
146pub struct Welch {
147 pub resolution: f64,
152 pub overlap: f64,
154 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 #[must_use]
171 pub fn new(resolution: f64) -> Self {
172 Self {
173 resolution,
174 ..Self::default()
175 }
176 }
177
178 #[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 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#[derive(Clone, Debug, PartialEq)]
269pub struct Estimate {
270 pub step: f64,
272 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 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 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 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 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 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}