Skip to main content

oxiphysics_io/
time_series_io.rs

1#![allow(clippy::needless_range_loop)]
2// Copyright 2026 COOLJAPAN OU (Team KitaSan)
3// SPDX-License-Identifier: Apache-2.0
4
5//! Time series I/O: storage, resampling, filtering, FFT, spectral analysis,
6//! autocorrelation/cross-correlation, peak detection, anomaly detection,
7//! CSV/JSON/binary export, and streaming/buffered I/O for large time series.
8
9use std::collections::HashMap;
10use std::fmt;
11use std::io::{self, Write};
12
13// ---------------------------------------------------------------------------
14// Error type
15// ---------------------------------------------------------------------------
16
17/// Errors produced by the time-series I/O subsystem.
18#[allow(dead_code)]
19#[derive(Debug)]
20pub enum TsError {
21    /// Length mismatch between two arrays.
22    LengthMismatch {
23        /// Expected length.
24        expected: usize,
25        /// Actual length received.
26        got: usize,
27    },
28    /// The sample rate is zero or negative.
29    InvalidSampleRate(f64),
30    /// Requested channel index does not exist.
31    ChannelNotFound(usize),
32    /// Named channel does not exist.
33    ChannelNameNotFound(String),
34    /// Not enough samples for the operation.
35    InsufficientSamples {
36        /// Required minimum number of samples.
37        need: usize,
38        /// Actual number of samples present.
39        have: usize,
40    },
41    /// FFT size is not a power of two.
42    FftSizeNotPow2(usize),
43    /// Generic I/O error.
44    Io(String),
45    /// Parse error.
46    Parse(String),
47}
48
49impl fmt::Display for TsError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::LengthMismatch { expected, got } => {
53                write!(f, "length mismatch: expected {expected}, got {got}")
54            }
55            Self::InvalidSampleRate(r) => write!(f, "invalid sample rate: {r}"),
56            Self::ChannelNotFound(i) => write!(f, "channel index {i} not found"),
57            Self::ChannelNameNotFound(n) => write!(f, "channel '{n}' not found"),
58            Self::InsufficientSamples { need, have } => {
59                write!(f, "need {need} samples, have {have}")
60            }
61            Self::FftSizeNotPow2(n) => write!(f, "FFT size {n} is not a power of two"),
62            Self::Io(s) => write!(f, "I/O error: {s}"),
63            Self::Parse(s) => write!(f, "parse error: {s}"),
64        }
65    }
66}
67
68impl std::error::Error for TsError {}
69
70// ---------------------------------------------------------------------------
71// TimeStamp and sample
72// ---------------------------------------------------------------------------
73
74/// A single time-stamped scalar sample.
75#[allow(dead_code)]
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct Sample {
78    /// Timestamp in seconds (or any consistent unit).
79    pub time: f64,
80    /// Scalar measurement value.
81    pub value: f64,
82}
83
84impl Sample {
85    /// Construct a new sample.
86    #[allow(dead_code)]
87    pub fn new(time: f64, value: f64) -> Self {
88        Self { time, value }
89    }
90}
91
92// ---------------------------------------------------------------------------
93// Channel — one named signal with uniform or non-uniform timestamps
94// ---------------------------------------------------------------------------
95
96/// A single named signal channel.
97#[allow(dead_code)]
98#[derive(Debug, Clone)]
99pub struct Channel {
100    /// Human-readable name, e.g. `"temperature"`.
101    pub name: String,
102    /// Physical unit string, e.g. `"K"` or `"m/s"`.
103    pub unit: String,
104    /// Ordered time-stamp array (seconds).
105    pub times: Vec<f64>,
106    /// Value array, same length as `times`.
107    pub values: Vec<f64>,
108}
109
110impl Channel {
111    /// Create a new empty channel.
112    #[allow(dead_code)]
113    pub fn new(name: impl Into<String>, unit: impl Into<String>) -> Self {
114        Self {
115            name: name.into(),
116            unit: unit.into(),
117            times: Vec::new(),
118            values: Vec::new(),
119        }
120    }
121
122    /// Push one sample.
123    #[allow(dead_code)]
124    pub fn push(&mut self, time: f64, value: f64) {
125        self.times.push(time);
126        self.values.push(value);
127    }
128
129    /// Number of samples.
130    #[allow(dead_code)]
131    pub fn len(&self) -> usize {
132        self.times.len()
133    }
134
135    /// Return `true` when there are no samples.
136    #[allow(dead_code)]
137    pub fn is_empty(&self) -> bool {
138        self.times.is_empty()
139    }
140
141    /// Minimum value over all samples, or `NAN` if empty.
142    #[allow(dead_code)]
143    pub fn min_value(&self) -> f64 {
144        self.values.iter().cloned().fold(f64::INFINITY, f64::min)
145    }
146
147    /// Maximum value over all samples, or `NAN` if empty.
148    #[allow(dead_code)]
149    pub fn max_value(&self) -> f64 {
150        self.values
151            .iter()
152            .cloned()
153            .fold(f64::NEG_INFINITY, f64::max)
154    }
155
156    /// Arithmetic mean of all values, or `NAN` if empty.
157    #[allow(dead_code)]
158    pub fn mean(&self) -> f64 {
159        if self.values.is_empty() {
160            return f64::NAN;
161        }
162        self.values.iter().sum::<f64>() / self.values.len() as f64
163    }
164
165    /// Sample variance (population).
166    #[allow(dead_code)]
167    pub fn variance(&self) -> f64 {
168        if self.values.is_empty() {
169            return f64::NAN;
170        }
171        let m = self.mean();
172        self.values.iter().map(|v| (v - m).powi(2)).sum::<f64>() / self.values.len() as f64
173    }
174
175    /// Standard deviation (population).
176    #[allow(dead_code)]
177    pub fn std_dev(&self) -> f64 {
178        self.variance().sqrt()
179    }
180
181    /// Linearly interpolate the value at arbitrary time `t`.
182    /// Clamps to the boundary if `t` is out of range.
183    #[allow(dead_code)]
184    pub fn interp_linear(&self, t: f64) -> f64 {
185        let n = self.times.len();
186        if n == 0 {
187            return f64::NAN;
188        }
189        if t <= self.times[0] {
190            return self.values[0];
191        }
192        if t >= self.times[n - 1] {
193            return self.values[n - 1];
194        }
195        // binary search
196        let pos = self.times.partition_point(|&x| x <= t);
197        let i = pos.saturating_sub(1);
198        let j = i + 1;
199        let dt = self.times[j] - self.times[i];
200        if dt.abs() < 1e-300 {
201            return self.values[i];
202        }
203        let alpha = (t - self.times[i]) / dt;
204        self.values[i] * (1.0 - alpha) + self.values[j] * alpha
205    }
206
207    /// Cubic (Catmull-Rom) spline interpolation at time `t`.
208    #[allow(dead_code)]
209    pub fn interp_cubic(&self, t: f64) -> f64 {
210        let n = self.times.len();
211        if n < 4 {
212            return self.interp_linear(t);
213        }
214        if t <= self.times[0] {
215            return self.values[0];
216        }
217        if t >= self.times[n - 1] {
218            return self.values[n - 1];
219        }
220        let pos = self.times.partition_point(|&x| x <= t);
221        let i1 = pos.saturating_sub(1).min(n - 2);
222        let i0 = i1.saturating_sub(1);
223        let i2 = (i1 + 1).min(n - 1);
224        let i3 = (i1 + 2).min(n - 1);
225        let dt = self.times[i2] - self.times[i1];
226        let alpha = if dt.abs() < 1e-300 {
227            0.0
228        } else {
229            (t - self.times[i1]) / dt
230        };
231        catmull_rom(
232            self.values[i0],
233            self.values[i1],
234            self.values[i2],
235            self.values[i3],
236            alpha,
237        )
238    }
239}
240
241/// Catmull-Rom spline helper (one-dimensional).
242#[allow(dead_code)]
243fn catmull_rom(p0: f64, p1: f64, p2: f64, p3: f64, t: f64) -> f64 {
244    let t2 = t * t;
245    let t3 = t2 * t;
246    0.5 * ((2.0 * p1)
247        + (-p0 + p2) * t
248        + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2
249        + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3)
250}
251
252// ---------------------------------------------------------------------------
253// MultiChannelSeries — container for N synchronized channels
254// ---------------------------------------------------------------------------
255
256/// Multi-channel time series container.
257#[allow(dead_code)]
258#[derive(Debug, Clone, Default)]
259pub struct MultiChannelSeries {
260    /// Ordered channel list.
261    pub channels: Vec<Channel>,
262    /// Optional metadata key-value store.
263    pub metadata: HashMap<String, String>,
264}
265
266impl MultiChannelSeries {
267    /// Create an empty container.
268    #[allow(dead_code)]
269    pub fn new() -> Self {
270        Self::default()
271    }
272
273    /// Add a channel and return its index.
274    #[allow(dead_code)]
275    pub fn add_channel(&mut self, ch: Channel) -> usize {
276        let idx = self.channels.len();
277        self.channels.push(ch);
278        idx
279    }
280
281    /// Number of channels.
282    #[allow(dead_code)]
283    pub fn num_channels(&self) -> usize {
284        self.channels.len()
285    }
286
287    /// Borrow channel by index.
288    #[allow(dead_code)]
289    pub fn channel(&self, idx: usize) -> Option<&Channel> {
290        self.channels.get(idx)
291    }
292
293    /// Mutable borrow channel by index.
294    #[allow(dead_code)]
295    pub fn channel_mut(&mut self, idx: usize) -> Option<&mut Channel> {
296        self.channels.get_mut(idx)
297    }
298
299    /// Find a channel by name.
300    #[allow(dead_code)]
301    pub fn channel_by_name(&self, name: &str) -> Option<&Channel> {
302        self.channels.iter().find(|c| c.name == name)
303    }
304
305    /// Insert metadata.
306    #[allow(dead_code)]
307    pub fn set_metadata(&mut self, key: impl Into<String>, value: impl Into<String>) {
308        self.metadata.insert(key.into(), value.into());
309    }
310}
311
312// ---------------------------------------------------------------------------
313// Resampling
314// ---------------------------------------------------------------------------
315
316/// Resampling method.
317#[allow(dead_code)]
318#[derive(Debug, Clone, Copy, PartialEq)]
319pub enum ResampleMethod {
320    /// Piecewise linear interpolation.
321    Linear,
322    /// Catmull-Rom cubic spline.
323    CubicSpline,
324}
325
326/// Resample `channel` onto a new uniform grid with `n_samples` points
327/// spanning from `t_start` to `t_end`.
328#[allow(dead_code)]
329pub fn resample_uniform(
330    channel: &Channel,
331    t_start: f64,
332    t_end: f64,
333    n_samples: usize,
334    method: ResampleMethod,
335) -> Result<Channel, TsError> {
336    if n_samples < 2 {
337        return Err(TsError::InsufficientSamples {
338            need: 2,
339            have: n_samples,
340        });
341    }
342    let mut out = Channel::new(channel.name.clone(), channel.unit.clone());
343    let dt = (t_end - t_start) / (n_samples - 1) as f64;
344    for i in 0..n_samples {
345        let t = t_start + i as f64 * dt;
346        let v = match method {
347            ResampleMethod::Linear => channel.interp_linear(t),
348            ResampleMethod::CubicSpline => channel.interp_cubic(t),
349        };
350        out.push(t, v);
351    }
352    Ok(out)
353}
354
355/// Resample `channel` onto an explicit list of target timestamps.
356#[allow(dead_code)]
357pub fn resample_to_times(
358    channel: &Channel,
359    target_times: &[f64],
360    method: ResampleMethod,
361) -> Channel {
362    let mut out = Channel::new(channel.name.clone(), channel.unit.clone());
363    for &t in target_times {
364        let v = match method {
365            ResampleMethod::Linear => channel.interp_linear(t),
366            ResampleMethod::CubicSpline => channel.interp_cubic(t),
367        };
368        out.push(t, v);
369    }
370    out
371}
372
373// ---------------------------------------------------------------------------
374// Moving-average filter
375// ---------------------------------------------------------------------------
376
377/// Apply a simple causal moving-average filter in-place.
378/// `window` is the number of samples (≥ 1).
379#[allow(dead_code)]
380pub fn moving_average(values: &[f64], window: usize) -> Vec<f64> {
381    let n = values.len();
382    let w = window.max(1);
383    let mut out = vec![0.0_f64; n];
384    let mut acc = 0.0_f64;
385    let mut count = 0_usize;
386    for (i, &v) in values.iter().enumerate() {
387        acc += v;
388        count += 1;
389        if count > w {
390            acc -= values[i - w];
391            count -= 1;
392        }
393        out[i] = acc / count as f64;
394    }
395    out
396}
397
398// ---------------------------------------------------------------------------
399// Butterworth IIR filter (2nd-order biquad sections)
400// ---------------------------------------------------------------------------
401
402/// Butterworth filter type.
403#[allow(dead_code)]
404#[derive(Debug, Clone, Copy, PartialEq)]
405pub enum FilterType {
406    /// Low-pass filter.
407    LowPass,
408    /// High-pass filter.
409    HighPass,
410}
411
412/// Coefficients for a single biquad (2nd-order IIR) section.
413#[allow(dead_code)]
414#[derive(Debug, Clone, Copy)]
415pub struct BiquadCoeffs {
416    /// Feed-forward coefficients b0, b1, b2.
417    pub b: [f64; 3],
418    /// Feed-back coefficients a1, a2 (a0 is normalised to 1).
419    pub a: [f64; 2],
420}
421
422impl BiquadCoeffs {
423    /// Design a single 2nd-order Butterworth section.
424    /// `fc` is the cut-off frequency in Hz, `fs` is the sample rate in Hz.
425    #[allow(dead_code)]
426    pub fn butterworth_2nd(fc: f64, fs: f64, filter_type: FilterType) -> Self {
427        // Bilinear-transform design for 2nd-order Butterworth
428        let omega = std::f64::consts::PI * fc / fs;
429        let k = omega.tan();
430        let k2 = k * k;
431        let sqrt2 = std::f64::consts::SQRT_2;
432        let norm = 1.0 / (1.0 + sqrt2 * k + k2);
433        match filter_type {
434            FilterType::LowPass => {
435                let b0 = k2 * norm;
436                let b1 = 2.0 * b0;
437                let b2 = b0;
438                let a1 = 2.0 * (k2 - 1.0) * norm;
439                let a2 = (1.0 - sqrt2 * k + k2) * norm;
440                Self {
441                    b: [b0, b1, b2],
442                    a: [a1, a2],
443                }
444            }
445            FilterType::HighPass => {
446                let b0 = norm;
447                let b1 = -2.0 * b0;
448                let b2 = b0;
449                let a1 = 2.0 * (k2 - 1.0) * norm;
450                let a2 = (1.0 - sqrt2 * k + k2) * norm;
451                Self {
452                    b: [b0, b1, b2],
453                    a: [a1, a2],
454                }
455            }
456        }
457    }
458
459    /// Apply this biquad section to `input`, return filtered output.
460    #[allow(dead_code)]
461    pub fn apply(&self, input: &[f64]) -> Vec<f64> {
462        let mut out = vec![0.0_f64; input.len()];
463        let mut w1 = 0.0_f64;
464        let mut w2 = 0.0_f64;
465        for (i, &x) in input.iter().enumerate() {
466            let w0 = x - self.a[0] * w1 - self.a[1] * w2;
467            out[i] = self.b[0] * w0 + self.b[1] * w1 + self.b[2] * w2;
468            w2 = w1;
469            w1 = w0;
470        }
471        out
472    }
473
474    /// Zero-phase forward-backward filtering (doubles the filter order).
475    #[allow(dead_code)]
476    pub fn apply_zero_phase(&self, input: &[f64]) -> Vec<f64> {
477        let forward = self.apply(input);
478        let rev: Vec<f64> = forward.iter().cloned().rev().collect();
479        let backward = self.apply(&rev);
480        backward.iter().cloned().rev().collect()
481    }
482}
483
484// ---------------------------------------------------------------------------
485// FFT (radix-2 Cooley-Tukey, in-place)
486// ---------------------------------------------------------------------------
487
488/// A complex number stored as `(real, imag)`.
489#[allow(dead_code)]
490pub type Complex = (f64, f64);
491
492/// Compute the in-place radix-2 DIT FFT of `buf` (length must be a power of 2).
493/// `inverse` == true → IFFT (no 1/N normalisation; caller must divide).
494#[allow(dead_code)]
495pub fn fft_inplace(buf: &mut [(f64, f64)], inverse: bool) {
496    let n = buf.len();
497    // Bit-reversal permutation
498    let mut j = 0_usize;
499    for i in 1..n {
500        let mut bit = n >> 1;
501        while j & bit != 0 {
502            j ^= bit;
503            bit >>= 1;
504        }
505        j ^= bit;
506        if i < j {
507            buf.swap(i, j);
508        }
509    }
510    // Cooley-Tukey butterfly
511    let sign = if inverse { 1.0_f64 } else { -1.0_f64 };
512    let mut len = 2_usize;
513    while len <= n {
514        let half = len / 2;
515        let ang = sign * std::f64::consts::TAU / len as f64;
516        let wr = ang.cos();
517        let wi = ang.sin();
518        let mut k = 0;
519        while k < n {
520            let mut wre = 1.0_f64;
521            let mut wim = 0.0_f64;
522            for m in 0..half {
523                let (ur, ui) = buf[k + m];
524                let (vr, vi) = buf[k + m + half];
525                let tr = wre * vr - wim * vi;
526                let ti = wre * vi + wim * vr;
527                buf[k + m] = (ur + tr, ui + ti);
528                buf[k + m + half] = (ur - tr, ui - ti);
529                let new_wre = wre * wr - wim * wi;
530                wim = wre * wi + wim * wr;
531                wre = new_wre;
532            }
533            k += len;
534        }
535        len *= 2;
536    }
537}
538
539/// Compute the FFT of real-valued data.  Returns complex spectrum of length N/2+1.
540/// `data` length must be a power of two.
541#[allow(dead_code)]
542pub fn rfft(data: &[f64]) -> Result<Vec<Complex>, TsError> {
543    let n = data.len();
544    if n == 0 || (n & (n - 1)) != 0 {
545        return Err(TsError::FftSizeNotPow2(n));
546    }
547    let mut buf: Vec<Complex> = data.iter().map(|&v| (v, 0.0)).collect();
548    fft_inplace(&mut buf, false);
549    Ok(buf[..=n / 2].to_vec())
550}
551
552/// Compute the power spectral density (one-sided, in units of value²/Hz)
553/// from `rfft` output given sample rate `fs`.
554#[allow(dead_code)]
555pub fn power_spectrum(rfft_out: &[Complex], n: usize, fs: f64) -> Vec<f64> {
556    let scale = 2.0 / (n as f64 * fs);
557    rfft_out
558        .iter()
559        .enumerate()
560        .map(|(k, &(re, im))| {
561            let p = (re * re + im * im) * scale;
562            // DC and Nyquist are one-sided, others doubled above
563            if k == 0 || k == n / 2 { p * 0.5 } else { p }
564        })
565        .collect()
566}
567
568/// Frequency axis (Hz) matching `rfft` output for signal of length `n` at rate `fs`.
569#[allow(dead_code)]
570pub fn rfft_frequencies(n: usize, fs: f64) -> Vec<f64> {
571    (0..=n / 2).map(|k| k as f64 * fs / n as f64).collect()
572}
573
574// ---------------------------------------------------------------------------
575// Welch's method (spectral density estimation)
576// ---------------------------------------------------------------------------
577
578/// Welch window type.
579#[allow(dead_code)]
580#[derive(Debug, Clone, Copy, PartialEq)]
581pub enum WindowType {
582    /// Rectangular (no windowing).
583    Rectangular,
584    /// Hann (raised cosine).
585    Hann,
586    /// Hamming.
587    Hamming,
588    /// Blackman.
589    Blackman,
590}
591
592/// Generate a window function of length `n`.
593#[allow(dead_code)]
594pub fn make_window(wtype: WindowType, n: usize) -> Vec<f64> {
595    use std::f64::consts::TAU;
596    match wtype {
597        WindowType::Rectangular => vec![1.0; n],
598        WindowType::Hann => (0..n)
599            .map(|i| 0.5 * (1.0 - (TAU * i as f64 / (n - 1) as f64).cos()))
600            .collect(),
601        WindowType::Hamming => (0..n)
602            .map(|i| 0.54 - 0.46 * (TAU * i as f64 / (n - 1) as f64).cos())
603            .collect(),
604        WindowType::Blackman => (0..n)
605            .map(|i| {
606                let x = TAU * i as f64 / (n - 1) as f64;
607                0.42 - 0.5 * x.cos() + 0.08 * (2.0 * x).cos()
608            })
609            .collect(),
610    }
611}
612
613/// Compute the Welch power spectral density estimate.
614/// `segment_len` must be a power of two; `overlap` is in (0..segment_len).
615#[allow(dead_code)]
616#[allow(clippy::too_many_arguments)]
617pub fn welch_psd(
618    data: &[f64],
619    fs: f64,
620    segment_len: usize,
621    overlap: usize,
622    window: WindowType,
623) -> Result<(Vec<f64>, Vec<f64>), TsError> {
624    if segment_len == 0 || (segment_len & (segment_len - 1)) != 0 {
625        return Err(TsError::FftSizeNotPow2(segment_len));
626    }
627    if fs <= 0.0 {
628        return Err(TsError::InvalidSampleRate(fs));
629    }
630    let step = (segment_len - overlap).max(1);
631    let win = make_window(window, segment_len);
632    let win_power: f64 = win.iter().map(|w| w * w).sum::<f64>() / segment_len as f64;
633    let half = segment_len / 2 + 1;
634    let mut accum = vec![0.0_f64; half];
635    let mut n_segments = 0_usize;
636    let mut start = 0;
637    while start + segment_len <= data.len() {
638        let seg: Vec<f64> = data[start..start + segment_len]
639            .iter()
640            .zip(win.iter())
641            .map(|(&d, &w)| d * w)
642            .collect();
643        let spec = rfft(&seg)?;
644        let scale = 1.0 / (fs * win_power * segment_len as f64);
645        for (k, &(re, im)) in spec.iter().enumerate() {
646            let p = (re * re + im * im) * scale;
647            accum[k] += if k == 0 || k == segment_len / 2 {
648                p
649            } else {
650                2.0 * p
651            };
652        }
653        n_segments += 1;
654        start += step;
655    }
656    if n_segments == 0 {
657        return Err(TsError::InsufficientSamples {
658            need: segment_len,
659            have: data.len(),
660        });
661    }
662    let psd: Vec<f64> = accum.iter().map(|v| v / n_segments as f64).collect();
663    let freqs = rfft_frequencies(segment_len, fs);
664    Ok((freqs, psd))
665}
666
667// ---------------------------------------------------------------------------
668// Autocorrelation and cross-correlation
669// ---------------------------------------------------------------------------
670
671/// Compute the (biased) autocorrelation of `x` for lags 0..=max_lag.
672#[allow(dead_code)]
673pub fn autocorrelation(x: &[f64], max_lag: usize) -> Vec<f64> {
674    let n = x.len();
675    let mean = x.iter().sum::<f64>() / n as f64;
676    let xc: Vec<f64> = x.iter().map(|&v| v - mean).collect();
677    let c0 = xc.iter().map(|&v| v * v).sum::<f64>() / n as f64;
678    let lags = max_lag.min(n - 1);
679    (0..=lags)
680        .map(|lag| {
681            let s: f64 = xc[..n - lag]
682                .iter()
683                .zip(xc[lag..].iter())
684                .map(|(&a, &b)| a * b)
685                .sum();
686            s / (n as f64 * c0)
687        })
688        .collect()
689}
690
691/// Compute the (biased) cross-correlation of `x` and `y` for lags -(max_lag)..=max_lag.
692/// Returns (lag_vector, ccf_values).
693#[allow(dead_code)]
694pub fn cross_correlation(x: &[f64], y: &[f64], max_lag: usize) -> (Vec<i64>, Vec<f64>) {
695    let n = x.len().min(y.len());
696    let mx = x[..n].iter().sum::<f64>() / n as f64;
697    let my = y[..n].iter().sum::<f64>() / n as f64;
698    let xc: Vec<f64> = x[..n].iter().map(|&v| v - mx).collect();
699    let yc: Vec<f64> = y[..n].iter().map(|&v| v - my).collect();
700    let sx: f64 = (xc.iter().map(|v| v * v).sum::<f64>() / n as f64).sqrt();
701    let sy: f64 = (yc.iter().map(|v| v * v).sum::<f64>() / n as f64).sqrt();
702    let denom = sx * sy * n as f64;
703    let lags = max_lag.min(n - 1);
704    let lag_vec: Vec<i64> = (-(lags as i64)..=(lags as i64)).collect();
705    let ccf: Vec<f64> = lag_vec
706        .iter()
707        .map(|&lag| {
708            let s: f64 = if lag >= 0 {
709                let l = lag as usize;
710                xc[..n - l]
711                    .iter()
712                    .zip(yc[l..].iter())
713                    .map(|(&a, &b)| a * b)
714                    .sum()
715            } else {
716                let l = (-lag) as usize;
717                xc[l..]
718                    .iter()
719                    .zip(yc[..n - l].iter())
720                    .map(|(&a, &b)| a * b)
721                    .sum()
722            };
723            if denom.abs() < 1e-300 { 0.0 } else { s / denom }
724        })
725        .collect();
726    (lag_vec, ccf)
727}
728
729// ---------------------------------------------------------------------------
730// Peak detection
731// ---------------------------------------------------------------------------
732
733/// A detected peak with its index, timestamp, and value.
734#[allow(dead_code)]
735#[derive(Debug, Clone, Copy)]
736pub struct Peak {
737    /// Sample index.
738    pub index: usize,
739    /// Timestamp at the peak.
740    pub time: f64,
741    /// Value at the peak.
742    pub value: f64,
743    /// Prominence relative to surrounding valleys.
744    pub prominence: f64,
745}
746
747/// Detect local maxima in `channel`.
748/// `min_prominence` filters weak peaks; `min_distance` is the minimum index separation.
749#[allow(dead_code)]
750pub fn detect_peaks(channel: &Channel, min_prominence: f64, min_distance: usize) -> Vec<Peak> {
751    let n = channel.values.len();
752    if n < 3 {
753        return Vec::new();
754    }
755    let v = &channel.values;
756    let candidates: Vec<usize> = (1..n - 1)
757        .filter(|&i| v[i] > v[i - 1] && v[i] >= v[i + 1])
758        .collect();
759    // Compute prominence
760    let mut peaks: Vec<Peak> = candidates
761        .iter()
762        .map(|&i| {
763            let left_min = v[..i].iter().cloned().fold(f64::INFINITY, f64::min);
764            let right_min = v[i + 1..].iter().cloned().fold(f64::INFINITY, f64::min);
765            let base = left_min.max(right_min);
766            let prom = v[i] - base;
767            Peak {
768                index: i,
769                time: channel.times[i],
770                value: v[i],
771                prominence: prom,
772            }
773        })
774        .filter(|p| p.prominence >= min_prominence)
775        .collect();
776    // Enforce minimum distance (greedy, largest prominence first)
777    peaks.sort_by(|a, b| {
778        b.prominence
779            .partial_cmp(&a.prominence)
780            .unwrap_or(std::cmp::Ordering::Equal)
781    });
782    let mut kept: Vec<Peak> = Vec::new();
783    for p in &peaks {
784        let too_close = kept.iter().any(|k: &Peak| {
785            let d = k.index.abs_diff(p.index);
786            d < min_distance
787        });
788        if !too_close {
789            kept.push(*p);
790        }
791    }
792    kept.sort_by_key(|p| p.index);
793    // Remove spurious candidates after distance filter
794    let _ = candidates.len(); // suppress unused warning
795    kept
796}
797
798// ---------------------------------------------------------------------------
799// Anomaly detection
800// ---------------------------------------------------------------------------
801
802/// An anomalous sample identified by index.
803#[allow(dead_code)]
804#[derive(Debug, Clone, Copy)]
805pub struct Anomaly {
806    /// Sample index.
807    pub index: usize,
808    /// Timestamp.
809    pub time: f64,
810    /// Raw value.
811    pub value: f64,
812    /// Anomaly score (z-score or IQR distance).
813    pub score: f64,
814}
815
816/// Z-score anomaly detection: flag samples where |z| > `threshold`.
817#[allow(dead_code)]
818pub fn anomalies_zscore(channel: &Channel, threshold: f64) -> Vec<Anomaly> {
819    let mean = channel.mean();
820    let std = channel.std_dev();
821    if std < 1e-300 {
822        return Vec::new();
823    }
824    channel
825        .values
826        .iter()
827        .enumerate()
828        .filter_map(|(i, &v)| {
829            let z = (v - mean) / std;
830            if z.abs() > threshold {
831                Some(Anomaly {
832                    index: i,
833                    time: channel.times[i],
834                    value: v,
835                    score: z,
836                })
837            } else {
838                None
839            }
840        })
841        .collect()
842}
843
844/// IQR-based anomaly detection.
845/// Flags values below Q1 − 1.5·IQR or above Q3 + 1.5·IQR (or custom `k`).
846#[allow(dead_code)]
847pub fn anomalies_iqr(channel: &Channel, k: f64) -> Vec<Anomaly> {
848    let mut sorted = channel.values.clone();
849    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
850    let n = sorted.len();
851    if n < 4 {
852        return Vec::new();
853    }
854    let q1 = sorted[n / 4];
855    let q3 = sorted[3 * n / 4];
856    let iqr = q3 - q1;
857    let lo = q1 - k * iqr;
858    let hi = q3 + k * iqr;
859    channel
860        .values
861        .iter()
862        .enumerate()
863        .filter_map(|(i, &v)| {
864            if v < lo || v > hi {
865                let score = if v < lo {
866                    (lo - v) / iqr
867                } else {
868                    (v - hi) / iqr
869                };
870                Some(Anomaly {
871                    index: i,
872                    time: channel.times[i],
873                    value: v,
874                    score,
875                })
876            } else {
877                None
878            }
879        })
880        .collect()
881}
882
883// ---------------------------------------------------------------------------
884// Export: CSV
885// ---------------------------------------------------------------------------
886
887/// Write a single channel to CSV (time, value) using any `Write` sink.
888#[allow(dead_code)]
889pub fn write_channel_csv<W: Write>(w: &mut W, channel: &Channel) -> io::Result<()> {
890    writeln!(w, "time_s,{}", channel.name)?;
891    for (t, v) in channel.times.iter().zip(channel.values.iter()) {
892        writeln!(w, "{t:.9e},{v:.9e}")?;
893    }
894    Ok(())
895}
896
897/// Write a `MultiChannelSeries` to CSV.  First column is `time_s`, then one
898/// column per channel.  All channels are assumed to have identical timestamps.
899#[allow(dead_code)]
900pub fn write_multi_channel_csv<W: Write>(w: &mut W, mcs: &MultiChannelSeries) -> io::Result<()> {
901    if mcs.channels.is_empty() {
902        return Ok(());
903    }
904    // Header
905    write!(w, "time_s")?;
906    for ch in &mcs.channels {
907        write!(w, ",{}", ch.name)?;
908    }
909    writeln!(w)?;
910    let n = mcs.channels[0].len();
911    for i in 0..n {
912        let t = mcs.channels[0].times[i];
913        write!(w, "{t:.9e}")?;
914        for ch in &mcs.channels {
915            let v = if i < ch.len() { ch.values[i] } else { f64::NAN };
916            write!(w, ",{v:.9e}")?;
917        }
918        writeln!(w)?;
919    }
920    Ok(())
921}
922
923/// Parse a two-column CSV (time, value) into a `Channel`.
924#[allow(dead_code)]
925pub fn read_channel_csv(text: &str, name: &str, unit: &str) -> Result<Channel, TsError> {
926    let mut ch = Channel::new(name, unit);
927    for (line_no, line) in text.lines().enumerate() {
928        if line_no == 0 || line.trim().is_empty() {
929            continue;
930        }
931        let mut parts = line.splitn(2, ',');
932        let t_str = parts.next().unwrap_or("").trim();
933        let v_str = parts.next().unwrap_or("").trim();
934        let t: f64 = t_str
935            .parse()
936            .map_err(|_| TsError::Parse(format!("line {line_no}: bad time '{t_str}'")))?;
937        let v: f64 = v_str
938            .parse()
939            .map_err(|_| TsError::Parse(format!("line {line_no}: bad value '{v_str}'")))?;
940        ch.push(t, v);
941    }
942    Ok(ch)
943}
944
945// ---------------------------------------------------------------------------
946// Export: JSON
947// ---------------------------------------------------------------------------
948
949/// Serialise a `Channel` to a compact JSON string.
950#[allow(dead_code)]
951pub fn channel_to_json(channel: &Channel) -> String {
952    let pairs: Vec<String> = channel
953        .times
954        .iter()
955        .zip(channel.values.iter())
956        .map(|(t, v)| format!("[{t:.9e},{v:.9e}]"))
957        .collect();
958    format!(
959        "{{\"name\":{:?},\"unit\":{:?},\"samples\":[{}]}}",
960        channel.name,
961        channel.unit,
962        pairs.join(",")
963    )
964}
965
966/// Deserialise a `Channel` from a minimal JSON string (as produced by `channel_to_json`).
967/// This is a hand-rolled parser to avoid a serde dependency.
968#[allow(dead_code)]
969pub fn channel_from_json(json: &str) -> Result<Channel, TsError> {
970    // Very naive extractor: find name, unit, then parse [t,v] pairs
971    fn extract_string_field<'a>(json: &'a str, key: &str) -> Option<&'a str> {
972        let needle = format!("\"{key}\":");
973        let start = json.find(&needle)? + needle.len();
974        let rest = json[start..].trim_start();
975        if !rest.starts_with('"') {
976            return None;
977        }
978        let inner = &rest[1..];
979        let end = inner.find('"')?;
980        Some(&inner[..end])
981    }
982    let name = extract_string_field(json, "name").unwrap_or("").to_string();
983    let unit = extract_string_field(json, "unit").unwrap_or("").to_string();
984    let mut ch = Channel::new(name, unit);
985    // Parse [t,v] pairs from samples array
986    let samples_key = "\"samples\":[";
987    if let Some(start) = json.find(samples_key) {
988        let rest = &json[start + samples_key.len()..];
989        // Iterate over [...] blocks
990        let mut remaining = rest;
991        while let Some(open) = remaining.find('[') {
992            remaining = &remaining[open + 1..];
993            let close = remaining.find(']').unwrap_or(0);
994            let inner = &remaining[..close];
995            remaining = &remaining[close + 1..];
996            let mut parts = inner.splitn(2, ',');
997            let t_s = parts.next().unwrap_or("").trim();
998            let v_s = parts.next().unwrap_or("").trim();
999            if let (Ok(t), Ok(v)) = (t_s.parse::<f64>(), v_s.parse::<f64>()) {
1000                ch.push(t, v);
1001            }
1002        }
1003    }
1004    Ok(ch)
1005}
1006
1007// ---------------------------------------------------------------------------
1008// Export: Binary (little-endian f64 pairs: [t0 v0 t1 v1 ...])
1009// ---------------------------------------------------------------------------
1010
1011/// Serialise a `Channel` to raw binary (little-endian f64 interleaved t/v pairs).
1012#[allow(dead_code)]
1013pub fn channel_to_binary(channel: &Channel) -> Vec<u8> {
1014    let mut buf = Vec::with_capacity(channel.len() * 16);
1015    for (&t, &v) in channel.times.iter().zip(channel.values.iter()) {
1016        buf.extend_from_slice(&t.to_le_bytes());
1017        buf.extend_from_slice(&v.to_le_bytes());
1018    }
1019    buf
1020}
1021
1022/// Deserialise a `Channel` from the binary format produced by `channel_to_binary`.
1023#[allow(dead_code)]
1024pub fn channel_from_binary(data: &[u8], name: &str, unit: &str) -> Result<Channel, TsError> {
1025    if !data.len().is_multiple_of(16) {
1026        return Err(TsError::Parse(format!(
1027            "binary data length {} is not divisible by 16",
1028            data.len()
1029        )));
1030    }
1031    let mut ch = Channel::new(name, unit);
1032    for chunk in data.chunks_exact(16) {
1033        let t = f64::from_le_bytes(chunk[..8].try_into().expect("slice length must match"));
1034        let v = f64::from_le_bytes(chunk[8..16].try_into().expect("slice length must match"));
1035        ch.push(t, v);
1036    }
1037    Ok(ch)
1038}
1039
1040// ---------------------------------------------------------------------------
1041// Streaming / buffered I/O
1042// ---------------------------------------------------------------------------
1043
1044/// A streaming time-series writer that flushes to an underlying `Write` sink
1045/// in chunks, enabling memory-efficient handling of very large time series.
1046#[allow(dead_code)]
1047pub struct StreamingTsWriter<W: Write> {
1048    /// Underlying writer.
1049    sink: W,
1050    /// Internal sample buffer.
1051    buffer: Vec<(f64, f64)>,
1052    /// Number of samples before an automatic flush.
1053    flush_threshold: usize,
1054    /// Channel name (written in the CSV header).
1055    name: String,
1056    /// Whether the header has been written.
1057    header_written: bool,
1058}
1059
1060impl<W: Write> StreamingTsWriter<W> {
1061    /// Create a new streaming writer with a given buffer size.
1062    #[allow(dead_code)]
1063    pub fn new(sink: W, name: impl Into<String>, flush_threshold: usize) -> Self {
1064        Self {
1065            sink,
1066            buffer: Vec::with_capacity(flush_threshold),
1067            flush_threshold: flush_threshold.max(1),
1068            name: name.into(),
1069            header_written: false,
1070        }
1071    }
1072
1073    /// Push one sample. Auto-flushes when the buffer is full.
1074    #[allow(dead_code)]
1075    pub fn push(&mut self, time: f64, value: f64) -> io::Result<()> {
1076        if !self.header_written {
1077            writeln!(self.sink, "time_s,{}", self.name)?;
1078            self.header_written = true;
1079        }
1080        self.buffer.push((time, value));
1081        if self.buffer.len() >= self.flush_threshold {
1082            self.flush()?;
1083        }
1084        Ok(())
1085    }
1086
1087    /// Flush the internal buffer to the underlying writer.
1088    #[allow(dead_code)]
1089    pub fn flush(&mut self) -> io::Result<()> {
1090        for (t, v) in self.buffer.drain(..) {
1091            writeln!(self.sink, "{t:.9e},{v:.9e}")?;
1092        }
1093        self.sink.flush()
1094    }
1095
1096    /// Finish writing; flushes remaining samples.
1097    #[allow(dead_code)]
1098    pub fn finish(mut self) -> io::Result<W> {
1099        self.flush()?;
1100        Ok(self.sink)
1101    }
1102}
1103
1104/// A buffered time-series reader that reads CSV data line-by-line without
1105/// loading the entire file into memory.
1106#[allow(dead_code)]
1107pub struct BufferedTsReader {
1108    /// Accumulated samples so far.
1109    pub samples: Vec<(f64, f64)>,
1110    /// Buffer size hint.
1111    chunk_size: usize,
1112}
1113
1114impl BufferedTsReader {
1115    /// Create a new reader.
1116    #[allow(dead_code)]
1117    pub fn new(chunk_size: usize) -> Self {
1118        Self {
1119            samples: Vec::new(),
1120            chunk_size: chunk_size.max(1),
1121        }
1122    }
1123
1124    /// Feed a chunk of CSV text into the reader.
1125    #[allow(dead_code)]
1126    pub fn feed(&mut self, text: &str, skip_header: bool) -> Result<(), TsError> {
1127        for (i, line) in text.lines().enumerate() {
1128            if i == 0 && skip_header {
1129                continue;
1130            }
1131            let line = line.trim();
1132            if line.is_empty() {
1133                continue;
1134            }
1135            let mut parts = line.splitn(2, ',');
1136            let t_s = parts.next().unwrap_or("").trim();
1137            let v_s = parts.next().unwrap_or("").trim();
1138            let t: f64 = t_s
1139                .parse()
1140                .map_err(|_| TsError::Parse(format!("bad time '{t_s}'")))?;
1141            let v: f64 = v_s
1142                .parse()
1143                .map_err(|_| TsError::Parse(format!("bad value '{v_s}'")))?;
1144            self.samples.push((t, v));
1145        }
1146        Ok(())
1147    }
1148
1149    /// Convert accumulated samples into a `Channel`.
1150    #[allow(dead_code)]
1151    pub fn into_channel(self, name: &str, unit: &str) -> Channel {
1152        let mut ch = Channel::new(name, unit);
1153        for (t, v) in self.samples {
1154            ch.push(t, v);
1155        }
1156        ch
1157    }
1158
1159    /// Chunk size hint for external callers.
1160    #[allow(dead_code)]
1161    pub fn chunk_size(&self) -> usize {
1162        self.chunk_size
1163    }
1164}
1165
1166// ---------------------------------------------------------------------------
1167// Frequency analysis helpers
1168// ---------------------------------------------------------------------------
1169
1170/// Dominant frequency (Hz) in a signal, estimated from FFT peak.
1171#[allow(dead_code)]
1172pub fn dominant_frequency(data: &[f64], fs: f64) -> Result<f64, TsError> {
1173    let n = data.len();
1174    if n < 2 {
1175        return Err(TsError::InsufficientSamples { need: 2, have: n });
1176    }
1177    // Pad / trim to next power of two
1178    let np2 = next_pow2(n);
1179    let mut padded = data.to_vec();
1180    padded.resize(np2, 0.0);
1181    let spec = rfft(&padded)?;
1182    let freqs = rfft_frequencies(np2, fs);
1183    let (max_k, _) = spec
1184        .iter()
1185        .enumerate()
1186        .skip(1) // skip DC
1187        .map(|(k, &(re, im))| (k, re * re + im * im))
1188        .fold(
1189            (1, 0.0_f64),
1190            |(bk, bv), (k, v)| {
1191                if v > bv { (k, v) } else { (bk, bv) }
1192            },
1193        );
1194    Ok(freqs[max_k])
1195}
1196
1197/// Next power of two ≥ n.
1198#[allow(dead_code)]
1199pub fn next_pow2(n: usize) -> usize {
1200    if n == 0 {
1201        return 1;
1202    }
1203    let mut p = 1;
1204    while p < n {
1205        p <<= 1;
1206    }
1207    p
1208}
1209
1210/// Total harmonic distortion (THD) estimate: ratio of harmonic power to fundamental.
1211/// `fundamental_freq` in Hz, `n_harmonics` harmonics to sum above fundamental.
1212#[allow(dead_code)]
1213pub fn total_harmonic_distortion(
1214    data: &[f64],
1215    fs: f64,
1216    fundamental_freq: f64,
1217    n_harmonics: usize,
1218) -> Result<f64, TsError> {
1219    let np2 = next_pow2(data.len());
1220    let mut padded = data.to_vec();
1221    padded.resize(np2, 0.0);
1222    let spec = rfft(&padded)?;
1223    let df = fs / np2 as f64;
1224    let bin_fund = (fundamental_freq / df).round() as usize;
1225    if bin_fund == 0 || bin_fund >= spec.len() {
1226        return Err(TsError::InsufficientSamples {
1227            need: bin_fund + 1,
1228            have: spec.len(),
1229        });
1230    }
1231    let p_fund = {
1232        let (re, im) = spec[bin_fund];
1233        re * re + im * im
1234    };
1235    let p_harm: f64 = (2..=n_harmonics + 1)
1236        .map(|h| {
1237            let k = bin_fund * h;
1238            if k < spec.len() {
1239                let (re, im) = spec[k];
1240                re * re + im * im
1241            } else {
1242                0.0
1243            }
1244        })
1245        .sum();
1246    if p_fund < 1e-300 {
1247        return Ok(0.0);
1248    }
1249    Ok((p_harm / p_fund).sqrt())
1250}
1251
1252// ---------------------------------------------------------------------------
1253// Statistical helpers
1254// ---------------------------------------------------------------------------
1255
1256/// Root-mean-square of a slice.
1257#[allow(dead_code)]
1258pub fn rms(data: &[f64]) -> f64 {
1259    if data.is_empty() {
1260        return 0.0;
1261    }
1262    (data.iter().map(|v| v * v).sum::<f64>() / data.len() as f64).sqrt()
1263}
1264
1265/// Percentile of `data` (0.0–100.0) using linear interpolation.
1266#[allow(dead_code)]
1267pub fn percentile(data: &[f64], p: f64) -> f64 {
1268    if data.is_empty() {
1269        return f64::NAN;
1270    }
1271    let mut sorted = data.to_vec();
1272    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1273    let n = sorted.len();
1274    let frac = p / 100.0 * (n - 1) as f64;
1275    let lo = frac.floor() as usize;
1276    let hi = frac.ceil() as usize;
1277    if lo == hi {
1278        sorted[lo]
1279    } else {
1280        let alpha = frac - lo as f64;
1281        sorted[lo] * (1.0 - alpha) + sorted[hi] * alpha
1282    }
1283}
1284
1285/// Median absolute deviation (MAD) of `data`.
1286#[allow(dead_code)]
1287pub fn mad(data: &[f64]) -> f64 {
1288    let med = percentile(data, 50.0);
1289    let devs: Vec<f64> = data.iter().map(|v| (v - med).abs()).collect();
1290    percentile(&devs, 50.0)
1291}
1292
1293// ---------------------------------------------------------------------------
1294// Ring buffer for real-time streaming
1295// ---------------------------------------------------------------------------
1296
1297/// A fixed-capacity circular ring buffer for real-time time-series streaming.
1298#[allow(dead_code)]
1299pub struct RingBuffer {
1300    times: Vec<f64>,
1301    values: Vec<f64>,
1302    head: usize,
1303    len: usize,
1304    capacity: usize,
1305}
1306
1307impl RingBuffer {
1308    /// Create a ring buffer with given capacity.
1309    #[allow(dead_code)]
1310    pub fn new(capacity: usize) -> Self {
1311        let cap = capacity.max(1);
1312        Self {
1313            times: vec![0.0; cap],
1314            values: vec![0.0; cap],
1315            head: 0,
1316            len: 0,
1317            capacity: cap,
1318        }
1319    }
1320
1321    /// Push one sample, overwriting the oldest if full.
1322    #[allow(dead_code)]
1323    pub fn push(&mut self, time: f64, value: f64) {
1324        let idx = (self.head + self.len) % self.capacity;
1325        if self.len < self.capacity {
1326            self.times[idx] = time;
1327            self.values[idx] = value;
1328            self.len += 1;
1329        } else {
1330            self.times[self.head] = time;
1331            self.values[self.head] = value;
1332            self.head = (self.head + 1) % self.capacity;
1333        }
1334    }
1335
1336    /// Number of valid samples.
1337    #[allow(dead_code)]
1338    pub fn len(&self) -> usize {
1339        self.len
1340    }
1341
1342    /// Return `true` when empty.
1343    #[allow(dead_code)]
1344    pub fn is_empty(&self) -> bool {
1345        self.len == 0
1346    }
1347
1348    /// Drain all samples into a `Channel` (chronological order).
1349    #[allow(dead_code)]
1350    pub fn drain_to_channel(&mut self, name: &str, unit: &str) -> Channel {
1351        let mut ch = Channel::new(name, unit);
1352        for i in 0..self.len {
1353            let idx = (self.head + i) % self.capacity;
1354            ch.push(self.times[idx], self.values[idx]);
1355        }
1356        self.len = 0;
1357        self.head = 0;
1358        ch
1359    }
1360
1361    /// View current samples as a `Channel` without consuming the buffer.
1362    #[allow(dead_code)]
1363    pub fn snapshot(&self, name: &str, unit: &str) -> Channel {
1364        let mut ch = Channel::new(name, unit);
1365        for i in 0..self.len {
1366            let idx = (self.head + i) % self.capacity;
1367            ch.push(self.times[idx], self.values[idx]);
1368        }
1369        ch
1370    }
1371}
1372
1373// ---------------------------------------------------------------------------
1374// Tests
1375// ---------------------------------------------------------------------------
1376
1377#[cfg(test)]
1378mod tests {
1379    use super::*;
1380
1381    // helpers ----------------------------------------------------------------
1382
1383    fn sine_channel(freq: f64, fs: f64, n: usize) -> Channel {
1384        let mut ch = Channel::new("sine", "V");
1385        for i in 0..n {
1386            let t = i as f64 / fs;
1387            ch.push(t, (2.0 * std::f64::consts::PI * freq * t).sin());
1388        }
1389        ch
1390    }
1391
1392    fn ramp_channel(n: usize) -> Channel {
1393        let mut ch = Channel::new("ramp", "m");
1394        for i in 0..n {
1395            ch.push(i as f64, i as f64);
1396        }
1397        ch
1398    }
1399
1400    // Channel basics ---------------------------------------------------------
1401
1402    #[test]
1403    fn test_channel_len_empty() {
1404        let ch = Channel::new("x", "m");
1405        assert!(ch.is_empty());
1406        assert_eq!(ch.len(), 0);
1407    }
1408
1409    #[test]
1410    fn test_channel_push_and_stats() {
1411        let mut ch = Channel::new("x", "Pa");
1412        for i in 0..10 {
1413            ch.push(i as f64, i as f64);
1414        }
1415        assert_eq!(ch.len(), 10);
1416        assert!((ch.mean() - 4.5).abs() < 1e-12);
1417        assert!(ch.min_value() < 1.0);
1418        assert!((ch.max_value() - 9.0).abs() < 1e-12);
1419    }
1420
1421    #[test]
1422    fn test_channel_std_dev_uniform() {
1423        let mut ch = Channel::new("u", "");
1424        for _ in 0..100 {
1425            ch.push(0.0, 5.0);
1426        }
1427        assert!(ch.std_dev().abs() < 1e-12);
1428    }
1429
1430    // Interpolation ----------------------------------------------------------
1431
1432    #[test]
1433    fn test_interp_linear_exact_nodes() {
1434        let ch = ramp_channel(10);
1435        for i in 0..10 {
1436            assert!((ch.interp_linear(i as f64) - i as f64).abs() < 1e-12);
1437        }
1438    }
1439
1440    #[test]
1441    fn test_interp_linear_midpoint() {
1442        let ch = ramp_channel(5);
1443        assert!((ch.interp_linear(1.5) - 1.5).abs() < 1e-12);
1444    }
1445
1446    #[test]
1447    fn test_interp_linear_clamp_low() {
1448        let ch = ramp_channel(5);
1449        assert!((ch.interp_linear(-1.0) - 0.0).abs() < 1e-12);
1450    }
1451
1452    #[test]
1453    fn test_interp_linear_clamp_high() {
1454        let ch = ramp_channel(5);
1455        assert!((ch.interp_linear(100.0) - 4.0).abs() < 1e-12);
1456    }
1457
1458    #[test]
1459    fn test_interp_cubic_midpoint() {
1460        let ch = ramp_channel(10);
1461        // Catmull-Rom through linear data must reproduce exact midpoints
1462        assert!((ch.interp_cubic(3.5) - 3.5).abs() < 1e-9);
1463    }
1464
1465    // Resampling -------------------------------------------------------------
1466
1467    #[test]
1468    fn test_resample_uniform_linear() {
1469        let ch = ramp_channel(10);
1470        let out = resample_uniform(&ch, 0.0, 9.0, 19, ResampleMethod::Linear).unwrap();
1471        assert_eq!(out.len(), 19);
1472        assert!((out.values[0] - 0.0).abs() < 1e-12);
1473        assert!((out.values[18] - 9.0).abs() < 1e-12);
1474        assert!((out.values[9] - 4.5).abs() < 1e-12);
1475    }
1476
1477    #[test]
1478    fn test_resample_uniform_too_few() {
1479        let ch = ramp_channel(4);
1480        assert!(resample_uniform(&ch, 0.0, 3.0, 1, ResampleMethod::Linear).is_err());
1481    }
1482
1483    #[test]
1484    fn test_resample_to_times() {
1485        let ch = ramp_channel(5);
1486        let targets = [0.5, 1.5, 2.5];
1487        let out = resample_to_times(&ch, &targets, ResampleMethod::Linear);
1488        assert_eq!(out.len(), 3);
1489        for (i, &v) in out.values.iter().enumerate() {
1490            assert!((v - targets[i]).abs() < 1e-12);
1491        }
1492    }
1493
1494    // Moving average ---------------------------------------------------------
1495
1496    #[test]
1497    fn test_moving_average_constant() {
1498        let data = vec![3.0_f64; 20];
1499        let out = moving_average(&data, 5);
1500        for v in &out {
1501            assert!((*v - 3.0).abs() < 1e-12);
1502        }
1503    }
1504
1505    #[test]
1506    fn test_moving_average_window_1() {
1507        let data: Vec<f64> = (0..10).map(|i| i as f64).collect();
1508        let out = moving_average(&data, 1);
1509        for (a, b) in data.iter().zip(out.iter()) {
1510            assert!((a - b).abs() < 1e-12);
1511        }
1512    }
1513
1514    // Butterworth filter -----------------------------------------------------
1515
1516    #[test]
1517    fn test_butterworth_lp_passes_dc() {
1518        let bq = BiquadCoeffs::butterworth_2nd(100.0, 1000.0, FilterType::LowPass);
1519        let dc = vec![1.0_f64; 200];
1520        let out = bq.apply(&dc);
1521        assert!((out.last().copied().unwrap_or(0.0) - 1.0).abs() < 1e-6);
1522    }
1523
1524    #[test]
1525    fn test_butterworth_hp_blocks_dc() {
1526        let bq = BiquadCoeffs::butterworth_2nd(50.0, 1000.0, FilterType::HighPass);
1527        let dc = vec![1.0_f64; 500];
1528        let out = bq.apply(&dc);
1529        assert!(out.last().copied().unwrap_or(1.0).abs() < 1e-4);
1530    }
1531
1532    #[test]
1533    fn test_butterworth_zero_phase() {
1534        let bq = BiquadCoeffs::butterworth_2nd(100.0, 1000.0, FilterType::LowPass);
1535        let impulse: Vec<f64> = std::iter::once(1.0)
1536            .chain(std::iter::repeat_n(0.0, 99))
1537            .collect();
1538        let out = bq.apply_zero_phase(&impulse);
1539        assert_eq!(out.len(), 100);
1540    }
1541
1542    // FFT -------------------------------------------------------------------
1543
1544    #[test]
1545    fn test_fft_dc() {
1546        let data = vec![1.0_f64; 8];
1547        let spec = rfft(&data).unwrap();
1548        assert!((spec[0].0 - 8.0).abs() < 1e-10); // DC bin = sum
1549        for k in 1..spec.len() {
1550            assert!(spec[k].0.abs() < 1e-10);
1551            assert!(spec[k].1.abs() < 1e-10);
1552        }
1553    }
1554
1555    #[test]
1556    fn test_fft_size_not_pow2_error() {
1557        let data = vec![0.0_f64; 7];
1558        assert!(rfft(&data).is_err());
1559    }
1560
1561    #[test]
1562    fn test_rfft_frequencies_length() {
1563        let freqs = rfft_frequencies(16, 100.0);
1564        assert_eq!(freqs.len(), 9); // N/2 + 1
1565    }
1566
1567    #[test]
1568    fn test_next_pow2() {
1569        assert_eq!(next_pow2(1), 1);
1570        assert_eq!(next_pow2(3), 4);
1571        assert_eq!(next_pow2(8), 8);
1572        assert_eq!(next_pow2(9), 16);
1573    }
1574
1575    // Welch PSD --------------------------------------------------------------
1576
1577    #[test]
1578    fn test_welch_psd_output_length() {
1579        let data: Vec<f64> = (0..256).map(|i| (i as f64 * 0.1).sin()).collect();
1580        let (freqs, psd) = welch_psd(&data, 100.0, 64, 32, WindowType::Hann).unwrap();
1581        assert_eq!(freqs.len(), 33);
1582        assert_eq!(psd.len(), 33);
1583    }
1584
1585    #[test]
1586    fn test_welch_psd_invalid_fs() {
1587        let data = vec![0.0_f64; 256];
1588        assert!(welch_psd(&data, -1.0, 64, 0, WindowType::Rectangular).is_err());
1589    }
1590
1591    // Window functions -------------------------------------------------------
1592
1593    #[test]
1594    fn test_hann_window_ends() {
1595        let w = make_window(WindowType::Hann, 64);
1596        assert!(w[0].abs() < 1e-12);
1597        assert!(w[63].abs() < 1e-12);
1598    }
1599
1600    #[test]
1601    fn test_rectangular_window() {
1602        let w = make_window(WindowType::Rectangular, 10);
1603        assert!(w.iter().all(|&v| (v - 1.0).abs() < 1e-12));
1604    }
1605
1606    // Autocorrelation --------------------------------------------------------
1607
1608    #[test]
1609    fn test_autocorrelation_lag0_is_1() {
1610        let ch = sine_channel(5.0, 100.0, 128);
1611        let ac = autocorrelation(&ch.values, 0);
1612        assert!((ac[0] - 1.0).abs() < 1e-10);
1613    }
1614
1615    #[test]
1616    fn test_autocorrelation_length() {
1617        let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
1618        let ac = autocorrelation(&data, 10);
1619        assert_eq!(ac.len(), 11);
1620    }
1621
1622    // Cross-correlation ------------------------------------------------------
1623
1624    #[test]
1625    fn test_cross_correlation_self_is_autocorr() {
1626        let data: Vec<f64> = (0..32).map(|i| (i as f64 * 0.3).sin()).collect();
1627        let (_lags, ccf) = cross_correlation(&data, &data, 5);
1628        let ac = autocorrelation(&data, 5);
1629        for (a, b) in ac.iter().zip(ccf.iter().skip(5)) {
1630            assert!((a - b).abs() < 1e-10);
1631        }
1632    }
1633
1634    // Peak detection ---------------------------------------------------------
1635
1636    #[test]
1637    fn test_detect_peaks_sine() {
1638        let ch = sine_channel(1.0, 100.0, 200);
1639        let peaks = detect_peaks(&ch, 0.5, 50);
1640        // Two full periods → 2 peaks
1641        assert!(!peaks.is_empty());
1642        for p in &peaks {
1643            assert!(p.value > 0.8);
1644        }
1645    }
1646
1647    #[test]
1648    fn test_detect_peaks_flat() {
1649        let mut ch = Channel::new("flat", "");
1650        for i in 0..20 {
1651            ch.push(i as f64, 1.0);
1652        }
1653        let peaks = detect_peaks(&ch, 0.1, 1);
1654        assert!(peaks.is_empty());
1655    }
1656
1657    // Anomaly detection ------------------------------------------------------
1658
1659    #[test]
1660    fn test_zscore_anomaly_detects_spike() {
1661        let mut ch = Channel::new("v", "");
1662        for i in 0..100 {
1663            ch.push(i as f64, 0.0);
1664        }
1665        ch.push(100.0, 100.0);
1666        let anomalies = anomalies_zscore(&ch, 2.0);
1667        assert!(!anomalies.is_empty());
1668        assert_eq!(anomalies[0].index, 100);
1669    }
1670
1671    #[test]
1672    fn test_iqr_anomaly_detects_outlier() {
1673        let mut ch = Channel::new("v", "");
1674        for i in 0..50 {
1675            ch.push(i as f64, (i % 2) as f64);
1676        }
1677        ch.push(50.0, 9999.0);
1678        let anomalies = anomalies_iqr(&ch, 1.5);
1679        assert!(!anomalies.is_empty());
1680    }
1681
1682    // CSV export/import ------------------------------------------------------
1683
1684    #[test]
1685    fn test_csv_roundtrip() {
1686        let mut ch = Channel::new("pressure", "Pa");
1687        for i in 0..5 {
1688            ch.push(i as f64 * 0.01, i as f64 * 100.0);
1689        }
1690        let mut buf = Vec::new();
1691        write_channel_csv(&mut buf, &ch).unwrap();
1692        let text = String::from_utf8(buf).unwrap();
1693        let ch2 = read_channel_csv(&text, "pressure", "Pa").unwrap();
1694        assert_eq!(ch2.len(), 5);
1695        for (a, b) in ch.values.iter().zip(ch2.values.iter()) {
1696            assert!((a - b).abs() < 1e-6);
1697        }
1698    }
1699
1700    #[test]
1701    fn test_multi_channel_csv_header() {
1702        let mut mcs = MultiChannelSeries::new();
1703        let mut ch = Channel::new("temp", "K");
1704        ch.push(0.0, 300.0);
1705        ch.push(1.0, 310.0);
1706        mcs.add_channel(ch);
1707        let mut buf = Vec::new();
1708        write_multi_channel_csv(&mut buf, &mcs).unwrap();
1709        let text = String::from_utf8(buf).unwrap();
1710        assert!(text.starts_with("time_s,temp"));
1711    }
1712
1713    // JSON export/import -----------------------------------------------------
1714
1715    #[test]
1716    fn test_json_roundtrip() {
1717        let mut ch = Channel::new("vel", "m/s");
1718        for i in 0..4 {
1719            ch.push(i as f64, i as f64 * 2.0);
1720        }
1721        let json = channel_to_json(&ch);
1722        let ch2 = channel_from_json(&json).unwrap();
1723        assert_eq!(ch2.len(), 4);
1724        assert_eq!(ch2.name, "vel");
1725        for (a, b) in ch.values.iter().zip(ch2.values.iter()) {
1726            assert!((a - b).abs() < 1e-6);
1727        }
1728    }
1729
1730    // Binary export/import ---------------------------------------------------
1731
1732    #[test]
1733    fn test_binary_roundtrip() {
1734        let mut ch = Channel::new("force", "N");
1735        for i in 0..8 {
1736            ch.push(i as f64 * 0.1, i as f64 * 3.125);
1737        }
1738        let bin = channel_to_binary(&ch);
1739        let ch2 = channel_from_binary(&bin, "force", "N").unwrap();
1740        assert_eq!(ch2.len(), 8);
1741        for (a, b) in ch.values.iter().zip(ch2.values.iter()) {
1742            assert!((a - b).abs() < 1e-12);
1743        }
1744    }
1745
1746    #[test]
1747    fn test_binary_bad_length() {
1748        let bad = vec![0u8; 17]; // not divisible by 16
1749        assert!(channel_from_binary(&bad, "x", "").is_err());
1750    }
1751
1752    // Streaming writer -------------------------------------------------------
1753
1754    #[test]
1755    fn test_streaming_writer_produces_csv() {
1756        let buf: Vec<u8> = Vec::new();
1757        let mut writer = StreamingTsWriter::new(buf, "sig", 4);
1758        for i in 0..10_u32 {
1759            writer.push(i as f64, i as f64 * 1.5).unwrap();
1760        }
1761        let finished = writer.finish().unwrap();
1762        let text = String::from_utf8(finished).unwrap();
1763        assert!(text.starts_with("time_s,sig"));
1764        assert_eq!(text.lines().count(), 11); // header + 10 data rows
1765    }
1766
1767    // Ring buffer -----------------------------------------------------------
1768
1769    #[test]
1770    fn test_ring_buffer_capacity() {
1771        let mut rb = RingBuffer::new(4);
1772        for i in 0..6_u32 {
1773            rb.push(i as f64, i as f64);
1774        }
1775        // Only last 4 samples should be retained
1776        assert_eq!(rb.len(), 4);
1777        let ch = rb.drain_to_channel("r", "");
1778        assert_eq!(ch.values[0], 2.0);
1779    }
1780
1781    #[test]
1782    fn test_ring_buffer_snapshot() {
1783        let mut rb = RingBuffer::new(8);
1784        for i in 0..5_u32 {
1785            rb.push(i as f64, i as f64);
1786        }
1787        let snap = rb.snapshot("s", "");
1788        assert_eq!(snap.len(), 5);
1789        // Buffer still intact
1790        assert_eq!(rb.len(), 5);
1791    }
1792
1793    // Dominant frequency ----------------------------------------------------
1794
1795    #[test]
1796    fn test_dominant_frequency() {
1797        let fs = 512.0_f64;
1798        let freq = 32.0_f64;
1799        let data: Vec<f64> = (0..512)
1800            .map(|i| (2.0 * std::f64::consts::PI * freq * i as f64 / fs).sin())
1801            .collect();
1802        let dom = dominant_frequency(&data, fs).unwrap();
1803        assert!((dom - freq).abs() < fs / 512.0 + 1.0);
1804    }
1805
1806    // RMS / percentile / MAD ------------------------------------------------
1807
1808    #[test]
1809    fn test_rms_unit_sine() {
1810        let data: Vec<f64> = (0..1024)
1811            .map(|i| (2.0 * std::f64::consts::PI * i as f64 / 1024.0).sin())
1812            .collect();
1813        let r = rms(&data);
1814        assert!((r - 1.0 / 2.0_f64.sqrt()).abs() < 0.005);
1815    }
1816
1817    #[test]
1818    fn test_percentile_median() {
1819        let data: Vec<f64> = (1..=100).map(|i| i as f64).collect();
1820        let m = percentile(&data, 50.0);
1821        assert!((m - 50.5).abs() < 0.01);
1822    }
1823
1824    #[test]
1825    fn test_mad_constant() {
1826        let data = vec![7.0_f64; 20];
1827        assert!(mad(&data).abs() < 1e-12);
1828    }
1829
1830    // Multi-channel ---------------------------------------------------------
1831
1832    #[test]
1833    fn test_multi_channel_add_and_find() {
1834        let mut mcs = MultiChannelSeries::new();
1835        let mut ch = Channel::new("omega", "rad/s");
1836        ch.push(0.0, 1.0);
1837        mcs.add_channel(ch);
1838        assert_eq!(mcs.num_channels(), 1);
1839        assert!(mcs.channel_by_name("omega").is_some());
1840        assert!(mcs.channel_by_name("missing").is_none());
1841    }
1842
1843    // TsError display -------------------------------------------------------
1844
1845    #[test]
1846    fn test_error_display() {
1847        let e = TsError::LengthMismatch {
1848            expected: 3,
1849            got: 5,
1850        };
1851        assert!(!format!("{e}").is_empty());
1852        let e2 = TsError::FftSizeNotPow2(7);
1853        assert!(format!("{e2}").contains("7"));
1854    }
1855}