use std::{
collections::VecDeque,
time::{Duration, Instant},
};
const MIN_WINDOW: Duration = Duration::from_secs(30);
const MAX_OBSERVATIONS: usize = 64;
const MIN_SPAN_FOR_ESTIMATE: Duration = Duration::from_secs(5);
#[derive(Debug, Default, Clone)]
pub struct EtaEstimator {
observations: VecDeque<(Instant, f64)>,
}
impl EtaEstimator {
pub fn observe(&mut self, now: Instant, fraction_done: f64) {
self.observations
.push_back((now, fraction_done.clamp(0.0, 1.0)));
while self.observations.len() > MAX_OBSERVATIONS {
let Some(&(oldest, _)) = self.observations.front() else {
break;
};
if now.saturating_duration_since(oldest) <= MIN_WINDOW {
break;
}
self.observations.pop_front();
}
}
#[must_use]
pub fn eta(&self, now: Instant) -> Option<Duration> {
let &(first_time, _) = self.observations.front()?;
let &(last_time, _) = self.observations.back()?;
let span = last_time.duration_since(first_time);
if self.observations.len() < 2 || span < MIN_SPAN_FOR_ESTIMATE {
return None;
}
let xs_ys: Vec<(f64, f64)> = self
.observations
.iter()
.map(|&(t, f)| (t.duration_since(first_time).as_secs_f64(), f))
.collect();
let count = xs_ys.len() as f64;
let mean_x = xs_ys.iter().map(|&(x, _)| x).sum::<f64>() / count;
let mean_y = xs_ys.iter().map(|&(_, y)| y).sum::<f64>() / count;
let mut covariance = 0.0;
let mut variance = 0.0;
for &(x, y) in &xs_ys {
let dx = x - mean_x;
covariance = dx.mul_add(y - mean_y, covariance);
variance = dx.mul_add(dx, variance);
}
if variance <= f64::EPSILON {
return None;
}
let slope = covariance / variance;
if slope <= 0.0 {
return None;
}
let intercept = slope.mul_add(-mean_x, mean_y);
let now_x = now.duration_since(first_time).as_secs_f64();
let fitted_now = slope.mul_add(now_x, intercept).max(0.0);
let remaining_fraction = (1.0 - fitted_now).max(0.0);
let remaining_secs = remaining_fraction / slope;
if !remaining_secs.is_finite() {
return None;
}
Some(Duration::from_secs_f64(remaining_secs))
}
pub fn reset(&mut self) {
self.observations.clear();
}
}
#[must_use]
pub fn format_eta(eta: Option<Duration>) -> String {
let Some(remaining) = eta else {
return String::new();
};
let total_secs = remaining.as_secs_f64().round().max(1.0) as u64;
if total_secs < 60 {
format!("about {total_secs} s left")
} else if total_secs < 3600 {
let minutes = (total_secs as f64 / 60.0).round() as u64;
format!("about {minutes} min left")
} else {
let hours = total_secs / 3600;
let minutes = (total_secs % 3600) / 60;
if minutes == 0 {
format!("about {hours} h left")
} else {
format!("about {hours} h {minutes} min left")
}
}
}
#[must_use]
pub fn batch_eta_label(completed: u32, total: u32, eta: Option<Duration>) -> String {
if total == 0 || completed >= total {
return String::new();
}
match eta {
Some(_) => format_eta(eta),
None => "estimating...".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::{EtaEstimator, batch_eta_label, format_eta};
use std::time::{Duration, Instant};
#[test]
fn linear_progress_gives_exact_eta() {
let start = Instant::now();
let mut eta = EtaEstimator::default();
for t in 0..=9u64 {
eta.observe(start + Duration::from_secs(t), 0.05 * t as f64);
}
let now = start + Duration::from_secs(9);
let remaining = eta.eta(now).expect("enough history for an estimate");
assert!(
(remaining.as_secs_f64() - 11.0).abs() < 1e-6,
"expected ~11s, got {remaining:?}"
);
}
#[test]
fn step_like_progress_is_within_a_quarter_of_the_truth_after_three_steps() {
let start = Instant::now();
let mut eta = EtaEstimator::default();
eta.observe(start, 0.0);
eta.observe(start + Duration::from_secs(20), 0.2);
eta.observe(start + Duration::from_secs(40), 0.4);
eta.observe(start + Duration::from_secs(60), 0.6);
let now = start + Duration::from_secs(60);
let remaining = eta.eta(now).expect("enough history after three steps");
let truth_secs = 40.0; let error = (remaining.as_secs_f64() - truth_secs).abs() / truth_secs;
assert!(
error <= 0.25,
"expected within 25% of {truth_secs}s, got {remaining:?}"
);
}
#[test]
fn stalled_progress_returns_none() {
let start = Instant::now();
let mut eta = EtaEstimator::default();
for t in 0..=6u64 {
eta.observe(start + Duration::from_secs(t), 0.3);
}
assert!(eta.eta(start + Duration::from_secs(6)).is_none());
}
#[test]
fn reset_clears_prior_history() {
let start = Instant::now();
let mut eta = EtaEstimator::default();
for t in 0..=9u64 {
eta.observe(start + Duration::from_secs(t), 0.05 * t as f64);
}
assert!(eta.eta(start + Duration::from_secs(9)).is_some());
eta.reset();
eta.observe(start + Duration::from_secs(9), 0.45);
assert!(
eta.eta(start + Duration::from_secs(9)).is_none(),
"a single observation right after reset must not yet produce an estimate"
);
}
#[test]
fn batch_eta_label_covers_estimating_known_and_finished() {
assert_eq!(batch_eta_label(3, 10, None), "estimating...");
assert_eq!(
batch_eta_label(3, 10, Some(Duration::from_secs(120))),
"about 2 min left"
);
assert_eq!(batch_eta_label(10, 10, None), "");
assert_eq!(batch_eta_label(0, 0, None), "");
}
#[test]
fn format_eta_renders_each_bucket() {
assert_eq!(format_eta(None), "");
assert_eq!(format_eta(Some(Duration::from_secs(5))), "about 5 s left");
assert_eq!(
format_eta(Some(Duration::from_secs(120))),
"about 2 min left"
);
assert_eq!(
format_eta(Some(Duration::from_hours(1) + Duration::from_mins(20))),
"about 1 h 20 min left"
);
assert_eq!(
format_eta(Some(Duration::from_millis(400))),
"about 1 s left"
);
}
}