vector_common/stats/
time_ewma.rs

1use std::time::Instant;
2
3#[derive(Clone, Copy, Debug)]
4struct State {
5    average: f64,
6    point: f64,
7    reference: Instant,
8}
9
10/// Continuous-Time Exponentially Weighted Moving Average.
11///
12/// This is used to average values that are observed at irregular intervals but have a fixed value
13/// between the observations, AKA a piecewise-constant signal sampled at change points. Instead of
14/// an "alpha" parameter, this uses a "half-life" parameter which is the time it takes for the
15/// average to decay to half of its value, measured in seconds.
16#[derive(Clone, Copy, Debug)]
17pub struct TimeEwma {
18    state: Option<State>,
19    half_life_seconds: f64,
20}
21
22impl TimeEwma {
23    #[must_use]
24    pub const fn new(half_life_seconds: f64) -> Self {
25        Self {
26            state: None,
27            half_life_seconds,
28        }
29    }
30
31    #[must_use]
32    pub fn average(&self) -> Option<f64> {
33        self.state.map(|state| state.average)
34    }
35
36    /// Update the current average and return it for convenience. Note that this average will "lag"
37    /// the current observation because the new average is based on the previous point and the
38    /// duration during which it was held constant. If the reference time is before the previous
39    /// update, the update is ignored and the previous average is returned.
40    pub fn update(&mut self, point: f64, reference: Instant) -> f64 {
41        let average = match self.state {
42            None => point,
43            Some(state) => {
44                if let Some(duration) = reference.checked_duration_since(state.reference) {
45                    let k = (-duration.as_secs_f64() / self.half_life_seconds).exp2();
46                    // The elapsed duration applies to the previously observed point, since that value
47                    // was held constant between observations.
48                    k * state.average + (1.0 - k) * state.point
49                } else {
50                    state.average
51                }
52            }
53        };
54        self.state = Some(State {
55            average,
56            point,
57            reference,
58        });
59        average
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::TimeEwma;
66    use std::time::{Duration, Instant};
67
68    #[test]
69    #[expect(clippy::float_cmp, reason = "exact values for this test")]
70    fn time_ewma_uses_previous_point_duration() {
71        let mut ewma = TimeEwma::new(1.0);
72        let t0 = Instant::now();
73        let t1 = t0 + Duration::from_secs(1);
74        let t2 = t1 + Duration::from_secs(1);
75
76        assert_eq!(ewma.average(), None);
77        assert_eq!(ewma.update(0.0, t0), 0.0);
78        assert_eq!(ewma.average(), Some(0.0));
79        assert_eq!(ewma.update(10.0, t1), 0.0);
80        assert_eq!(ewma.average(), Some(0.0));
81        assert_eq!(ewma.update(10.0, t2), 5.0);
82        assert_eq!(ewma.average(), Some(5.0));
83    }
84}