use std::collections::VecDeque;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Trajectory {
Rising,
Falling,
Plateau,
Peak,
Valley,
Unknown,
}
#[derive(Debug, Clone, Copy)]
pub struct Sample {
pub tick: u64,
pub value: f64,
}
#[derive(Debug, Clone)]
pub struct TensionTracker {
window_size: usize,
samples: VecDeque<Sample>,
threshold: f64,
}
impl TensionTracker {
pub fn new(window_size: usize) -> Self {
assert!(
window_size >= 3,
"window must be at least 3 for trajectory classification"
);
Self {
window_size,
samples: VecDeque::new(),
threshold: 0.01,
}
}
pub fn with_threshold(window_size: usize, threshold: f64) -> Self {
assert!(
window_size >= 3,
"window must be at least 3 for trajectory classification"
);
Self {
window_size,
samples: VecDeque::new(),
threshold,
}
}
pub fn push(&mut self, tick: u64, value: f64) {
self.samples.push_back(Sample { tick, value });
if self.samples.len() > self.window_size {
self.samples.pop_front();
}
}
pub fn current(&self) -> Option<f64> {
self.samples.back().map(|s| s.value)
}
pub fn slope(&self) -> f64 {
if self.samples.len() < 2 {
return 0.0;
}
let n = self.samples.len() as f64;
let x_mean: f64 = self.samples.iter().map(|s| s.tick as f64).sum::<f64>() / n;
let y_mean: f64 = self.samples.iter().map(|s| s.value).sum::<f64>() / n;
let mut num = 0.0;
let mut den = 0.0;
for s in &self.samples {
let dx = s.tick as f64 - x_mean;
let dy = s.value - y_mean;
num += dx * dy;
den += dx * dx;
}
if den.abs() < f64::EPSILON {
0.0
} else {
num / den
}
}
pub fn trajectory(&self) -> Trajectory {
if self.samples.len() < 3 {
return Trajectory::Unknown;
}
let slope = self.slope();
let threshold = self.threshold;
let mid = self.samples.len() / 2;
let first_half: Vec<f64> = self.samples.iter().take(mid).map(|s| s.value).collect();
let second_half: Vec<f64> = self.samples.iter().skip(mid).map(|s| s.value).collect();
let first_mean = first_half.iter().sum::<f64>() / first_half.len() as f64;
let second_mean = second_half.iter().sum::<f64>() / second_half.len() as f64;
let mid_value = self.samples[mid].value;
if mid_value > first_mean
&& mid_value > second_mean
&& (first_mean - second_mean).abs() < threshold * 10.0
{
return Trajectory::Peak;
}
if mid_value < first_mean
&& mid_value < second_mean
&& (first_mean - second_mean).abs() < threshold * 10.0
{
return Trajectory::Valley;
}
if slope > threshold {
Trajectory::Rising
} else if slope < -threshold {
Trajectory::Falling
} else {
Trajectory::Plateau
}
}
pub fn sample_count(&self) -> usize {
self.samples.len()
}
pub fn reset(&mut self) {
self.samples.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rising_trajectory() {
let mut t = TensionTracker::new(10);
for i in 0..10 {
t.push(i, i as f64 * 0.5);
}
assert_eq!(t.trajectory(), Trajectory::Rising);
assert!(t.slope() > 0.0);
}
#[test]
fn falling_trajectory() {
let mut t = TensionTracker::new(10);
for i in 0..10 {
t.push(i, 10.0 - i as f64);
}
assert_eq!(t.trajectory(), Trajectory::Falling);
assert!(t.slope() < 0.0);
}
#[test]
fn plateau_trajectory() {
let mut t = TensionTracker::new(10);
for i in 0..10 {
t.push(i, 5.0);
}
assert_eq!(t.trajectory(), Trajectory::Plateau);
assert!(t.slope().abs() < 0.01);
}
#[test]
fn sliding_window_drops_old() {
let mut t = TensionTracker::new(5);
for i in 0..20 {
t.push(i, i as f64);
}
assert_eq!(t.sample_count(), 5);
}
#[test]
fn unknown_with_too_few_samples() {
let mut t = TensionTracker::new(10);
t.push(0, 1.0);
assert_eq!(t.trajectory(), Trajectory::Unknown);
}
#[test]
fn custom_threshold_classifies_gentle_rise_as_plateau() {
let mut t = TensionTracker::with_threshold(10, 1.0); for i in 0..10 {
t.push(i, i as f64 * 0.001); }
assert_eq!(t.trajectory(), Trajectory::Plateau);
}
}