Skip to main content

hya_core/
rate.rs

1// Copyright (C) 2026 Javad Rajabzadeh
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! A smoothed transfer rate and the ETA derived from it, for progress readouts.
5//!
6//! The scheduler's own per-connection estimates are a control signal and twitch
7//! by design; a readout built from them jumps every refresh. This is the other
8//! kind of rate: bytes over wall clock through a time constant, which reads as
9//! a steady counter whatever the caller's refresh cadence is.
10
11/// Exponentially weighted moving average of a byte counter's rate.
12///
13/// Time-based: the weight of each sample depends on how much wall clock it
14/// covers, not on how many samples were taken, so the smoothing has the same
15/// time constant at 50 Hz and at one sample a second. The estimate starts at
16/// zero and is driven by the samples alone; the first call only records where
17/// the counter stood.
18#[derive(Clone, Debug, PartialEq)]
19pub struct RateMeter {
20    tau: f64,
21    rate: f64,
22    mark: Option<(f64, u64)>,
23}
24
25impl RateMeter {
26    /// Below this the rate is startup noise and an ETA built from it counts
27    /// down from nonsense; [`eta_secs`](Self::eta_secs) reports none instead.
28    pub const ETA_FLOOR: f64 = 1024.0;
29
30    /// A meter that forgets about 63% of the past every `tau_secs` seconds.
31    pub fn new(tau_secs: f64) -> Self {
32        RateMeter {
33            tau: tau_secs.max(f64::MIN_POSITIVE),
34            rate: 0.0,
35            mark: None,
36        }
37    }
38
39    /// Feed the counter's value at `now_secs` and get the smoothed rate back.
40    ///
41    /// A sample that does not advance the clock is ignored, and a counter that
42    /// went backwards (a retried chunk) reads as zero bytes moved, not as a
43    /// negative rate.
44    pub fn sample(&mut self, now_secs: f64, done: u64) -> f64 {
45        if let Some((at, prev)) = self.mark {
46            let dt = now_secs - at;
47            if dt <= 0.0 {
48                return self.rate;
49            }
50            let inst = done.saturating_sub(prev) as f64 / dt;
51            let keep = (-dt / self.tau).exp();
52            self.rate = self.rate * keep + inst * (1.0 - keep);
53        }
54        self.mark = Some((now_secs, done));
55        self.rate
56    }
57
58    /// The smoothed rate in bytes per second.
59    pub fn rate(&self) -> f64 {
60        self.rate
61    }
62
63    /// Seconds until `remaining` bytes arrive at the current rate, once the
64    /// rate has warmed past [`ETA_FLOOR`](Self::ETA_FLOOR).
65    pub fn eta_secs(&self, remaining: u64) -> Option<f64> {
66        if self.rate > Self::ETA_FLOOR {
67            Some(remaining as f64 / self.rate)
68        } else {
69            None
70        }
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    fn close(a: f64, b: f64) -> bool {
79        (a - b).abs() <= 1e-6 * b.abs().max(1.0)
80    }
81
82    #[test]
83    fn the_first_sample_only_sets_the_mark() {
84        let mut m = RateMeter::new(1.5);
85        assert_eq!(m.sample(0.0, 5000), 0.0);
86        assert_eq!(m.rate(), 0.0);
87        assert_eq!(m.eta_secs(1), None);
88    }
89
90    #[test]
91    fn a_steady_transfer_converges_on_its_true_rate() {
92        let mut m = RateMeter::new(1.5);
93        let mut done = 0;
94        for i in 0..200 {
95            done += 10_000;
96            m.sample(i as f64 * 0.1, done);
97        }
98        assert!((m.rate() - 100_000.0).abs() < 1.0, "{}", m.rate());
99    }
100
101    #[test]
102    fn one_time_constant_forgets_sixty_three_percent_of_the_past() {
103        let mut m = RateMeter::new(2.0);
104        m.sample(0.0, 0);
105        m.sample(2.0, 2_000_000);
106        let expected = 1_000_000.0 * (1.0 - (-1.0f64).exp());
107        assert!(close(m.rate(), expected), "{} vs {expected}", m.rate());
108    }
109
110    #[test]
111    fn the_time_constant_does_not_depend_on_the_sample_cadence() {
112        let mut fast = RateMeter::new(1.5);
113        let mut slow = RateMeter::new(1.5);
114        for i in 0..=30 {
115            fast.sample(i as f64 * 0.1, i * 100_000);
116        }
117        for i in 0..=3 {
118            slow.sample(i as f64, i * 1_000_000);
119        }
120        let expected = 1_000_000.0 * (1.0 - (-3.0f64 / 1.5).exp());
121        assert!(
122            close(fast.rate(), expected),
123            "{} vs {expected}",
124            fast.rate()
125        );
126        assert!(
127            close(slow.rate(), expected),
128            "{} vs {expected}",
129            slow.rate()
130        );
131    }
132
133    #[test]
134    fn a_stalled_transfer_decays_towards_zero() {
135        let mut m = RateMeter::new(1.0);
136        m.sample(0.0, 0);
137        m.sample(1.0, 1_000_000);
138        let busy = m.rate();
139        m.sample(2.0, 1_000_000);
140        m.sample(3.0, 1_000_000);
141        assert!(m.rate() < busy * 0.2, "{} after {busy}", m.rate());
142        assert!(m.rate() > 0.0);
143    }
144
145    #[test]
146    fn a_clock_that_did_not_advance_and_a_counter_that_went_backwards_are_harmless() {
147        let mut m = RateMeter::new(1.5);
148        m.sample(0.0, 0);
149        m.sample(1.0, 500_000);
150        let r = m.rate();
151        assert_eq!(m.sample(1.0, 900_000), r);
152        assert_eq!(m.sample(0.5, 900_000), r);
153        let after = m.sample(2.0, 100_000);
154        assert!(after < r && after >= 0.0, "{after} after {r}");
155    }
156
157    #[test]
158    fn eta_is_withheld_until_the_rate_clears_the_floor() {
159        let mut m = RateMeter::new(0.001);
160        m.sample(0.0, 0);
161        m.sample(1.0, 1024);
162        assert!(close(m.rate(), 1024.0));
163        assert_eq!(m.eta_secs(4096), None, "exactly the floor is still noise");
164        m.sample(2.0, 1024 + 2048);
165        assert!(m.rate() > RateMeter::ETA_FLOOR);
166        let eta = m.eta_secs(4096).unwrap();
167        assert!(close(eta, 4096.0 / m.rate()), "{eta}");
168        assert_eq!(m.eta_secs(0), Some(0.0));
169    }
170
171    #[test]
172    fn a_degenerate_time_constant_does_not_produce_nan() {
173        let mut m = RateMeter::new(0.0);
174        m.sample(0.0, 0);
175        let r = m.sample(0.5, 1000);
176        assert!(r.is_finite());
177        assert!(close(r, 2000.0), "{r}");
178    }
179}