use std::time::{Duration, Instant};
const DEFAULT_WINDOW_SIZE: usize = 10;
const SAMPLE_INTERVAL_MS: u64 = 500;
const BURST_THRESHOLD_MULTIPLIER: f64 = 3.0;
pub struct SpeedSmoother {
ema_speed: f64,
alpha: f64,
last_update: Option<Instant>,
raw_total_bytes: u64,
sample_start: Option<Instant>,
samples_count: usize,
last_instant_speed: f64,
}
impl SpeedSmoother {
pub fn new(window_size: usize) -> Self {
let n = if window_size == 0 {
DEFAULT_WINDOW_SIZE
} else {
window_size
};
Self {
ema_speed: 0.0,
alpha: 2.0 / (n as f64 + 1.0),
last_update: None,
raw_total_bytes: 0,
sample_start: None,
samples_count: 0,
last_instant_speed: 0.0,
}
}
pub fn with_default_window() -> Self {
Self::new(DEFAULT_WINDOW_SIZE)
}
pub fn record_bytes(&mut self, bytes: u64) {
let now = Instant::now();
if self.sample_start.is_none() {
self.sample_start = Some(now);
}
self.raw_total_bytes += bytes;
let should_sample = match self.last_update {
Some(last) => now.duration_since(last) >= Duration::from_millis(SAMPLE_INTERVAL_MS),
None => true, };
if should_sample {
self.update_ema(now);
}
}
fn update_ema(&mut self, now: Instant) {
let sample_duration = match self.sample_start {
Some(start) => now.duration_since(start).as_secs_f64(),
None => return,
};
if sample_duration <= 0.0 {
return;
}
let instant_speed = self.raw_total_bytes as f64 / sample_duration;
self.last_instant_speed = instant_speed;
if self.samples_count == 0 {
self.ema_speed = instant_speed;
} else {
self.ema_speed = self.alpha * instant_speed + (1.0 - self.alpha) * self.ema_speed;
}
self.raw_total_bytes = 0;
self.sample_start = Some(now);
self.last_update = Some(now);
self.samples_count += 1;
}
pub fn smoothed_speed(&self) -> f64 {
self.ema_speed.max(0.0)
}
pub fn instant_speed(&self) -> f64 {
if self.raw_total_bytes > 0 {
let now = Instant::now();
if let Some(start) = self.sample_start {
let elapsed = now.duration_since(start).as_secs_f64();
if elapsed > 0.0 {
return self.raw_total_bytes as f64 / elapsed;
}
}
}
self.last_instant_speed
}
pub fn eta_seconds(&self, remaining: u64) -> Option<u64> {
let speed = self.smoothed_speed();
if speed <= 0.0 {
return None;
}
Some((remaining as f64 / speed).ceil() as u64)
}
pub fn is_burst(&self) -> bool {
let instant = self.instant_speed();
let ema = self.smoothed_speed();
ema > 0.0 && instant > BURST_THRESHOLD_MULTIPLIER * ema
}
pub fn reset(&mut self) {
self.ema_speed = 0.0;
self.last_update = None;
self.raw_total_bytes = 0;
self.sample_start = None;
self.samples_count = 0;
self.last_instant_speed = 0.0;
}
pub fn samples_count(&self) -> usize {
self.samples_count
}
pub fn alpha(&self) -> f64 {
self.alpha
}
}
impl Default for SpeedSmoother {
fn default() -> Self {
Self::with_default_window()
}
}
pub use super::format::{format_duration_short, format_speed as format_bytes_per_sec};
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
fn simulate_downloads(
smoother: &mut SpeedSmoother,
bytes_per_sample: u64,
interval_ms: u64,
num_samples: usize,
) {
for _ in 0..num_samples {
smoother.record_bytes(bytes_per_sample);
if interval_ms > 0 {
thread::sleep(Duration::from_millis(interval_ms));
}
}
}
#[test]
fn test_ema_convergence() {
let mut smoother = SpeedSmoother::new(10);
const BYTES_PER_CALL: u64 = 100;
const TARGET_SPEED_BPS: f64 = 1000.0;
const INTERVAL_MS: u64 = 100;
simulate_downloads(&mut smoother, BYTES_PER_CALL, INTERVAL_MS, 25);
let final_speed = smoother.smoothed_speed();
let error_ratio = (final_speed - TARGET_SPEED_BPS).abs() / TARGET_SPEED_BPS;
assert!(
error_ratio < 0.50,
"EMA should converge to target speed. Got {:.2}, expected ~{}, error ratio: {:.2}",
final_speed,
TARGET_SPEED_BPS,
error_ratio
);
assert!(
smoother.samples_count() >= 5,
"Should have recorded at least 5 samples, got {}",
smoother.samples_count()
);
}
#[test]
fn test_ema_reacts_to_drop() {
let mut smoother = SpeedSmoother::new(10);
simulate_downloads(&mut smoother, 1000, 100, 15);
let high_speed = smoother.smoothed_speed();
simulate_downloads(&mut smoother, 100, 100, 8);
let low_speed = smoother.smoothed_speed();
assert!(
low_speed < high_speed * 0.80,
"EMA should react to speed drop. Before: {:.2}, After: {:.2}",
high_speed,
low_speed
);
assert!(
low_speed < high_speed * 0.85,
"Post-drop speed ({:.2}) should be below pre-drop ({:.2})",
low_speed,
high_speed
);
}
#[test]
fn test_burst_detection() {
let mut smoother = SpeedSmoother::new(10);
simulate_downloads(&mut smoother, 10, 100, 12);
let _baseline_speed = smoother.smoothed_speed();
let baseline_samples = smoother.samples_count();
thread::sleep(Duration::from_millis(120));
smoother.record_bytes(100000);
let is_burst = smoother.is_burst();
let instant = smoother.instant_speed();
let samples_after = smoother.samples_count();
assert!(
is_burst || instant > 10000.0 || samples_after > baseline_samples,
"Should detect burst or process burst bytes. Instant: {:.2}, is_burst: {}, samples: {} -> {}",
instant,
is_burst,
baseline_samples,
samples_after
);
}
#[test]
fn test_eta_calculation() {
let mut smoother = SpeedSmoother::new(10);
simulate_downloads(&mut smoother, 1000, 100, 15);
let speed = smoother.smoothed_speed();
assert!(
speed > 0.0,
"Should have non-zero speed for ETA calculation"
);
let eta = smoother.eta_seconds(10000);
assert!(eta.is_some(), "ETA should be calculable when speed > 0");
let eta_value = eta.unwrap();
assert!(
eta_value <= 10, "ETA for 10000 bytes at {:.0} B/s should be <= 10s, got {}s",
speed,
eta_value
);
let eta_zero = smoother.eta_seconds(0);
assert_eq!(eta_zero, Some(0), "ETA for 0 remaining bytes should be 0");
smoother.reset();
let eta_no_speed = smoother.eta_seconds(99999);
assert!(
eta_no_speed.is_none(),
"ETA should be None when speed is zero"
);
}
#[test]
fn test_reset_clears_state() {
let mut smoother = SpeedSmoother::new(10);
simulate_downloads(&mut smoother, 500, 50, 20);
assert!(
smoother.smoothed_speed() > 0.0,
"Should have speed before reset"
);
assert!(
smoother.samples_count() > 0,
"Should have samples before reset"
);
smoother.reset();
assert_eq!(
smoother.smoothed_speed(),
0.0,
"Speed should be 0 after reset"
);
assert_eq!(
smoother.samples_count(),
0,
"Sample count should be 0 after reset"
);
assert_eq!(
smoother.instant_speed(),
0.0,
"Instant speed should be 0 after reset"
);
let eta = smoother.eta_seconds(12345);
assert!(eta.is_none(), "ETA should be None after reset (no speed)");
assert!(
!smoother.is_burst(),
"Should not be in burst state after reset"
);
}
#[test]
fn test_format_bytes_per_sec_units() {
assert!(
format_bytes_per_sec(500.0).contains("B/s"),
"Small values use B/s"
);
assert!(
format_bytes_per_sec(2048.0).contains("KiB/s"),
"KiB range uses KiB/s"
);
assert!(
format_bytes_per_sec(3.0 * 1024.0 * 1024.0).contains("MiB/s"),
"MiB range uses MiB/s"
);
assert!(
format_bytes_per_sec(2.0 * 1024.0 * 1024.0 * 1024.0).contains("GiB/s"),
"GiB range uses GiB/s"
);
}
#[test]
fn test_format_duration_short_various() {
assert_eq!(format_duration_short(0), "0s");
assert_eq!(format_duration_short(1), "1s");
assert_eq!(format_duration_short(59), "59s");
assert_eq!(format_duration_short(60), "1m0s");
assert_eq!(format_duration_short(61), "1m1s");
assert_eq!(format_duration_short(3599), "59m59s");
assert_eq!(format_duration_short(3600), "1h0m0s");
assert_eq!(format_duration_short(3661), "1h1m1s");
assert!(format_duration_short(86400).starts_with("24h"));
}
#[test]
fn test_default_window_size_alpha() {
let smoother = SpeedSmoother::default();
let expected_alpha = 2.0 / (DEFAULT_WINDOW_SIZE as f64 + 1.0);
assert!(
(smoother.alpha() - expected_alpha).abs() < 0.0001,
"Default alpha should be 2/(N+1)"
);
}
#[test]
fn test_custom_window_size() {
let small_window = SpeedSmoother::new(5);
let large_window = SpeedSmoother::new(20);
assert!(
small_window.alpha() > large_window.alpha(),
"Smaller window should have higher alpha"
);
assert!(small_window.alpha() > 0.0 && small_window.alpha() <= 1.0);
assert!(large_window.alpha() > 0.0 && large_window.alpha() <= 1.0);
}
}