Skip to main content

oxiphysics_core/
signal_analysis.rs

1#![allow(clippy::needless_range_loop, clippy::ptr_arg)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Signal analysis: autocorrelation, cross-correlation, PSD, Hilbert transform,
6//! empirical mode decomposition, peak detection, and signal filtering.
7
8#![allow(dead_code)]
9
10use std::f64::consts::PI;
11
12// ─────────────────────────────────────────────────────────────────────────────
13// Helper: FFT (Cooley-Tukey radix-2)
14// ─────────────────────────────────────────────────────────────────────────────
15
16#[derive(Clone, Copy, Debug)]
17struct C64 {
18    re: f64,
19    im: f64,
20}
21
22impl C64 {
23    #[inline]
24    fn new(re: f64, im: f64) -> Self {
25        Self { re, im }
26    }
27    #[inline]
28    fn exp_i(theta: f64) -> Self {
29        Self {
30            re: theta.cos(),
31            im: theta.sin(),
32        }
33    }
34}
35
36impl std::ops::Add for C64 {
37    type Output = Self;
38    fn add(self, r: Self) -> Self {
39        Self::new(self.re + r.re, self.im + r.im)
40    }
41}
42impl std::ops::Sub for C64 {
43    type Output = Self;
44    fn sub(self, r: Self) -> Self {
45        Self::new(self.re - r.re, self.im - r.im)
46    }
47}
48impl std::ops::Mul for C64 {
49    type Output = Self;
50    fn mul(self, r: Self) -> Self {
51        Self::new(
52            self.re * r.re - self.im * r.im,
53            self.re * r.im + self.im * r.re,
54        )
55    }
56}
57
58fn fft(a: &mut Vec<C64>, invert: bool) {
59    let n = a.len();
60    // Bit-reverse permutation
61    let mut j = 0usize;
62    for i in 1..n {
63        let mut bit = n >> 1;
64        while j & bit != 0 {
65            j ^= bit;
66            bit >>= 1;
67        }
68        j ^= bit;
69        if i < j {
70            a.swap(i, j);
71        }
72    }
73    let mut len = 2usize;
74    while len <= n {
75        let ang = if invert {
76            2.0 * PI / len as f64
77        } else {
78            -2.0 * PI / len as f64
79        };
80        let wlen = C64::exp_i(ang);
81        let mut i = 0;
82        while i < n {
83            let mut w = C64::new(1.0, 0.0);
84            for jj in 0..(len / 2) {
85                let u = a[i + jj];
86                let v = a[i + jj + len / 2] * w;
87                a[i + jj] = u + v;
88                a[i + jj + len / 2] = u - v;
89                w = w * wlen;
90            }
91            i += len;
92        }
93        len <<= 1;
94    }
95    if invert {
96        let nf = n as f64;
97        for x in a.iter_mut() {
98            x.re /= nf;
99            x.im /= nf;
100        }
101    }
102}
103
104/// Next power of two >= n.
105fn next_pow2(n: usize) -> usize {
106    let mut p = 1;
107    while p < n {
108        p <<= 1;
109    }
110    p
111}
112
113// ─────────────────────────────────────────────────────────────────────────────
114// AutoCorrelation
115// ─────────────────────────────────────────────────────────────────────────────
116
117/// Autocorrelation estimator for a real-valued signal.
118///
119/// Supports biased and unbiased estimates and optional normalization.
120#[derive(Debug, Clone)]
121pub struct AutoCorrelation {
122    /// The autocorrelation values at lags 0, 1, ..., max_lag.
123    pub values: Vec<f64>,
124    /// Maximum lag computed.
125    pub max_lag: usize,
126    /// Whether the unbiased (1/(N-k)) estimator was used.
127    pub unbiased: bool,
128}
129
130impl AutoCorrelation {
131    /// Compute autocorrelation of `signal` up to `max_lag`.
132    ///
133    /// If `unbiased` is true, use the `1/(N-k)` normalization; otherwise use `1/N`.
134    /// If `normalize` is true, divide all values by the lag-0 value.
135    pub fn compute(signal: &[f64], max_lag: usize, unbiased: bool, normalize: bool) -> Self {
136        let n = signal.len();
137        let mean = signal.iter().sum::<f64>() / n as f64;
138        let centered: Vec<f64> = signal.iter().map(|&x| x - mean).collect();
139        let lag_limit = max_lag.min(n.saturating_sub(1));
140        let mut values = vec![0.0_f64; lag_limit + 1];
141        for k in 0..=lag_limit {
142            let sum: f64 = (0..(n - k)).map(|i| centered[i] * centered[i + k]).sum();
143            let denom = if unbiased { (n - k) as f64 } else { n as f64 };
144            values[k] = sum / denom;
145        }
146        if normalize && values[0].abs() > 1e-15 {
147            let r0 = values[0];
148            for v in values.iter_mut() {
149                *v /= r0;
150            }
151        }
152        Self {
153            values,
154            max_lag: lag_limit,
155            unbiased,
156        }
157    }
158
159    /// Return the autocorrelation at lag `k`, or 0 if out of range.
160    pub fn at_lag(&self, k: usize) -> f64 {
161        self.values.get(k).copied().unwrap_or(0.0)
162    }
163
164    /// Return the dominant lag (lag > 0 with maximum autocorrelation).
165    pub fn dominant_lag(&self) -> usize {
166        self.values[1..]
167            .iter()
168            .enumerate()
169            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
170            .map(|(i, _)| i + 1)
171            .unwrap_or(0)
172    }
173}
174
175// ─────────────────────────────────────────────────────────────────────────────
176// CrossCorrelation
177// ─────────────────────────────────────────────────────────────────────────────
178
179/// Cross-correlation between two real-valued signals.
180///
181/// Computes R_xy(k) = Σ x\[n\] * y\[n+k\] for k in \[-max_lag, max_lag\].
182#[derive(Debug, Clone)]
183pub struct CrossCorrelation {
184    /// Cross-correlation values; index 0 corresponds to lag -max_lag.
185    pub values: Vec<f64>,
186    /// Maximum lag in each direction.
187    pub max_lag: usize,
188}
189
190impl CrossCorrelation {
191    /// Compute cross-correlation between `x` and `y` up to `max_lag`.
192    ///
193    /// Both signals are mean-subtracted before computation.
194    pub fn compute(x: &[f64], y: &[f64], max_lag: usize) -> Self {
195        let n = x.len().min(y.len());
196        let mx = x[..n].iter().sum::<f64>() / n as f64;
197        let my = y[..n].iter().sum::<f64>() / n as f64;
198        let lag_limit = max_lag.min(n.saturating_sub(1));
199        let total_lags = 2 * lag_limit + 1;
200        let mut values = vec![0.0_f64; total_lags];
201        for (idx, lag) in (-(lag_limit as i64)..=(lag_limit as i64)).enumerate() {
202            let sum: f64 = (0..n)
203                .filter_map(|i| {
204                    let j = i as i64 + lag;
205                    if j >= 0 && (j as usize) < n {
206                        Some((x[i] - mx) * (y[j as usize] - my))
207                    } else {
208                        None
209                    }
210                })
211                .sum();
212            values[idx] = sum / n as f64;
213        }
214        Self {
215            values,
216            max_lag: lag_limit,
217        }
218    }
219
220    /// Return the lag at which cross-correlation is maximum.
221    ///
222    /// Negative lags mean x leads y; positive lags mean y leads x.
223    pub fn peak_lag(&self) -> i64 {
224        let (idx, _) = self
225            .values
226            .iter()
227            .enumerate()
228            .max_by(|(_, a), (_, b)| {
229                a.abs()
230                    .partial_cmp(&b.abs())
231                    .unwrap_or(std::cmp::Ordering::Equal)
232            })
233            .unwrap_or((self.max_lag, &0.0));
234        idx as i64 - self.max_lag as i64
235    }
236
237    /// Return the cross-correlation value at a given lag.
238    pub fn at_lag(&self, lag: i64) -> f64 {
239        let idx = lag + self.max_lag as i64;
240        if idx < 0 || idx as usize >= self.values.len() {
241            return 0.0;
242        }
243        self.values[idx as usize]
244    }
245}
246
247// ─────────────────────────────────────────────────────────────────────────────
248// PowerSpectralDensity
249// ─────────────────────────────────────────────────────────────────────────────
250
251/// Power Spectral Density estimate via Welch's method.
252///
253/// The signal is divided into overlapping segments, each windowed with a Hann
254/// window and FFT'd, and the resulting periodograms are averaged.
255#[derive(Debug, Clone)]
256pub struct PowerSpectralDensity {
257    /// Frequency bins (Hz).
258    pub frequencies: Vec<f64>,
259    /// PSD values at each frequency bin.
260    pub power: Vec<f64>,
261    /// Sampling frequency (Hz).
262    pub fs: f64,
263    /// FFT window length used.
264    pub window_len: usize,
265}
266
267impl PowerSpectralDensity {
268    /// Estimate PSD using Welch's method.
269    ///
270    /// `fs` is the sampling frequency.
271    /// `window_len` is the segment length (will be rounded to power-of-two).
272    /// `overlap` in \[0, 1) fraction of the window to overlap.
273    pub fn welch(signal: &[f64], fs: f64, window_len: usize, overlap: f64) -> Self {
274        let n = signal.len();
275        let seg_len = next_pow2(window_len.min(n));
276        let step = ((seg_len as f64 * (1.0 - overlap.clamp(0.0, 0.99))) as usize).max(1);
277        let hann: Vec<f64> = (0..seg_len)
278            .map(|i| 0.5 * (1.0 - (2.0 * PI * i as f64 / (seg_len - 1) as f64).cos()))
279            .collect();
280        let win_power: f64 = hann.iter().map(|&w| w * w).sum::<f64>();
281
282        let half = seg_len / 2 + 1;
283        let mut psd_sum = vec![0.0_f64; half];
284        let mut n_segs = 0usize;
285
286        let mut start = 0;
287        while start + seg_len <= n {
288            let seg = &signal[start..start + seg_len];
289            let mut buf: Vec<C64> = seg
290                .iter()
291                .zip(hann.iter())
292                .map(|(&x, &w)| C64::new(x * w, 0.0))
293                .collect();
294            fft(&mut buf, false);
295            for k in 0..half {
296                let power = buf[k].re * buf[k].re + buf[k].im * buf[k].im;
297                // Double for all bins except DC and Nyquist
298                psd_sum[k] += if k == 0 || k == half - 1 {
299                    power
300                } else {
301                    2.0 * power
302                };
303            }
304            n_segs += 1;
305            start += step;
306        }
307        if n_segs == 0 {
308            n_segs = 1;
309        }
310        let scale = 1.0 / (fs * win_power * n_segs as f64);
311        let power: Vec<f64> = psd_sum.iter().map(|&p| p * scale).collect();
312        let frequencies: Vec<f64> = (0..half).map(|k| k as f64 * fs / seg_len as f64).collect();
313        Self {
314            frequencies,
315            power,
316            fs,
317            window_len: seg_len,
318        }
319    }
320
321    /// Return the frequency resolution (Hz per bin).
322    pub fn freq_resolution(&self) -> f64 {
323        if self.window_len > 0 {
324            self.fs / self.window_len as f64
325        } else {
326            0.0
327        }
328    }
329
330    /// Return the dominant frequency (frequency bin with maximum power).
331    pub fn dominant_frequency(&self) -> f64 {
332        self.frequencies
333            .iter()
334            .zip(self.power.iter())
335            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
336            .map(|(&f, _)| f)
337            .unwrap_or(0.0)
338    }
339
340    /// Compute total power (integral of PSD over all frequencies).
341    pub fn total_power(&self) -> f64 {
342        let df = self.freq_resolution();
343        self.power.iter().sum::<f64>() * df
344    }
345}
346
347// ─────────────────────────────────────────────────────────────────────────────
348// HilbertTransform
349// ─────────────────────────────────────────────────────────────────────────────
350
351/// Hilbert transform of a real signal, yielding the analytic signal.
352///
353/// The analytic signal is z\[n\] = x\[n\] + i*H{x}\[n\], where H{x} is the
354/// Hilbert transform.  From this, instantaneous amplitude, phase, and
355/// frequency can be extracted.
356#[derive(Debug, Clone)]
357pub struct HilbertTransform {
358    /// Real part of the analytic signal (original signal).
359    pub real_part: Vec<f64>,
360    /// Imaginary part of the analytic signal (Hilbert transform).
361    pub imaginary_part: Vec<f64>,
362    /// Instantaneous amplitude (envelope).
363    pub amplitude: Vec<f64>,
364    /// Instantaneous phase in radians.
365    pub phase: Vec<f64>,
366}
367
368impl HilbertTransform {
369    /// Compute the Hilbert transform of `signal` using FFT.
370    ///
371    /// Returns the analytic signal components.
372    pub fn compute(signal: &[f64]) -> Self {
373        let n = signal.len();
374        let n_fft = next_pow2(n);
375        let mut buf: Vec<C64> = signal
376            .iter()
377            .map(|&x| C64::new(x, 0.0))
378            .chain(std::iter::repeat(C64::new(0.0, 0.0)))
379            .take(n_fft)
380            .collect();
381        fft(&mut buf, false);
382
383        // Apply the one-sided filter: H[k] = 2 for 1..N/2, 1 at DC and Nyquist, 0 otherwise
384        for k in 0..n_fft {
385            if k == 0 || (n_fft.is_multiple_of(2) && k == n_fft / 2) {
386                // keep as-is
387            } else if k < n_fft / 2 {
388                buf[k].re *= 2.0;
389                buf[k].im *= 2.0;
390            } else {
391                buf[k] = C64::new(0.0, 0.0);
392            }
393        }
394        fft(&mut buf, true); // IFFT
395
396        let real_part: Vec<f64> = buf[..n].iter().map(|c| c.re).collect();
397        let imaginary_part: Vec<f64> = buf[..n].iter().map(|c| c.im).collect();
398        let amplitude: Vec<f64> = real_part
399            .iter()
400            .zip(imaginary_part.iter())
401            .map(|(&r, &i)| (r * r + i * i).sqrt())
402            .collect();
403        let phase: Vec<f64> = real_part
404            .iter()
405            .zip(imaginary_part.iter())
406            .map(|(&r, &i)| i.atan2(r))
407            .collect();
408        Self {
409            real_part,
410            imaginary_part,
411            amplitude,
412            phase,
413        }
414    }
415
416    /// Compute instantaneous frequency from the phase using finite differences.
417    ///
418    /// `fs` is the sampling frequency in Hz.
419    /// Phase unwrapping is applied before differencing.
420    pub fn instantaneous_frequency(&self, fs: f64) -> Vec<f64> {
421        let n = self.phase.len();
422        if n < 2 {
423            return vec![0.0; n];
424        }
425        // Unwrap phase
426        let mut unwrapped = self.phase.clone();
427        for i in 1..n {
428            let diff = unwrapped[i] - unwrapped[i - 1];
429            if diff > PI {
430                for v in unwrapped[i..].iter_mut() {
431                    *v -= 2.0 * PI;
432                }
433            } else if diff < -PI {
434                for v in unwrapped[i..].iter_mut() {
435                    *v += 2.0 * PI;
436                }
437            }
438        }
439        let mut freq = vec![0.0_f64; n];
440        for i in 1..(n - 1) {
441            freq[i] = (unwrapped[i + 1] - unwrapped[i - 1]) * fs / (4.0 * PI);
442        }
443        freq[0] = freq[1];
444        freq[n - 1] = freq[n - 2];
445        freq
446    }
447}
448
449// ─────────────────────────────────────────────────────────────────────────────
450// EMD (Empirical Mode Decomposition)
451// ─────────────────────────────────────────────────────────────────────────────
452
453/// Empirical Mode Decomposition (EMD) into Intrinsic Mode Functions (IMFs).
454///
455/// The sifting algorithm extracts IMFs by subtracting the mean envelope
456/// (computed from local extrema interpolation) until a stopping criterion
457/// is met.  The Hilbert-Huang spectrum can then be computed from the IMFs.
458#[derive(Debug, Clone)]
459pub struct EMD {
460    /// Extracted Intrinsic Mode Functions (IMFs).
461    pub imfs: Vec<Vec<f64>>,
462    /// Residual signal after all IMFs are extracted.
463    pub residual: Vec<f64>,
464}
465
466impl EMD {
467    /// Decompose `signal` into IMFs via the sifting algorithm.
468    ///
469    /// `max_imfs` limits the number of IMFs extracted.
470    /// `max_sift_iter` limits the sifting iterations per IMF.
471    /// `sift_threshold` is the stopping threshold for sifting.
472    pub fn decompose(
473        signal: &[f64],
474        max_imfs: usize,
475        max_sift_iter: usize,
476        sift_threshold: f64,
477    ) -> Self {
478        let n = signal.len();
479        let mut residual = signal.to_vec();
480        let mut imfs = Vec::new();
481
482        for _ in 0..max_imfs {
483            if residual.iter().all(|&x| x.abs() < sift_threshold) {
484                break;
485            }
486            let (maxima_idx, _) = find_local_maxima(&residual);
487            let (minima_idx, _) = find_local_minima(&residual);
488            if maxima_idx.len() < 2 || minima_idx.len() < 2 {
489                break;
490            }
491            let mut h = residual.clone();
492            for _ in 0..max_sift_iter {
493                let upper = cubic_spline_envelope(&h, &find_local_maxima(&h).0, true);
494                let lower = cubic_spline_envelope(&h, &find_local_minima(&h).0, false);
495                let mean_env: Vec<f64> = upper
496                    .iter()
497                    .zip(lower.iter())
498                    .map(|(u, l)| (u + l) / 2.0)
499                    .collect();
500                let prev = h.clone();
501                for i in 0..n {
502                    h[i] -= mean_env[i];
503                }
504                // SD stopping criterion
505                let sd: f64 = prev
506                    .iter()
507                    .zip(h.iter())
508                    .map(|(p, c)| (p - c) * (p - c) / (p * p + 1e-15))
509                    .sum::<f64>()
510                    / n as f64;
511                if sd < sift_threshold {
512                    break;
513                }
514            }
515            for i in 0..n {
516                residual[i] -= h[i];
517            }
518            imfs.push(h);
519        }
520        Self { imfs, residual }
521    }
522
523    /// Compute the Hilbert-Huang spectrum (instantaneous frequency vs time for each IMF).
524    ///
525    /// `fs` is the sampling frequency.
526    /// Returns one frequency vector per IMF.
527    pub fn hilbert_huang_spectrum(&self, fs: f64) -> Vec<Vec<f64>> {
528        self.imfs
529            .iter()
530            .map(|imf| {
531                let ht = HilbertTransform::compute(imf);
532                ht.instantaneous_frequency(fs)
533            })
534            .collect()
535    }
536}
537
538/// Find local maxima of a signal.  Returns (indices, values).
539fn find_local_maxima(x: &[f64]) -> (Vec<usize>, Vec<f64>) {
540    let n = x.len();
541    let mut idx = Vec::new();
542    let mut vals = Vec::new();
543    for i in 1..(n.saturating_sub(1)) {
544        if x[i] > x[i - 1] && x[i] > x[i + 1] {
545            idx.push(i);
546            vals.push(x[i]);
547        }
548    }
549    (idx, vals)
550}
551
552/// Find local minima of a signal.  Returns (indices, values).
553fn find_local_minima(x: &[f64]) -> (Vec<usize>, Vec<f64>) {
554    let n = x.len();
555    let mut idx = Vec::new();
556    let mut vals = Vec::new();
557    for i in 1..(n.saturating_sub(1)) {
558        if x[i] < x[i - 1] && x[i] < x[i + 1] {
559            idx.push(i);
560            vals.push(x[i]);
561        }
562    }
563    (idx, vals)
564}
565
566/// Build a piecewise-linear (degenerate cubic spline) envelope from extrema.
567fn cubic_spline_envelope(signal: &[f64], extrema_idx: &[usize], upper: bool) -> Vec<f64> {
568    let n = signal.len();
569    if extrema_idx.is_empty() {
570        let fill = if upper {
571            signal.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
572        } else {
573            signal.iter().cloned().fold(f64::INFINITY, f64::min)
574        };
575        return vec![fill; n];
576    }
577    // Add boundary extrema
578    let mut xs: Vec<f64> = Vec::new();
579    let mut ys: Vec<f64> = Vec::new();
580    xs.push(0.0);
581    ys.push(signal[extrema_idx[0]]);
582    for &i in extrema_idx {
583        xs.push(i as f64);
584        ys.push(signal[i]);
585    }
586    xs.push((n - 1) as f64);
587    ys.push(signal[extrema_idx[extrema_idx.len() - 1]]);
588
589    // Piecewise linear interpolation
590    let mut env = vec![0.0_f64; n];
591    for i in 0..n {
592        let xi = i as f64;
593        // Find surrounding xs
594        let mut lo = 0usize;
595        let mut hi = xs.len() - 1;
596        for k in 0..(xs.len() - 1) {
597            if xs[k] <= xi && xi <= xs[k + 1] {
598                lo = k;
599                hi = k + 1;
600                break;
601            }
602        }
603        let t = if (xs[hi] - xs[lo]).abs() < 1e-15 {
604            0.0
605        } else {
606            (xi - xs[lo]) / (xs[hi] - xs[lo])
607        };
608        env[i] = ys[lo] + t * (ys[hi] - ys[lo]);
609    }
610    env
611}
612
613// ─────────────────────────────────────────────────────────────────────────────
614// PeakDetection
615// ─────────────────────────────────────────────────────────────────────────────
616
617/// Detected peak in a signal.
618#[derive(Debug, Clone, PartialEq)]
619pub struct Peak {
620    /// Sample index of the peak.
621    pub index: usize,
622    /// Signal value at the peak.
623    pub value: f64,
624    /// Prominence of the peak (height above surrounding baseline).
625    pub prominence: f64,
626    /// Estimated half-peak width (in samples).
627    pub width: f64,
628}
629
630/// Peak detection algorithm.
631#[derive(Debug, Clone)]
632pub struct PeakDetection {
633    /// Detected peaks.
634    pub peaks: Vec<Peak>,
635}
636
637impl PeakDetection {
638    /// Detect local maxima in `signal` above `min_height`.
639    ///
640    /// `min_distance` enforces a minimum sample separation between peaks.
641    /// `min_prominence` filters out peaks below this prominence.
642    pub fn find_peaks(
643        signal: &[f64],
644        min_height: f64,
645        min_distance: usize,
646        min_prominence: f64,
647    ) -> Self {
648        let n = signal.len();
649        // Collect raw local maxima
650        let mut candidates: Vec<usize> = (1..(n.saturating_sub(1)))
651            .filter(|&i| {
652                signal[i] > signal[i - 1] && signal[i] > signal[i + 1] && signal[i] >= min_height
653            })
654            .collect();
655
656        // Apply min_distance: keep only the tallest in each window
657        if min_distance > 0 {
658            candidates = apply_min_distance(signal, &candidates, min_distance);
659        }
660
661        // Compute prominence and width
662        let peaks: Vec<Peak> = candidates
663            .iter()
664            .filter_map(|&i| {
665                let prom = prominence(signal, i);
666                if prom < min_prominence {
667                    return None;
668                }
669                let w = half_peak_width(signal, i, signal[i] - prom / 2.0);
670                Some(Peak {
671                    index: i,
672                    value: signal[i],
673                    prominence: prom,
674                    width: w,
675                })
676            })
677            .collect();
678
679        Self { peaks }
680    }
681
682    /// Return peak indices sorted by decreasing prominence.
683    pub fn sorted_by_prominence(&self) -> Vec<usize> {
684        let mut sorted = self.peaks.clone();
685        sorted.sort_by(|a, b| {
686            b.prominence
687                .partial_cmp(&a.prominence)
688                .unwrap_or(std::cmp::Ordering::Equal)
689        });
690        sorted.iter().map(|p| p.index).collect()
691    }
692}
693
694fn apply_min_distance(signal: &[f64], candidates: &[usize], min_dist: usize) -> Vec<usize> {
695    let mut result: Vec<usize> = Vec::new();
696    for &c in candidates {
697        if result
698            .iter()
699            .all(|&r| (c as i64 - r as i64).unsigned_abs() as usize >= min_dist)
700        {
701            result.push(c);
702        } else {
703            // Replace if higher
704            if let Some(close) = result
705                .iter_mut()
706                .find(|r| ((c as i64 - **r as i64).unsigned_abs() as usize) < min_dist)
707                && signal[c] > signal[*close]
708            {
709                *close = c;
710            }
711        }
712    }
713    result
714}
715
716fn prominence(signal: &[f64], peak_idx: usize) -> f64 {
717    let n = signal.len();
718    let val = signal[peak_idx];
719    // Find the minimum between peak and nearest higher peak on each side
720    let left_min = (0..peak_idx)
721        .rev()
722        .take(100)
723        .map(|i| signal[i])
724        .fold(f64::INFINITY, f64::min);
725    let right_min = ((peak_idx + 1)..n.min(peak_idx + 101))
726        .map(|i| signal[i])
727        .fold(f64::INFINITY, f64::min);
728    let base = left_min.min(right_min);
729    if base.is_infinite() { val } else { val - base }
730}
731
732fn half_peak_width(signal: &[f64], peak_idx: usize, level: f64) -> f64 {
733    let n = signal.len();
734    let mut left = peak_idx;
735    let mut right = peak_idx;
736    while left > 0 && signal[left] > level {
737        left -= 1;
738    }
739    while right < n - 1 && signal[right] > level {
740        right += 1;
741    }
742    (right - left) as f64
743}
744
745// ─────────────────────────────────────────────────────────────────────────────
746// SignalFilter
747// ─────────────────────────────────────────────────────────────────────────────
748
749/// Collection of signal filtering methods.
750#[derive(Debug, Clone)]
751pub struct SignalFilter;
752
753impl SignalFilter {
754    /// Apply a simple moving average filter.
755    ///
756    /// The output at index `i` is the mean of `signal[i-window/2 .. i+window/2]`
757    /// (with boundary clamping).
758    pub fn moving_average(signal: &[f64], window: usize) -> Vec<f64> {
759        let n = signal.len();
760        let hw = window / 2;
761        (0..n)
762            .map(|i| {
763                let lo = i.saturating_sub(hw);
764                let hi = (i + hw + 1).min(n);
765                let count = hi - lo;
766                signal[lo..hi].iter().sum::<f64>() / count as f64
767            })
768            .collect()
769    }
770
771    /// Apply exponential smoothing (single exponential).
772    ///
773    /// `alpha` in (0, 1]: smoothing factor.  Larger alpha = less smoothing.
774    pub fn exponential_smoothing(signal: &[f64], alpha: f64) -> Vec<f64> {
775        let n = signal.len();
776        if n == 0 {
777            return vec![];
778        }
779        let alpha = alpha.clamp(1e-6, 1.0);
780        let mut out = vec![0.0_f64; n];
781        out[0] = signal[0];
782        for i in 1..n {
783            out[i] = alpha * signal[i] + (1.0 - alpha) * out[i - 1];
784        }
785        out
786    }
787
788    /// Apply a Savitzky-Golay filter (polynomial smoothing).
789    ///
790    /// `window` must be odd and >= `poly_order + 1`.
791    /// Uses a polynomial of degree `poly_order` fitted to each local window.
792    ///
793    /// This implementation uses a direct least-squares fit per window.
794    pub fn savitzky_golay(signal: &[f64], window: usize, poly_order: usize) -> Vec<f64> {
795        let n = signal.len();
796        if n == 0 || window > n {
797            return signal.to_vec();
798        }
799        let window = if window.is_multiple_of(2) {
800            window + 1
801        } else {
802            window
803        };
804        let hw = window / 2;
805        let poly_order = poly_order.min(window - 1);
806
807        let mut out = vec![0.0_f64; n];
808        for i in 0..n {
809            let lo = i.saturating_sub(hw);
810            let hi = (i + hw + 1).min(n);
811            let seg = &signal[lo..hi];
812            let m = seg.len();
813            let center = (i - lo) as f64;
814            // Build Vandermonde matrix and solve for central coefficient
815            // Using a simple analytical approximation: weighted polynomial fit
816            let xs: Vec<f64> = (0..m).map(|k| k as f64 - center).collect();
817            out[i] = poly_fit_center(seg, &xs, poly_order);
818        }
819        out
820    }
821
822    /// Apply a simple FIR low-pass filter using a Hann-windowed sinc kernel.
823    ///
824    /// `cutoff` is the normalized cutoff frequency (0..0.5).
825    /// `num_taps` is the number of filter taps (odd).
826    pub fn lowpass_fir(signal: &[f64], cutoff: f64, num_taps: usize) -> Vec<f64> {
827        let num_taps = if num_taps.is_multiple_of(2) {
828            num_taps + 1
829        } else {
830            num_taps
831        };
832        let hw = num_taps / 2;
833        // Build sinc kernel with Hann window
834        let kernel: Vec<f64> = (0..num_taps)
835            .map(|i| {
836                let k = i as f64 - hw as f64;
837                let sinc = if k.abs() < 1e-10 {
838                    2.0 * cutoff
839                } else {
840                    (2.0 * PI * cutoff * k).sin() / (PI * k)
841                };
842                let hann = 0.5 * (1.0 - (2.0 * PI * i as f64 / (num_taps - 1) as f64).cos());
843                sinc * hann
844            })
845            .collect();
846        // Normalize
847        let sum: f64 = kernel.iter().sum();
848        let kernel: Vec<f64> = kernel.iter().map(|&k| k / sum.max(1e-15)).collect();
849        // Convolve (valid mode padded with edge values)
850        let n = signal.len();
851        (0..n)
852            .map(|i| {
853                kernel
854                    .iter()
855                    .enumerate()
856                    .map(|(k, &w)| {
857                        let j = i as i64 + k as i64 - hw as i64;
858                        let j = j.clamp(0, n as i64 - 1) as usize;
859                        w * signal[j]
860                    })
861                    .sum()
862            })
863            .collect()
864    }
865}
866
867/// Fit a polynomial of degree `p` to `(xs, ys)` and return the value at x=0.
868fn poly_fit_center(ys: &[f64], xs: &[f64], p: usize) -> f64 {
869    let m = ys.len();
870    let deg = p + 1;
871    // Build Vandermonde matrix V (m x deg)
872    let mut vt: Vec<Vec<f64>> = vec![vec![0.0; m]; deg];
873    for (j, &x) in xs.iter().enumerate() {
874        let mut xp = 1.0_f64;
875        for i in 0..deg {
876            vt[i][j] = xp;
877            xp *= x;
878        }
879    }
880    // Compute V^T V and V^T y
881    let mut vtv = vec![vec![0.0_f64; deg]; deg];
882    let mut vty = vec![0.0_f64; deg];
883    for i in 0..deg {
884        for k in 0..m {
885            vty[i] += vt[i][k] * ys[k];
886            for j in 0..deg {
887                vtv[i][j] += vt[i][k] * vt[j][k];
888            }
889        }
890    }
891    // Solve via Gaussian elimination
892    let coeffs = gaussian_elimination(&mut vtv, &mut vty);
893    // Evaluate at x=0: only constant term
894    coeffs.first().copied().unwrap_or(ys[m / 2])
895}
896
897/// Gaussian elimination for Ax = b (modifies A and b in place).
898fn gaussian_elimination(a: &mut Vec<Vec<f64>>, b: &mut Vec<f64>) -> Vec<f64> {
899    let n = b.len();
900    for col in 0..n {
901        // Partial pivot
902        let pivot = (col..n)
903            .max_by(|&i, &j| {
904                a[i][col]
905                    .abs()
906                    .partial_cmp(&a[j][col].abs())
907                    .unwrap_or(std::cmp::Ordering::Equal)
908            })
909            .unwrap_or(col);
910        a.swap(col, pivot);
911        b.swap(col, pivot);
912        if a[col][col].abs() < 1e-15 {
913            continue;
914        }
915        let diag = a[col][col];
916        for row in (col + 1)..n {
917            let factor = a[row][col] / diag;
918            for k in col..n {
919                let v = a[col][k];
920                a[row][k] -= factor * v;
921            }
922            let bv = b[col];
923            b[row] -= factor * bv;
924        }
925    }
926    // Back substitution
927    let mut x = vec![0.0_f64; n];
928    for i in (0..n).rev() {
929        let s: f64 = (i + 1..n).map(|j| a[i][j] * x[j]).sum();
930        x[i] = if a[i][i].abs() > 1e-15 {
931            (b[i] - s) / a[i][i]
932        } else {
933            0.0
934        };
935    }
936    x
937}
938
939// ─────────────────────────────────────────────────────────────────────────────
940// Tests
941// ─────────────────────────────────────────────────────────────────────────────
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946
947    fn sine_wave(n: usize, freq: f64, fs: f64) -> Vec<f64> {
948        (0..n)
949            .map(|i| (2.0 * PI * freq * i as f64 / fs).sin())
950            .collect()
951    }
952
953    fn ramp(n: usize) -> Vec<f64> {
954        (0..n).map(|i| i as f64).collect()
955    }
956
957    // ── AutoCorrelation ───────────────────────────────────────────────────────
958
959    #[test]
960    fn test_autocorr_lag0_equals_variance() {
961        let sig = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0];
962        let ac = AutoCorrelation::compute(&sig, 2, false, false);
963        let mean = sig.iter().sum::<f64>() / sig.len() as f64;
964        let var: f64 = sig.iter().map(|&x| (x - mean) * (x - mean)).sum::<f64>() / sig.len() as f64;
965        assert!((ac.at_lag(0) - var).abs() < 1e-10);
966    }
967
968    #[test]
969    fn test_autocorr_normalized_lag0_is_one() {
970        let sig: Vec<f64> = (0..20).map(|i| (i as f64).sin()).collect();
971        let ac = AutoCorrelation::compute(&sig, 5, false, true);
972        assert!((ac.at_lag(0) - 1.0_f64).abs() < 1e-10);
973    }
974
975    #[test]
976    fn test_autocorr_periodic_signal() {
977        let n = 128;
978        let sig = sine_wave(n, 8.0, n as f64);
979        let ac = AutoCorrelation::compute(&sig, n / 2, false, true);
980        // For a pure sine, autocorr at lag n/freq should be close to 1
981        let period = (n as f64 / 8.0) as usize;
982        assert!(ac.at_lag(period).abs() > 0.5);
983    }
984
985    #[test]
986    fn test_autocorr_unbiased_vs_biased() {
987        let sig = vec![1.0_f64, 2.0, 3.0, 4.0];
988        let biased = AutoCorrelation::compute(&sig, 1, false, false);
989        let unbiased = AutoCorrelation::compute(&sig, 1, true, false);
990        // Biased uses N, unbiased uses N-k; at lag 1 unbiased should be larger
991        assert!(unbiased.at_lag(1).abs() >= biased.at_lag(1).abs());
992    }
993
994    #[test]
995    fn test_autocorr_constant_signal_zero_lag_positive() {
996        let sig = vec![3.0_f64; 10];
997        let ac = AutoCorrelation::compute(&sig, 3, false, false);
998        assert!(ac.at_lag(0) >= 0.0);
999    }
1000
1001    #[test]
1002    fn test_autocorr_dominant_lag_returns_index() {
1003        let sig: Vec<f64> = (0..20).map(|i| (i as f64).sin()).collect();
1004        let ac = AutoCorrelation::compute(&sig, 10, false, false);
1005        let dl = ac.dominant_lag();
1006        assert!((1..=10).contains(&dl));
1007    }
1008
1009    // ── CrossCorrelation ──────────────────────────────────────────────────────
1010
1011    #[test]
1012    fn test_crosscorr_identical_signals() {
1013        let sig = vec![1.0_f64, 2.0, 3.0, 2.0, 1.0];
1014        let cc = CrossCorrelation::compute(&sig, &sig, 2);
1015        // Peak should be at lag 0
1016        assert_eq!(cc.peak_lag(), 0);
1017    }
1018
1019    #[test]
1020    fn test_crosscorr_shifted_signal() {
1021        let n = 32;
1022        // Use delay=2 with period=8 and max_lag=5 so the aliased peak at
1023        // -(period - delay) = -6 falls outside the search range, giving a
1024        // unique positive peak at lag = -delay = -2.
1025        let delay = 2usize;
1026        let x: Vec<f64> = (0..n).map(|i| (2.0 * PI * i as f64 / 8.0).sin()).collect();
1027        let y: Vec<f64> = (0..n)
1028            .map(|i| (2.0 * PI * (i + delay) as f64 / 8.0).sin())
1029            .collect();
1030        let cc = CrossCorrelation::compute(&x, &y, 5);
1031        let lag = cc.peak_lag();
1032        // R_xy(k) = -½ sin(2πk/8); maximum positive value at k = -delay = -2
1033        assert!(
1034            (lag + delay as i64).abs() <= 1,
1035            "lag={lag}, expected near -{delay}"
1036        );
1037    }
1038
1039    #[test]
1040    fn test_crosscorr_at_lag() {
1041        let x = vec![1.0_f64, 0.0, -1.0, 0.0, 1.0];
1042        let y = vec![0.0_f64, 1.0, 0.0, -1.0, 0.0];
1043        let cc = CrossCorrelation::compute(&x, &y, 2);
1044        // Value at lag 0 should be finite
1045        assert!(cc.at_lag(0).is_finite());
1046    }
1047
1048    #[test]
1049    fn test_crosscorr_out_of_range_lag() {
1050        let x = vec![1.0_f64, 2.0];
1051        let y = vec![1.0_f64, 2.0];
1052        let cc = CrossCorrelation::compute(&x, &y, 1);
1053        assert_eq!(cc.at_lag(100), 0.0);
1054    }
1055
1056    #[test]
1057    fn test_crosscorr_values_length() {
1058        let x = vec![1.0_f64, 2.0, 3.0, 4.0, 5.0];
1059        let y = vec![5.0_f64, 4.0, 3.0, 2.0, 1.0];
1060        let cc = CrossCorrelation::compute(&x, &y, 3);
1061        assert_eq!(cc.values.len(), 7); // 2*3+1
1062    }
1063
1064    // ── PowerSpectralDensity ──────────────────────────────────────────────────
1065
1066    #[test]
1067    fn test_psd_frequencies_start_at_zero() {
1068        let sig = vec![0.0_f64; 64];
1069        let psd = PowerSpectralDensity::welch(&sig, 100.0, 32, 0.5);
1070        assert!((psd.frequencies[0]).abs() < 1e-12);
1071    }
1072
1073    #[test]
1074    fn test_psd_power_nonneg() {
1075        let sig: Vec<f64> = (0..128)
1076            .map(|i| (2.0 * PI * 10.0 * i as f64 / 128.0).sin())
1077            .collect();
1078        let psd = PowerSpectralDensity::welch(&sig, 128.0, 32, 0.5);
1079        assert!(psd.power.iter().all(|&p| p >= 0.0));
1080    }
1081
1082    #[test]
1083    fn test_psd_dominant_freq() {
1084        let fs = 256.0_f64;
1085        let f0 = 16.0_f64;
1086        let sig: Vec<f64> = (0..256)
1087            .map(|i| (2.0 * PI * f0 * i as f64 / fs).sin())
1088            .collect();
1089        let psd = PowerSpectralDensity::welch(&sig, fs, 64, 0.5);
1090        let dominant = psd.dominant_frequency();
1091        assert!(
1092            (dominant - f0).abs() < 5.0,
1093            "expected ~{f0} Hz, got {dominant}"
1094        );
1095    }
1096
1097    #[test]
1098    fn test_psd_freq_resolution() {
1099        let sig = vec![0.0_f64; 64];
1100        let psd = PowerSpectralDensity::welch(&sig, 100.0, 32, 0.0);
1101        let df = psd.freq_resolution();
1102        assert!(df > 0.0);
1103    }
1104
1105    #[test]
1106    fn test_psd_total_power_nonneg() {
1107        let sig: Vec<f64> = (0..64).map(|i| i as f64).collect();
1108        let psd = PowerSpectralDensity::welch(&sig, 64.0, 16, 0.0);
1109        assert!(psd.total_power() >= 0.0);
1110    }
1111
1112    // ── HilbertTransform ──────────────────────────────────────────────────────
1113
1114    #[test]
1115    fn test_hilbert_real_part_matches_input() {
1116        let sig: Vec<f64> = (0..32).map(|i| (2.0 * PI * i as f64 / 8.0).sin()).collect();
1117        let ht = HilbertTransform::compute(&sig);
1118        // Real part may deviate slightly due to zero-padding
1119        for (r, s) in ht.real_part.iter().zip(sig.iter()) {
1120            assert!((r - s).abs() < 0.2, "real part deviation: {} vs {}", r, s);
1121        }
1122    }
1123
1124    #[test]
1125    fn test_hilbert_amplitude_nonneg() {
1126        let sig: Vec<f64> = (0..64).map(|i| (2.0 * PI * i as f64 / 8.0).sin()).collect();
1127        let ht = HilbertTransform::compute(&sig);
1128        assert!(ht.amplitude.iter().all(|&a| a >= 0.0));
1129    }
1130
1131    #[test]
1132    fn test_hilbert_phase_range() {
1133        let sig: Vec<f64> = (0..64).map(|i| (2.0 * PI * i as f64 / 8.0).sin()).collect();
1134        let ht = HilbertTransform::compute(&sig);
1135        assert!(
1136            ht.phase
1137                .iter()
1138                .all(|&p| (-PI - 1e-9..=PI + 1e-9).contains(&p))
1139        );
1140    }
1141
1142    #[test]
1143    fn test_hilbert_instantaneous_freq() {
1144        let fs = 64.0_f64;
1145        let f0 = 4.0_f64;
1146        let sig: Vec<f64> = (0..64)
1147            .map(|i| (2.0 * PI * f0 * i as f64 / fs).sin())
1148            .collect();
1149        let ht = HilbertTransform::compute(&sig);
1150        let ifreq = ht.instantaneous_frequency(fs);
1151        // Central samples should be close to f0
1152        let mid_freq: f64 = ifreq[16..48].iter().sum::<f64>() / 32.0;
1153        assert!(
1154            (mid_freq - f0).abs() < 2.0,
1155            "instantaneous freq {mid_freq} vs expected {f0}"
1156        );
1157    }
1158
1159    #[test]
1160    fn test_hilbert_analytic_length() {
1161        let n = 50;
1162        let sig = vec![1.0_f64; n];
1163        let ht = HilbertTransform::compute(&sig);
1164        assert_eq!(ht.real_part.len(), n);
1165        assert_eq!(ht.imaginary_part.len(), n);
1166        assert_eq!(ht.amplitude.len(), n);
1167        assert_eq!(ht.phase.len(), n);
1168    }
1169
1170    // ── EMD ───────────────────────────────────────────────────────────────────
1171
1172    #[test]
1173    fn test_emd_sum_of_imfs_plus_residual() {
1174        let sig: Vec<f64> = (0..64)
1175            .map(|i| (2.0 * PI * i as f64 / 8.0).sin() + 0.5 * (2.0 * PI * i as f64 / 16.0).sin())
1176            .collect();
1177        let emd = EMD::decompose(&sig, 3, 10, 0.05);
1178        let mut recon = emd.residual.clone();
1179        for imf in &emd.imfs {
1180            for (i, &v) in imf.iter().enumerate() {
1181                recon[i] += v;
1182            }
1183        }
1184        let err: f64 = sig
1185            .iter()
1186            .zip(recon.iter())
1187            .map(|(a, b)| (a - b) * (a - b))
1188            .sum::<f64>();
1189        assert!(err.sqrt() < 1.0, "reconstruction error: {err}");
1190    }
1191
1192    #[test]
1193    fn test_emd_imfs_not_empty_for_rich_signal() {
1194        let sig: Vec<f64> = (0..64)
1195            .map(|i| (2.0 * PI * i as f64 / 8.0).sin() + 0.3 * (i as f64).cos())
1196            .collect();
1197        let emd = EMD::decompose(&sig, 4, 10, 0.01);
1198        assert!(!emd.imfs.is_empty());
1199    }
1200
1201    #[test]
1202    fn test_emd_hilbert_huang_spectrum_shape() {
1203        let sig: Vec<f64> = (0..32).map(|i| (2.0 * PI * i as f64 / 8.0).sin()).collect();
1204        let emd = EMD::decompose(&sig, 2, 5, 0.1);
1205        let hhs = emd.hilbert_huang_spectrum(32.0);
1206        for row in &hhs {
1207            assert_eq!(row.len(), 32);
1208        }
1209    }
1210
1211    // ── PeakDetection ─────────────────────────────────────────────────────────
1212
1213    #[test]
1214    fn test_peaks_simple_sine() {
1215        let n = 64;
1216        let sig: Vec<f64> = (0..n).map(|i| (2.0 * PI * i as f64 / 8.0).sin()).collect();
1217        let pd = PeakDetection::find_peaks(&sig, 0.5, 4, 0.0);
1218        assert!(!pd.peaks.is_empty());
1219    }
1220
1221    #[test]
1222    fn test_peaks_heights_above_threshold() {
1223        let sig = vec![0.0_f64, 1.0, 0.5, 2.0, 0.3, 1.5, 0.0];
1224        let pd = PeakDetection::find_peaks(&sig, 0.8, 1, 0.0);
1225        for p in &pd.peaks {
1226            assert!(p.value >= 0.8_f64);
1227        }
1228    }
1229
1230    #[test]
1231    fn test_peaks_no_peaks_in_flat_signal() {
1232        let sig = vec![1.0_f64; 20];
1233        let pd = PeakDetection::find_peaks(&sig, 0.5, 1, 0.0);
1234        assert!(pd.peaks.is_empty());
1235    }
1236
1237    #[test]
1238    fn test_peaks_prominence_positive() {
1239        let sig = vec![0.0_f64, 1.0, 0.0, 2.0, 0.0];
1240        let pd = PeakDetection::find_peaks(&sig, 0.0, 1, 0.0);
1241        for p in &pd.peaks {
1242            assert!(p.prominence >= 0.0);
1243        }
1244    }
1245
1246    #[test]
1247    fn test_peaks_sorted_by_prominence() {
1248        let sig = vec![0.0_f64, 1.0, 0.0, 3.0, 0.0, 2.0, 0.0];
1249        let pd = PeakDetection::find_peaks(&sig, 0.0, 1, 0.0);
1250        let sorted = pd.sorted_by_prominence();
1251        // Index of highest peak (3.0 at index 3) should come first
1252        if !sorted.is_empty() {
1253            assert_eq!(sorted[0], 3);
1254        }
1255    }
1256
1257    #[test]
1258    fn test_peaks_width_nonneg() {
1259        let sig: Vec<f64> = (0..32).map(|i| (2.0 * PI * i as f64 / 8.0).sin()).collect();
1260        let pd = PeakDetection::find_peaks(&sig, 0.0, 2, 0.0);
1261        for p in &pd.peaks {
1262            assert!(p.width >= 0.0);
1263        }
1264    }
1265
1266    // ── SignalFilter ──────────────────────────────────────────────────────────
1267
1268    #[test]
1269    fn test_moving_average_constant() {
1270        let sig = vec![5.0_f64; 20];
1271        let filtered = SignalFilter::moving_average(&sig, 5);
1272        for &v in &filtered {
1273            assert!((v - 5.0_f64).abs() < 1e-12);
1274        }
1275    }
1276
1277    #[test]
1278    fn test_moving_average_length_preserved() {
1279        let sig = ramp(30);
1280        let out = SignalFilter::moving_average(&sig, 5);
1281        assert_eq!(out.len(), 30);
1282    }
1283
1284    #[test]
1285    fn test_exp_smoothing_constant_signal() {
1286        let sig = vec![3.0_f64; 10];
1287        let out = SignalFilter::exponential_smoothing(&sig, 0.3);
1288        // Should converge to 3
1289        assert!((out.last().unwrap() - 3.0_f64).abs() < 0.1);
1290    }
1291
1292    #[test]
1293    fn test_exp_smoothing_length_preserved() {
1294        let sig = ramp(15);
1295        let out = SignalFilter::exponential_smoothing(&sig, 0.5);
1296        assert_eq!(out.len(), 15);
1297    }
1298
1299    #[test]
1300    fn test_exp_smoothing_smooths_noise() {
1301        let sig: Vec<f64> = (0..20)
1302            .map(|i| if i % 2 == 0 { 1.0_f64 } else { -1.0_f64 })
1303            .collect();
1304        let out = SignalFilter::exponential_smoothing(&sig, 0.1);
1305        // Output should have smaller range than input
1306        let range_out = out.iter().cloned().fold(f64::NEG_INFINITY, f64::max)
1307            - out.iter().cloned().fold(f64::INFINITY, f64::min);
1308        let range_in = 2.0_f64;
1309        assert!(range_out < range_in);
1310    }
1311
1312    #[test]
1313    fn test_savitzky_golay_constant() {
1314        let sig = vec![7.0_f64; 20];
1315        let out = SignalFilter::savitzky_golay(&sig, 5, 2);
1316        for &v in &out {
1317            assert!((v - 7.0_f64).abs() < 1e-8);
1318        }
1319    }
1320
1321    #[test]
1322    fn test_savitzky_golay_length_preserved() {
1323        let sig = ramp(25);
1324        let out = SignalFilter::savitzky_golay(&sig, 5, 2);
1325        assert_eq!(out.len(), 25);
1326    }
1327
1328    #[test]
1329    fn test_lowpass_fir_length_preserved() {
1330        let sig: Vec<f64> = (0..64).map(|i| i as f64).collect();
1331        let out = SignalFilter::lowpass_fir(&sig, 0.1, 15);
1332        assert_eq!(out.len(), 64);
1333    }
1334
1335    #[test]
1336    fn test_lowpass_fir_attenuates_high_freq() {
1337        let n = 256;
1338        let fs = 256.0_f64;
1339        // Mix of low (4 Hz) and high (80 Hz) frequency
1340        let sig: Vec<f64> = (0..n)
1341            .map(|i| {
1342                (2.0 * PI * 4.0 * i as f64 / fs).sin() + (2.0 * PI * 80.0 * i as f64 / fs).sin()
1343            })
1344            .collect();
1345        let cutoff = 0.1_f64; // normalized ~25.6 Hz
1346        let out = SignalFilter::lowpass_fir(&sig, cutoff, 31);
1347        // Power of output should be less than input
1348        let p_in: f64 = sig.iter().map(|x| x * x).sum::<f64>();
1349        let p_out: f64 = out.iter().map(|x| x * x).sum::<f64>();
1350        assert!(p_out < p_in);
1351    }
1352}