floresta_common/ema.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216
// SPDX-License-Identifier: MIT OR Apache-2.0
use core::num::NonZeroU16;
#[derive(Debug, Clone)]
/// **Exponential moving average** (EMA) over scalar samples.
///
/// Stores a single `f64` state and updates it in O(1) time with O(1) memory. This is also called
/// an exponentially weighted moving average; older samples are never dropped, but their influence
/// on the average decays exponentially.
///
/// With a new sample `x`, the EMA is updated as: `ema = alpha * x + (1.0 - alpha) * ema_prev`
///
/// `alpha` ∈ (0, 1) is the weight of the newest sample, whereas `1 - alpha` is the weight of the
/// previous EMA value. `alpha` is called the *smoothing factor*: larger `alpha` reacts faster to
/// sample changes, while smaller `alpha` smooths the average.
///
/// You can construct this type from a half-life integer by using [`Ema::from_half_life`]. After
/// `half_life` updates, a sample contributes half as much as when it was added. With half-life
/// `H`, the most recent `N` samples contribute `1 - 2^(-N/H)` of the total weight (once warmed
/// up). Example: if `H = 50`, newest 50 samples = 50%, 100 = 75%, 150 = 87.5%, etc.
pub struct Ema {
/// Smoothing factor: weight of the newest sample.
alpha: f64,
/// Current EMA value, if any samples have been added.
value: Option<f64>,
}
impl Ema {
/// EMA preset: half-life 50 samples (good default for peer message latency).
pub fn with_half_life_50() -> Self {
Self::from_half_life(NonZeroU16::new(50).expect("non-zero"))
}
/// EMA preset: half-life 1000 samples (low noise, good for e.g., block validation time).
pub fn with_half_life_1000() -> Self {
Self::from_half_life(NonZeroU16::new(1_000).expect("non-zero"))
}
/// Constructs an EMA from a half-life measured in samples.
///
/// After `half_life` updates, a sample's weight is halved. This chooses:
/// `alpha = 1 - 2^(-1/half_life)`.
pub fn from_half_life(half_life: NonZeroU16) -> Self {
let hl = half_life.get() as f64;
// alpha is guaranteed in (0, 1) here for any finite hl > 0
let alpha = 1.0 - 2f64.powf(-1.0 / hl);
Self::new(alpha)
}
/// Constructs an EMA from `alpha` in (0, 1). Panics if `alpha` is outside this range.
fn new(alpha: f64) -> Self {
assert!(alpha > 0.0 && alpha < 1.0, "invalid alpha: {alpha}");
Self { alpha, value: None }
}
/// Adds a sample to the EMA.
pub fn add(&mut self, x: f64) {
self.value = Some(match self.value {
None => x, // first sample
Some(prev) => self.alpha * x + (1.0 - self.alpha) * prev,
});
}
/// Returns the current EMA value, or `None` if no samples have been added yet.
pub fn value(&self) -> Option<f64> {
self.value
}
/// Returns the `alpha` smoothing factor used by this EMA.
pub fn alpha(&self) -> f64 {
self.alpha
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[should_panic]
fn test_ema_alpha_zero() {
let _ = Ema::new(0.0);
}
#[test]
#[should_panic]
fn test_ema_alpha_one() {
let _ = Ema::new(1.0);
}
#[test]
fn test_ema_alpha_half() {
// EMA update: v = 0.5*x + 0.5*v
let mut avg = Ema::new(0.5);
assert_eq!(avg.value(), None);
// An alpha of 0.5 means that half-life is just 1 sample
assert_eq!(
avg.alpha(),
Ema::from_half_life(NonZeroU16::new(1).unwrap()).alpha()
);
let inputs = [10.0, 20.0, 30.0, 40.0, 50.0, 1.0, 99.0, 20.0, 0.0];
let expected = [
10.0, 15.0, 22.5, 31.25, 40.625, 20.8125, 59.90625, 39.953125, 19.9765625,
];
for (x, &e) in inputs.into_iter().zip(expected.iter()) {
avg.add(x);
assert_eq!(avg.value(), Some(e));
}
}
fn assert_close(got: f64, expected: f64) {
let tol = 1e-12;
let diff = (got - expected).abs();
assert!(diff <= tol, "got {got:.15}, expected {expected:.15}");
}
#[test]
fn test_ema_from_half_life() {
fn ema_from_u16(half_life: u16) -> Ema {
let hl = NonZeroU16::new(half_life).expect("half_life should be positive");
Ema::from_half_life(hl)
}
// Precomputed reference values: alpha = 1 - 2^(-1/half_life)
let alpha_hl_50 = 0.013_767_295_506_640_798;
let alpha_hl_1000 = 0.000_692_907_009_547_494_3;
assert_close(ema_from_u16(1).alpha(), 0.5);
assert_close(ema_from_u16(2).alpha(), 0.292_893_218_813_452_4);
assert_close(ema_from_u16(3).alpha(), 0.206_299_474_015_900_2);
assert_close(ema_from_u16(4).alpha(), 0.159_103_584_746_285_5);
assert_close(ema_from_u16(5).alpha(), 0.129_449_436_703_875_88);
assert_close(ema_from_u16(6).alpha(), 0.109_101_281_859_660_73);
assert_close(ema_from_u16(7).alpha(), 0.094_276_335_736_093_29);
assert_close(ema_from_u16(8).alpha(), 0.082_995_956_795_328_78);
assert_close(ema_from_u16(9).alpha(), 0.074_125_287_712_709_54);
assert_close(ema_from_u16(10).alpha(), 0.066_967_008_463_192_59);
assert_close(ema_from_u16(20).alpha(), 0.034_063_671_075_154_4);
assert_close(ema_from_u16(40).alpha(), 0.017_179_401_454_748_944);
assert_close(ema_from_u16(50).alpha(), alpha_hl_50);
assert_close(Ema::with_half_life_50().alpha(), alpha_hl_50);
assert_close(ema_from_u16(100).alpha(), 0.006_907_504_562_964_073);
assert_close(ema_from_u16(200).alpha(), 0.003_459_737_172_132_216);
assert_close(ema_from_u16(1000).alpha(), alpha_hl_1000);
assert_close(Ema::with_half_life_1000().alpha(), alpha_hl_1000);
assert_close(ema_from_u16(u16::MAX).alpha(), 0.000_010_576_692_072_161_72);
for i in 1..=u16::MAX {
// All values produce a valid alpha value (or else this would panic).
let _ = ema_from_u16(i);
}
}
#[test]
fn test_ema_half_life_50() {
let mut ema = Ema::from_half_life(NonZeroU16::new(50).unwrap());
assert_eq!(ema.alpha(), Ema::with_half_life_50().alpha());
assert_eq!(ema.value(), None);
let baseline = 120.0; // ms
let up = 120.2; // +0.2 ms
let down = 119.8; // -0.2 ms
// 1) Stabilize at baseline with 100 identical samples.
for _ in 0..100 {
ema.add(baseline);
}
assert_close(ema.value().unwrap(), 120.0);
// 2) Step up for 100 samples.
// With half-life=50:
// - after 50 samples, you're ~50% to the new level: 120.1
// - after 100 samples, you're ~75% to the new level: 120.15
let up_expected = [
(1, 120.002_753_459_101_33),
(2, 120.005_469_010_517_54),
(5, 120.013_393_401_692_62),
(20, 120.048_428_343_348_93),
(50, 120.099_999_999_999_94),
(100, 120.149_999_999_999_9),
];
for i in 1..=100 {
ema.add(up);
if let Some((_, e)) = up_expected.iter().find(|(k, _)| *k == i) {
assert_close(ema.value().unwrap(), *e);
}
}
// 3) Step down for 100 samples.
// Starting near 120.15, stepping to 119.8 (delta -0.35):
// - after 50 samples: 120.15 - 0.175 = 119.975
// - after 100 samples: 120.15 - 0.2625 = 119.8875
let down_expected = [
(1, 120.145_181_446_572_58),
(2, 120.140_429_231_594_2),
(5, 120.126_561_547_037_78),
(20, 120.065_250_399_139_22),
(50, 119.974_999_999_999_9),
(100, 119.887_499_999_999_92),
];
for i in 1..=100 {
ema.add(down);
if let Some((_, e)) = down_expected.iter().find(|(k, _)| *k == i) {
assert_close(ema.value().unwrap(), *e);
}
}
}
}