use std::collections::VecDeque;
use std::time::Duration;
const DISCONTINUITY_GAP_S: f64 = 0.5;
#[derive(Debug, Default)]
pub(crate) struct VelocityEstimator {
last_raw_pos: Option<f32>,
last_ts_us: Option<u32>,
accum_pos: f64,
accum_time_s: f64,
samples: VecDeque<(f64, f64)>,
}
impl VelocityEstimator {
pub fn update(&mut self, ts_us: u32, pos_rev: f32, window: Duration) -> Option<f32> {
let (Some(last_raw), Some(last_ts)) = (self.last_raw_pos, self.last_ts_us) else {
self.reset_to(ts_us, pos_rev);
return None;
};
let dt_s = ts_us.wrapping_sub(last_ts) as f64 * 1e-6;
if dt_s <= 0.0 || dt_s > DISCONTINUITY_GAP_S {
self.reset_to(ts_us, pos_rev);
return None;
}
let mut dpos = (pos_rev - last_raw) as f64;
if dpos > 0.5 {
dpos -= 1.0;
} else if dpos < -0.5 {
dpos += 1.0;
}
self.accum_pos += dpos;
self.accum_time_s += dt_s;
self.last_raw_pos = Some(pos_rev);
self.last_ts_us = Some(ts_us);
self.samples.push_back((self.accum_time_s, self.accum_pos));
let cutoff = self.accum_time_s - window.as_secs_f64();
while self.samples.len() > 2 {
match self.samples.front() {
Some(&(t, _)) if t < cutoff => {
self.samples.pop_front();
}
_ => break,
}
}
self.least_squares_slope()
}
fn reset_to(&mut self, ts_us: u32, pos_rev: f32) {
self.last_raw_pos = Some(pos_rev);
self.last_ts_us = Some(ts_us);
self.accum_pos = 0.0;
self.accum_time_s = 0.0;
self.samples.clear();
self.samples.push_back((0.0, 0.0));
}
fn least_squares_slope(&self) -> Option<f32> {
let n = self.samples.len();
if n < 2 {
return None;
}
let nf = n as f64;
let (mut st, mut sp, mut stt, mut stp) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
for &(t, p) in &self.samples {
st += t;
sp += p;
stt += t * t;
stp += t * p;
}
let denom = nf * stt - st * st;
if denom.abs() < 1e-12 {
return None;
}
let slope = (nf * stp - st * sp) / denom;
if slope.is_finite() {
Some(slope as f32)
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const WIN: Duration = Duration::from_millis(20);
#[test]
fn first_sample_returns_none() {
let mut e = VelocityEstimator::default();
assert!(e.update(1000, 0.0, WIN).is_none());
}
#[test]
fn constant_velocity_recovered() {
let mut e = VelocityEstimator::default();
let mut pos: f32 = 0.0;
let mut v = None;
for i in 1..=50u32 {
pos += 0.0003;
if pos >= 0.5 {
pos -= 1.0;
}
v = e.update(i * 1000, pos, WIN);
}
let v = v.expect("should have a velocity after warmup");
assert!((v - 0.3).abs() < 1e-3, "v={v}");
}
#[test]
fn handles_single_turn_wrap() {
let mut e = VelocityEstimator::default();
e.update(1000, 0.49, WIN);
let v = e.update(2000, -0.49, WIN); let v = v.expect("some velocity");
assert!(v > 0.0, "wrap should give positive velocity, got {v}");
assert!((v - 20.0).abs() < 1.0, "v={v}");
}
#[test]
fn large_gap_resets() {
let mut e = VelocityEstimator::default();
e.update(1000, 0.0, WIN);
e.update(2000, 0.001, WIN);
assert!(e.update(2000 + 1_000_000, 0.5, WIN).is_none());
}
#[test]
fn timestamp_wraparound_is_handled() {
let mut e = VelocityEstimator::default();
let near_max = u32::MAX - 500; e.update(near_max, 0.0, WIN);
let v = e.update(near_max.wrapping_add(1000), 0.0003, WIN);
let v = v.expect("velocity across wrap");
assert!((v - 0.3).abs() < 1e-2, "v={v}");
}
}