use crate::core::error::{Error, Result};
use oxifft::{Complex, Flags, RealPlan};
#[derive(Debug, Clone)]
pub(crate) struct Periodogram {
pub frequencies: Vec<f64>,
pub psd: Vec<f64>,
}
impl Periodogram {
pub fn total_power(&self) -> f64 {
self.psd.iter().sum()
}
pub fn dominant_bin(&self) -> Option<usize> {
self.psd
.iter()
.enumerate()
.skip(1)
.filter(|(_, p)| p.is_finite())
.max_by(|(_, a), (_, b)| a.total_cmp(b))
.map(|(idx, _)| idx)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Detrend {
Mean,
}
pub(crate) fn periodogram(values: &[f64], detrend: Detrend) -> Result<Periodogram> {
let n = values.len();
if n < 2 {
return Err(Error::InvalidInput(format!(
"periodogram requires at least 2 observations, got {n}"
)));
}
if let Some(bad) = values.iter().position(|v| !v.is_finite()) {
return Err(Error::InvalidInput(format!(
"periodogram requires finite values; index {bad} is {}",
values[bad]
)));
}
let input: Vec<f64> = match detrend {
Detrend::Mean => {
let mean = values.iter().sum::<f64>() / n as f64;
values.iter().map(|v| v - mean).collect()
}
};
let plan = RealPlan::<f64>::r2c_1d(n, Flags::ESTIMATE).ok_or_else(|| {
Error::InvalidOperation(format!("OxiFFT could not plan a real FFT of length {n}"))
})?;
let mut spectrum = vec![Complex::<f64>::zero(); plan.complex_size()];
plan.execute_r2c(&input, &mut spectrum);
let nf = n as f64;
let norm = nf * nf;
let n_even = n % 2 == 0;
let last = spectrum.len() - 1;
let mut psd = Vec::with_capacity(spectrum.len());
let mut frequencies = Vec::with_capacity(spectrum.len());
for (k, bin) in spectrum.iter().enumerate() {
let fold = if k == 0 || (n_even && k == last) {
1.0
} else {
2.0
};
psd.push(fold * bin.norm_sqr() / norm);
frequencies.push(k as f64 / nf);
}
Ok(Periodogram { frequencies, psd })
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
#[test]
fn periodogram_peaks_at_the_injected_frequency() {
let n = 256;
let signal: Vec<f64> = (0..n)
.map(|i| 5.0 + 3.0 * (2.0 * PI * 8.0 * i as f64 / n as f64).sin())
.collect();
let pg = periodogram(&signal, Detrend::Mean).expect("periodogram");
let peak = pg.dominant_bin().expect("dominant bin");
assert_eq!(peak, 8, "peak bin should be k=8, got {peak}");
assert!((pg.frequencies[peak] - 0.03125).abs() < 1e-12);
}
#[test]
fn periodogram_conserves_power() {
let values: Vec<f64> = (0..64)
.map(|i| (i as f64 * 0.37).sin() * 2.0 + 7.0)
.collect();
let mean = values.iter().sum::<f64>() / values.len() as f64;
let mean_square =
values.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / values.len() as f64;
let pg = periodogram(&values, Detrend::Mean).expect("periodogram");
assert!(
(pg.total_power() - mean_square).abs() < 1e-9,
"total power {} != mean square {}",
pg.total_power(),
mean_square
);
}
#[test]
fn periodogram_rejects_short_and_non_finite_input() {
assert!(periodogram(&[1.0], Detrend::Mean).is_err());
assert!(periodogram(&[1.0, f64::NAN, 3.0], Detrend::Mean).is_err());
}
}