1#![allow(clippy::needless_range_loop)]
2use std::collections::HashMap;
10use std::fmt;
11use std::io::{self, Write};
12
13#[allow(dead_code)]
19#[derive(Debug)]
20pub enum TsError {
21 LengthMismatch {
23 expected: usize,
25 got: usize,
27 },
28 InvalidSampleRate(f64),
30 ChannelNotFound(usize),
32 ChannelNameNotFound(String),
34 InsufficientSamples {
36 need: usize,
38 have: usize,
40 },
41 FftSizeNotPow2(usize),
43 Io(String),
45 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#[allow(dead_code)]
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct Sample {
78 pub time: f64,
80 pub value: f64,
82}
83
84impl Sample {
85 #[allow(dead_code)]
87 pub fn new(time: f64, value: f64) -> Self {
88 Self { time, value }
89 }
90}
91
92#[allow(dead_code)]
98#[derive(Debug, Clone)]
99pub struct Channel {
100 pub name: String,
102 pub unit: String,
104 pub times: Vec<f64>,
106 pub values: Vec<f64>,
108}
109
110impl Channel {
111 #[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 #[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 #[allow(dead_code)]
131 pub fn len(&self) -> usize {
132 self.times.len()
133 }
134
135 #[allow(dead_code)]
137 pub fn is_empty(&self) -> bool {
138 self.times.is_empty()
139 }
140
141 #[allow(dead_code)]
143 pub fn min_value(&self) -> f64 {
144 self.values.iter().cloned().fold(f64::INFINITY, f64::min)
145 }
146
147 #[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 #[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 #[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 #[allow(dead_code)]
177 pub fn std_dev(&self) -> f64 {
178 self.variance().sqrt()
179 }
180
181 #[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 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 #[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#[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#[allow(dead_code)]
258#[derive(Debug, Clone, Default)]
259pub struct MultiChannelSeries {
260 pub channels: Vec<Channel>,
262 pub metadata: HashMap<String, String>,
264}
265
266impl MultiChannelSeries {
267 #[allow(dead_code)]
269 pub fn new() -> Self {
270 Self::default()
271 }
272
273 #[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 #[allow(dead_code)]
283 pub fn num_channels(&self) -> usize {
284 self.channels.len()
285 }
286
287 #[allow(dead_code)]
289 pub fn channel(&self, idx: usize) -> Option<&Channel> {
290 self.channels.get(idx)
291 }
292
293 #[allow(dead_code)]
295 pub fn channel_mut(&mut self, idx: usize) -> Option<&mut Channel> {
296 self.channels.get_mut(idx)
297 }
298
299 #[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 #[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#[allow(dead_code)]
318#[derive(Debug, Clone, Copy, PartialEq)]
319pub enum ResampleMethod {
320 Linear,
322 CubicSpline,
324}
325
326#[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#[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#[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#[allow(dead_code)]
404#[derive(Debug, Clone, Copy, PartialEq)]
405pub enum FilterType {
406 LowPass,
408 HighPass,
410}
411
412#[allow(dead_code)]
414#[derive(Debug, Clone, Copy)]
415pub struct BiquadCoeffs {
416 pub b: [f64; 3],
418 pub a: [f64; 2],
420}
421
422impl BiquadCoeffs {
423 #[allow(dead_code)]
426 pub fn butterworth_2nd(fc: f64, fs: f64, filter_type: FilterType) -> Self {
427 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 #[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 #[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#[allow(dead_code)]
490pub type Complex = (f64, f64);
491
492#[allow(dead_code)]
495pub fn fft_inplace(buf: &mut [(f64, f64)], inverse: bool) {
496 let n = buf.len();
497 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 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#[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#[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 if k == 0 || k == n / 2 { p * 0.5 } else { p }
564 })
565 .collect()
566}
567
568#[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#[allow(dead_code)]
580#[derive(Debug, Clone, Copy, PartialEq)]
581pub enum WindowType {
582 Rectangular,
584 Hann,
586 Hamming,
588 Blackman,
590}
591
592#[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#[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#[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#[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#[allow(dead_code)]
735#[derive(Debug, Clone, Copy)]
736pub struct Peak {
737 pub index: usize,
739 pub time: f64,
741 pub value: f64,
743 pub prominence: f64,
745}
746
747#[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 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 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 let _ = candidates.len(); kept
796}
797
798#[allow(dead_code)]
804#[derive(Debug, Clone, Copy)]
805pub struct Anomaly {
806 pub index: usize,
808 pub time: f64,
810 pub value: f64,
812 pub score: f64,
814}
815
816#[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#[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#[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#[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 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#[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#[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#[allow(dead_code)]
969pub fn channel_from_json(json: &str) -> Result<Channel, TsError> {
970 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 let samples_key = "\"samples\":[";
987 if let Some(start) = json.find(samples_key) {
988 let rest = &json[start + samples_key.len()..];
989 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#[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#[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#[allow(dead_code)]
1047pub struct StreamingTsWriter<W: Write> {
1048 sink: W,
1050 buffer: Vec<(f64, f64)>,
1052 flush_threshold: usize,
1054 name: String,
1056 header_written: bool,
1058}
1059
1060impl<W: Write> StreamingTsWriter<W> {
1061 #[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 #[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 #[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 #[allow(dead_code)]
1098 pub fn finish(mut self) -> io::Result<W> {
1099 self.flush()?;
1100 Ok(self.sink)
1101 }
1102}
1103
1104#[allow(dead_code)]
1107pub struct BufferedTsReader {
1108 pub samples: Vec<(f64, f64)>,
1110 chunk_size: usize,
1112}
1113
1114impl BufferedTsReader {
1115 #[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 #[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 #[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 #[allow(dead_code)]
1161 pub fn chunk_size(&self) -> usize {
1162 self.chunk_size
1163 }
1164}
1165
1166#[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 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) .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#[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#[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#[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#[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#[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#[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 #[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 #[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 #[allow(dead_code)]
1338 pub fn len(&self) -> usize {
1339 self.len
1340 }
1341
1342 #[allow(dead_code)]
1344 pub fn is_empty(&self) -> bool {
1345 self.len == 0
1346 }
1347
1348 #[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 #[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#[cfg(test)]
1378mod tests {
1379 use super::*;
1380
1381 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 #[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 #[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 assert!((ch.interp_cubic(3.5) - 3.5).abs() < 1e-9);
1463 }
1464
1465 #[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 #[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 #[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 #[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); 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); }
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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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]; assert!(channel_from_binary(&bad, "x", "").is_err());
1750 }
1751
1752 #[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); }
1766
1767 #[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 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 assert_eq!(rb.len(), 5);
1791 }
1792
1793 #[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 #[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 #[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 #[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}