Skip to main content

embedded_dsp/
psd.rs

1//! Power Spectral Density (PSD) estimation using Welch's Method (Averaged Overlapped Periodogram) and Bartlett/standard periodograms.
2
3#[allow(unused_imports)]
4use crate::math::FloatMath;
5use crate::transform::cfft_f32;
6use crate::types::Status;
7use crate::window::*;
8
9/// Window function choice for spectral estimation.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum WelchWindow {
12    Rectangular,
13    Hamming,
14    Hanning,
15    Blackman,
16    BlackmanHarris,
17    Bartlett,
18    Welch,
19}
20
21/// Computes the Power Spectral Density (PSD) using Welch's Method (Averaged Overlapped Segment Periodograms).
22///
23/// `src`: continuous input signal time-series.
24/// `dst_psd`: destination slice receiving the one-sided PSD of length `fft_len / 2 + 1` (or `fft_len / 2`).
25/// `fft_len`: FFT segment size (must be power of 2, $\le 512$).
26/// `overlap`: number of overlapping samples between successive FFT segments (must be $< \text{fft\_len}$).
27/// `sample_rate`: sampling frequency in Hz (e.g. 1000.0, 44100.0).
28/// `window`: window function applied to each segment.
29/// `return_db`: if `true`, returns PSD in decibels ($10 \log_{10}(\text{PSD})$). If `false`, returns linear power.
30pub fn welch_psd_f32(
31    src: &[f32],
32    dst_psd: &mut [f32],
33    fft_len: usize,
34    overlap: usize,
35    sample_rate: f32,
36    window: WelchWindow,
37    return_db: bool,
38) -> Status {
39    let out_bins = fft_len / 2;
40    if fft_len < 4 || (fft_len & (fft_len - 1)) != 0 || fft_len > 512 {
41        return Status::ArgumentError;
42    }
43    if overlap >= fft_len || sample_rate <= 0.0 {
44        return Status::ArgumentError;
45    }
46    if src.len() < fft_len || dst_psd.len() < out_bins {
47        return Status::LengthError;
48    }
49
50    let step = fft_len - overlap;
51    let num_segments = (src.len() - fft_len) / step + 1;
52    if num_segments == 0 {
53        return Status::LengthError;
54    }
55
56    // Generate window
57    let mut win = [1.0f32; 512];
58    match window {
59        WelchWindow::Rectangular => win[..fft_len].fill(1.0),
60        WelchWindow::Hamming => hamming_f32(&mut win[..fft_len]),
61        WelchWindow::Hanning => hanning_f32(&mut win[..fft_len]),
62        WelchWindow::Blackman => blackman_f32(&mut win[..fft_len]),
63        WelchWindow::BlackmanHarris => blackman_harris_f32(&mut win[..fft_len]),
64        WelchWindow::Bartlett => bartlett_f32(&mut win[..fft_len]),
65        WelchWindow::Welch => welch_f32(&mut win[..fft_len]),
66    }
67
68    // Window power sum for normalization
69    let mut win_power = 0.0f32;
70    for i in 0..fft_len {
71        win_power += win[i] * win[i];
72    }
73    if win_power == 0.0 {
74        win_power = 1.0;
75    }
76
77    dst_psd[..out_bins].fill(0.0);
78
79    let mut scratch = [0.0f32; 1024];
80
81    for seg in 0..num_segments {
82        let start_idx = seg * step;
83        for i in 0..fft_len {
84            scratch[2 * i] = src[start_idx + i] * win[i];
85            scratch[2 * i + 1] = 0.0;
86        }
87
88        cfft_f32(&mut scratch[..2 * fft_len], fft_len, 0, 1);
89
90        for k in 0..out_bins {
91            let re = scratch[2 * k];
92            let im = scratch[2 * k + 1];
93            let mag_sq = re * re + im * im;
94            dst_psd[k] += mag_sq;
95        }
96    }
97
98    // Normalization factor for one-sided PSD:
99    // 2.0 / (num_segments * sample_rate * win_power)
100    let norm = 2.0f32 / (num_segments as f32 * sample_rate * win_power);
101    for k in 0..out_bins {
102        let linear_psd = dst_psd[k] * norm;
103        if return_db {
104            let clamped = if linear_psd > 1e-14 {
105                linear_psd
106            } else {
107                1e-14
108            };
109            dst_psd[k] = 10.0 * clamped.log10();
110        } else {
111            dst_psd[k] = linear_psd;
112        }
113    }
114
115    Status::Success
116}
117
118/// Computes the single-segment Periodogram Power Spectral Density.
119pub fn periodogram_f32(
120    src: &[f32],
121    dst_psd: &mut [f32],
122    fft_len: usize,
123    sample_rate: f32,
124    window: WelchWindow,
125    return_db: bool,
126) -> Status {
127    welch_psd_f32(src, dst_psd, fft_len, 0, sample_rate, window, return_db)
128}
129
130// ─────────────────────────────────────────────────────────────────────────────
131// Burg's Maximum Entropy Method (Autoregressive Spectral Estimation)
132// ─────────────────────────────────────────────────────────────────────────────
133
134/// Computes Autoregressive (AR) model coefficients of order `p` using Burg's Maximum Entropy Method.
135///
136/// Burg's method estimates reflection coefficients directly from data without computing autocorrelation,
137/// guaranteeing minimum-phase stable all-pole filters and superior frequency resolution on short frames.
138///
139/// `signal`: input sample vector ($N \ge 2p$).
140/// `order`: AR model order $p$ ($\le 32$).
141/// `ar_coeffs_out`: receives $p$ autoregressive coefficients $[a_1, a_2, \dots, a_p]$.
142/// Returns `Ok(noise_variance)` on success.
143pub fn ar_burg_f32(signal: &[f32], order: usize, ar_coeffs_out: &mut [f32]) -> Result<f32, Status> {
144    let n = signal.len();
145    if order == 0 || order > 32 || n < 2 * order {
146        return Err(Status::ArgumentError);
147    }
148    if ar_coeffs_out.len() < order {
149        return Err(Status::LengthError);
150    }
151
152    let mut f_err = [0.0f32; 256];
153    let mut b_err = [0.0f32; 256];
154    if n > f_err.len() {
155        return Err(Status::ArgumentError);
156    }
157
158    f_err[..n].copy_from_slice(signal);
159    b_err[..n].copy_from_slice(signal);
160
161    let mut total_energy = 0.0f32;
162    for &x in signal {
163        total_energy += x * x;
164    }
165    let mut noise_var = total_energy / n as f32;
166
167    let mut a_prev = [0.0f32; 32];
168
169    for m in 1..=order {
170        let mut num = 0.0f32;
171        let mut den = 0.0f32;
172
173        for i in m..n {
174            let f = f_err[i];
175            let b = b_err[i - 1];
176            num += f * b;
177            den += f * f + b * b;
178        }
179
180        if den.abs() < 1e-12 {
181            break;
182        }
183
184        let k_m = -2.0 * num / den;
185
186        // Update AR coefficients: a_i = a_prev_i + k_m * a_prev_{m-i}
187        ar_coeffs_out[m - 1] = k_m;
188        for i in 1..m {
189            ar_coeffs_out[i - 1] = a_prev[i - 1] + k_m * a_prev[m - 1 - i];
190        }
191        a_prev[..m].copy_from_slice(&ar_coeffs_out[..m]);
192
193        // Update forward and backward prediction errors
194        for i in (m..n).rev() {
195            let f = f_err[i];
196            let b = b_err[i - 1];
197            f_err[i] = f + k_m * b;
198            b_err[i] = b + k_m * f;
199        }
200
201        noise_var *= (1.0 - k_m * k_m).max(0.0);
202    }
203
204    Ok(noise_var)
205}
206
207/// Evaluates the Power Spectral Density from AR model coefficients at `num_bins` uniform frequency points.
208///
209/// Computes $P(e^{j\omega}) = \frac{\sigma^2}{|1 + \sum_{k=1}^p a_k e^{-j k \omega}|^2}$.
210pub fn ar_psd_f32(
211    ar_coeffs: &[f32],
212    noise_variance: f32,
213    num_bins: usize,
214    psd_out: &mut [f32],
215    return_db: bool,
216) -> Status {
217    if num_bins == 0 || psd_out.len() < num_bins {
218        return Status::LengthError;
219    }
220
221    let p = ar_coeffs.len();
222    let d_omega = core::f32::consts::PI / (num_bins as f32);
223
224    for bin in 0..num_bins {
225        let omega = bin as f32 * d_omega;
226        let mut re = 1.0f32;
227        let mut im = 0.0f32;
228
229        for k in 1..=p {
230            let angle = -(k as f32) * omega;
231            re += ar_coeffs[k - 1] * angle.cos();
232            im += ar_coeffs[k - 1] * angle.sin();
233        }
234
235        let denom = (re * re + im * im).max(1e-12);
236        let p_linear = noise_variance / denom;
237
238        if return_db {
239            psd_out[bin] = 10.0 * p_linear.max(1e-14).log10();
240        } else {
241            psd_out[bin] = p_linear;
242        }
243    }
244
245    Status::Success
246}