use std::collections::VecDeque;
use std::time::Duration;
pub trait EtaSmoother {
fn push(&mut self, raw_eta: Duration);
fn smoothed_eta(&self) -> Option<Duration>;
fn reset(&mut self);
fn sample_count(&self) -> usize;
}
pub struct RollingEtaSmoother {
window: VecDeque<Duration>,
window_size: usize,
min_samples: usize,
}
impl RollingEtaSmoother {
#[must_use]
pub fn new(window_size: usize) -> Self {
let window_size = window_size.max(1);
Self {
window: VecDeque::with_capacity(window_size),
window_size,
min_samples: 1,
}
}
#[must_use]
pub fn with_min_samples(mut self, min_samples: usize) -> Self {
self.min_samples = min_samples.min(self.window_size).max(1);
self
}
#[must_use]
pub fn window_size(&self) -> usize {
self.window_size
}
}
impl EtaSmoother for RollingEtaSmoother {
fn push(&mut self, raw_eta: Duration) {
if self.window.len() == self.window_size {
self.window.pop_front();
}
self.window.push_back(raw_eta);
}
fn smoothed_eta(&self) -> Option<Duration> {
if self.window.len() < self.min_samples {
return None;
}
let total_nanos: u128 = self
.window
.iter()
.map(|d| d.as_nanos())
.sum();
let avg_nanos = total_nanos / self.window.len() as u128;
Some(Duration::from_nanos(avg_nanos as u64))
}
fn reset(&mut self) {
self.window.clear();
}
fn sample_count(&self) -> usize {
self.window.len()
}
}
pub struct ExponentialEtaSmoother {
alpha: f64,
smoothed_nanos: Option<f64>,
count: usize,
}
impl ExponentialEtaSmoother {
#[must_use]
pub fn new(alpha: f64) -> Self {
let alpha = alpha.clamp(1e-6, 1.0);
Self { alpha, smoothed_nanos: None, count: 0 }
}
#[must_use]
pub fn alpha(&self) -> f64 {
self.alpha
}
#[must_use]
pub fn smoothed_seconds(&self) -> Option<f64> {
self.smoothed_nanos
.map(|n| n / 1_000_000_000.0)
}
}
impl EtaSmoother for ExponentialEtaSmoother {
fn push(&mut self, raw_eta: Duration) {
let raw_nanos = raw_eta.as_nanos() as f64;
self.smoothed_nanos = Some(match self.smoothed_nanos {
None => raw_nanos,
Some(prev) => self.alpha * raw_nanos + (1.0 - self.alpha) * prev,
});
self.count += 1;
}
fn smoothed_eta(&self) -> Option<Duration> {
self.smoothed_nanos
.map(|n| Duration::from_nanos(n as u64))
}
fn reset(&mut self) {
self.smoothed_nanos = None;
self.count = 0;
}
fn sample_count(&self) -> usize {
self.count
}
}
pub struct HybridEtaSmoother {
rolling: RollingEtaSmoother,
ewma: ExponentialEtaSmoother,
}
impl HybridEtaSmoother {
#[must_use]
pub fn new(window_size: usize, alpha: f64) -> Self {
Self {
rolling: RollingEtaSmoother::new(window_size),
ewma: ExponentialEtaSmoother::new(alpha),
}
}
}
impl EtaSmoother for HybridEtaSmoother {
fn push(&mut self, raw_eta: Duration) {
self.rolling.push(raw_eta);
if let Some(rolling_out) = self.rolling.smoothed_eta() {
self.ewma.push(rolling_out);
}
}
fn smoothed_eta(&self) -> Option<Duration> {
self.ewma.smoothed_eta()
}
fn reset(&mut self) {
self.rolling.reset();
self.ewma.reset();
}
fn sample_count(&self) -> usize {
self.ewma.sample_count()
}
}
pub struct SmoothedProgressTracker<S: EtaSmoother> {
smoother: S,
total_frames: u64,
current_frame: u64,
start_nanos: Option<u64>,
}
impl<S: EtaSmoother> SmoothedProgressTracker<S> {
#[must_use]
pub fn new(smoother: S, total_frames: u64) -> Self {
Self {
smoother,
total_frames,
current_frame: 0,
start_nanos: None,
}
}
pub fn update(&mut self, elapsed_nanos: u64, current_frame: u64) -> Option<Duration> {
self.current_frame = current_frame;
if self.start_nanos.is_none() {
self.start_nanos = Some(elapsed_nanos.saturating_sub(1));
}
if current_frame == 0 || elapsed_nanos == 0 {
return None;
}
let remaining = self.total_frames.saturating_sub(current_frame);
let nanos_per_frame = elapsed_nanos as f64 / current_frame as f64;
let raw_eta_nanos = (nanos_per_frame * remaining as f64) as u64;
let raw_eta = Duration::from_nanos(raw_eta_nanos);
self.smoother.push(raw_eta);
self.smoother.smoothed_eta()
}
#[must_use]
pub fn percent(&self) -> f64 {
if self.total_frames == 0 {
return 100.0;
}
(self.current_frame as f64 / self.total_frames as f64 * 100.0).min(100.0)
}
pub fn reset(&mut self) {
self.smoother.reset();
self.current_frame = 0;
self.start_nanos = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rolling_returns_none_before_min_samples() {
let mut s = RollingEtaSmoother::new(4).with_min_samples(3);
s.push(Duration::from_secs(10));
s.push(Duration::from_secs(20));
assert!(s.smoothed_eta().is_none(), "should require 3 samples");
s.push(Duration::from_secs(30));
assert!(s.smoothed_eta().is_some());
}
#[test]
fn test_rolling_average_correct() {
let mut s = RollingEtaSmoother::new(3);
s.push(Duration::from_secs(10));
s.push(Duration::from_secs(20));
s.push(Duration::from_secs(30));
let eta = s.smoothed_eta().expect("should have data");
assert_eq!(eta.as_secs(), 20, "average of 10, 20, 30 = 20");
}
#[test]
fn test_rolling_evicts_oldest_sample() {
let mut s = RollingEtaSmoother::new(3);
s.push(Duration::from_secs(100)); s.push(Duration::from_secs(10));
s.push(Duration::from_secs(20));
s.push(Duration::from_secs(30)); let eta = s.smoothed_eta().expect("data present");
assert_eq!(eta.as_secs(), 20, "average of 10, 20, 30 = 20");
}
#[test]
fn test_rolling_reset_clears_samples() {
let mut s = RollingEtaSmoother::new(4);
s.push(Duration::from_secs(60));
s.push(Duration::from_secs(60));
assert_eq!(s.sample_count(), 2);
s.reset();
assert_eq!(s.sample_count(), 0);
assert!(s.smoothed_eta().is_none());
}
#[test]
fn test_rolling_window_size_clamped_to_1() {
let s = RollingEtaSmoother::new(0);
assert_eq!(s.window_size(), 1);
}
#[test]
fn test_rolling_single_sample_returns_itself() {
let mut s = RollingEtaSmoother::new(5);
s.push(Duration::from_secs(42));
let eta = s.smoothed_eta().expect("one sample is enough");
assert_eq!(eta.as_secs(), 42);
}
#[test]
fn test_ewma_no_data_returns_none() {
let s = ExponentialEtaSmoother::new(0.3);
assert!(s.smoothed_eta().is_none());
}
#[test]
fn test_ewma_first_sample_equals_raw() {
let mut s = ExponentialEtaSmoother::new(0.3);
s.push(Duration::from_secs(60));
let eta = s.smoothed_eta().expect("one sample");
assert_eq!(eta.as_secs(), 60);
}
#[test]
fn test_ewma_converges_toward_new_value() {
let mut s = ExponentialEtaSmoother::new(0.5);
s.push(Duration::from_secs(100));
s.push(Duration::from_secs(50));
let eta = s.smoothed_eta().expect("has data");
assert!(eta.as_secs() < 100, "should move below 100 s");
assert!(eta.as_secs() > 50, "should not yet reach 50 s");
}
#[test]
fn test_ewma_alpha_clamped() {
let s = ExponentialEtaSmoother::new(0.0); assert!(s.alpha() > 0.0);
let s2 = ExponentialEtaSmoother::new(2.0); assert!((s2.alpha() - 1.0).abs() < 1e-9);
}
#[test]
fn test_ewma_sample_count_increments() {
let mut s = ExponentialEtaSmoother::new(0.3);
s.push(Duration::from_secs(10));
s.push(Duration::from_secs(20));
assert_eq!(s.sample_count(), 2);
}
#[test]
fn test_ewma_reset() {
let mut s = ExponentialEtaSmoother::new(0.3);
s.push(Duration::from_secs(30));
s.reset();
assert!(s.smoothed_eta().is_none());
assert_eq!(s.sample_count(), 0);
}
#[test]
fn test_hybrid_returns_smoothed_value() {
let mut h = HybridEtaSmoother::new(3, 0.5);
for _ in 0..5 {
h.push(Duration::from_secs(60));
}
let eta = h.smoothed_eta().expect("has data");
assert!((eta.as_secs_f64() - 60.0).abs() < 1.0);
}
#[test]
fn test_hybrid_reset_propagates() {
let mut h = HybridEtaSmoother::new(3, 0.5);
h.push(Duration::from_secs(30));
h.reset();
assert_eq!(h.sample_count(), 0);
}
#[test]
fn test_progress_tracker_zero_frame_returns_none() {
let smoother = RollingEtaSmoother::new(4);
let mut tracker = SmoothedProgressTracker::new(smoother, 1000);
let eta = tracker.update(1_000_000, 0);
assert!(eta.is_none());
}
#[test]
fn test_progress_tracker_eta_decreases_as_work_progresses() {
let smoother = RollingEtaSmoother::new(4);
let mut tracker = SmoothedProgressTracker::new(smoother, 1000);
let eta1 = tracker.update(1_000_000_000, 100); let eta2 = tracker.update(2_000_000_000, 500); if let (Some(e1), Some(e2)) = (eta1, eta2) {
assert!(e2 < e1, "ETA should decrease as more work is done");
}
}
#[test]
fn test_progress_tracker_percent() {
let smoother = ExponentialEtaSmoother::new(0.3);
let mut tracker = SmoothedProgressTracker::new(smoother, 200);
tracker.update(500_000_000, 50);
let pct = tracker.percent();
assert!((pct - 25.0).abs() < 0.1, "50/200 = 25%, got {pct}");
}
#[test]
fn test_progress_tracker_reset() {
let smoother = RollingEtaSmoother::new(4);
let mut tracker = SmoothedProgressTracker::new(smoother, 500);
tracker.update(1_000_000_000, 100);
tracker.reset();
assert!((tracker.percent() - 0.0).abs() < 1e-9);
}
}