Source code for progressbar.algorithms
"""Smoothing algorithms backing `SmoothingETA` and similar widgets.
Both concrete implementations below seed their running state with the
*first* observed value rather than `0`, so an EMA/DEMA-backed ETA doesn't
start out biased toward zero before enough samples have arrived. Both
`update` methods also accept an `elapsed` argument that they currently
ignore -- it's part of the `SmoothingAlgorithm` contract (for algorithms
that might weight by time rather than call count) but neither
implementation here uses it.
"""
from __future__ import annotations
import abc
import typing
from datetime import timedelta
[docs]
class SmoothingAlgorithm(abc.ABC):
"""Contract for a stateful value smoother fed one sample at a time."""
@abc.abstractmethod
def __init__(self, **kwargs: typing.Any):
"""Configure the algorithm.
Args:
**kwargs: Algorithm-specific parameters (e.g. `alpha`).
"""
raise NotImplementedError
[docs]
@abc.abstractmethod
def update(self, new_value: float, elapsed: timedelta) -> float:
"""Fold `new_value` in and return the smoothed value."""
raise NotImplementedError
[docs]
class ExponentialMovingAverage(SmoothingAlgorithm):
"""Exponentially weighted moving average (EMA) of the observed values.
More responsive to recent changes than a simple moving average, with
less lag.
"""
def __init__(self, alpha: float = 0.5) -> None:
"""Set the smoothing factor.
Args:
alpha: Weight given to the newest observation on each
`update()` (0-1); higher tracks recent values more
closely, lower smooths harder.
"""
self.alpha: float = alpha
self.value: float | None = None
[docs]
def update(self, new_value: float, elapsed: timedelta) -> float:
"""Fold `new_value` into the running average.
Args:
new_value: Latest observed value.
elapsed: Accepted for `SmoothingAlgorithm` compatibility but
not used by this implementation -- the average is weighted
by call count, not by wall-clock time.
Returns:
The updated EMA.
"""
if self.value is None:
# Seed with the first observation instead of biasing towards 0
self.value = new_value
else:
self.value = self.alpha * new_value + (1 - self.alpha) * self.value
return self.value
[docs]
class DoubleExponentialMovingAverage(SmoothingAlgorithm):
"""An EMA of an EMA (DEMA), reducing the lag a single EMA carries.
Reacts to recent changes faster than the plain
`ExponentialMovingAverage`.
"""
def __init__(self, alpha: float = 0.5) -> None:
"""Set the smoothing factor.
Args:
alpha: Weight given to the newest observation in each of the
two nested EMAs (0-1); higher tracks recent values more
closely, lower smooths harder.
"""
self.alpha: float = alpha
self.ema1: float | None = None
self.ema2: float | None = None
[docs]
def update(self, new_value: float, elapsed: timedelta) -> float:
"""Fold `new_value` into both nested EMAs.
Args:
new_value: Latest observed value.
elapsed: Ignored, as in `ExponentialMovingAverage.update`.
Returns:
The DEMA estimate, `2 * ema1 - ema2`.
"""
if self.ema1 is None or self.ema2 is None:
# Seed with the first observation instead of biasing towards 0
self.ema1 = self.ema2 = new_value
else:
self.ema1 = self.alpha * new_value + (1 - self.alpha) * self.ema1
self.ema2 = self.alpha * self.ema1 + (1 - self.alpha) * self.ema2
return 2 * self.ema1 - self.ema2