[exponential_moving_average] Add EMA preserved across reboots (#19882)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Clyde Stubbs
2026-10-05 13:19:12 +11:00
committed by GitHub
co-authored by J. Nick Koston
parent 6f2d2a2001
commit d9b3f299e0
12 changed files with 923 additions and 0 deletions
+1
View File
@@ -191,6 +191,7 @@ esphome/components/espnow/* @jesserockz
esphome/components/espnow/packet_transport/* @EasilyBoredEngineer
esphome/components/ethernet_info/* @gtjadsonsantos
esphome/components/event/* @nohat
esphome/components/exponential_moving_average/* @clydebarrow
esphome/components/exposure_notifications/* @OttoWinter
esphome/components/ezo/* @ssieb
esphome/components/ezo_pmp/* @carlos-sarmiento
@@ -0,0 +1 @@
CODEOWNERS = ["@clydebarrow"]
@@ -0,0 +1,116 @@
#include "exponential_moving_average_sensor.h"
#include "esphome/core/application.h"
#include "esphome/core/log.h"
#include <cmath>
namespace esphome::exponential_moving_average {
static const char *const TAG = "exponential_moving_average";
const LogString *time_weighting_to_string(TimeWeighting weighting) {
switch (weighting) {
case TIME_WEIGHTING_PREVIOUS:
return LOG_STR("previous");
case TIME_WEIGHTING_LINEAR:
return LOG_STR("linear");
default:
return LOG_STR("new");
}
}
ScaledDuration scale_duration(uint32_t ms) {
if (ms < 1000)
return {static_cast<float>(ms), LOG_STR("ms"), 0};
if (ms < 60 * 1000)
return {ms / 1000.0f, LOG_STR("s"), 1};
if (ms < 60 * 60 * 1000)
return {ms / (60 * 1000.0f), LOG_STR("min"), 1};
return {ms / (60 * 60 * 1000.0f), LOG_STR("h"), 1};
}
void ExponentialMovingAverageSensor::setup() {
if (this->restore_) {
this->pref_ = this->make_entity_preference<float>();
float restored;
if (this->pref_.load(&restored) && std::isfinite(restored)) {
this->accumulator_ = restored;
this->publish_state(restored);
}
}
const uint32_t now = App.get_loop_component_start_time();
this->last_update_ = now;
this->source_->add_on_state_callback(
[this](float value) { this->process_(value, App.get_loop_component_start_time()); });
// The source may have published during its own setup(), before the callback was added.
if (this->source_->has_state())
this->process_(this->source_->state, now);
}
void ExponentialMovingAverageSensor::dump_config() {
LOG_SENSOR("", "Exponential Moving Average Sensor", this);
if (this->time_constant_ms_ != 0) {
const ScaledDuration time_constant = scale_duration(this->time_constant_ms_);
ESP_LOGCONFIG(TAG,
" Time Constant: %.*f %s\n"
" Time Weighting: %s",
time_constant.decimals, time_constant.value, LOG_STR_ARG(time_constant.unit),
LOG_STR_ARG(time_weighting_to_string(this->time_weighting_)));
} else {
ESP_LOGCONFIG(TAG, " Alpha: %.3f", this->alpha_);
}
ESP_LOGCONFIG(TAG, " Restore: %s", YESNO(this->restore_));
}
void ExponentialMovingAverageSensor::reset() { this->publish_and_save_(NAN); }
void ExponentialMovingAverageSensor::process_(float value, uint32_t now) {
if (std::isnan(value))
return;
// After a reboot the downtime is unknown, so the first interval is measured from setup().
const uint32_t dt = now - this->last_update_;
this->last_update_ = now;
const float previous = this->previous_value_;
this->previous_value_ = value;
if (std::isnan(this->accumulator_)) {
this->publish_and_save_(value);
return;
}
if (this->time_constant_ms_ == 0) {
this->publish_and_save_(this->alpha_ * value + (1.0f - this->alpha_) * this->accumulator_);
return;
}
// Computed in double with expm1(): when the interval is short compared to the time constant, the weights are
// tiny and float rounding of exp() would swamp them.
const double x = static_cast<double>(dt) / this->time_constant_ms_;
// The share of the old average replaced during this interval.
const double gain = -std::expm1(-x);
const double average = this->accumulator_;
// After a reboot there is no previous reading, so only the new value can be used.
const TimeWeighting weighting = std::isnan(previous) ? TIME_WEIGHTING_NEW : this->time_weighting_;
double result;
switch (weighting) {
case TIME_WEIGHTING_PREVIOUS:
result = average + gain * (previous - average);
break;
case TIME_WEIGHTING_LINEAR: {
// Exact result for a value moving in a straight line from the previous reading to the new one.
const double weight_new = x > 0.0 ? (x + std::expm1(-x)) / x : 0.0;
result = average + (gain - weight_new) * (previous - average) + weight_new * (value - average);
break;
}
default:
result = average + gain * (value - average);
break;
}
this->publish_and_save_(static_cast<float>(result));
}
void ExponentialMovingAverageSensor::publish_and_save_(float value) {
this->accumulator_ = value;
this->publish_state(value);
if (this->restore_)
this->pref_.save(&value);
}
} // namespace esphome::exponential_moving_average
@@ -0,0 +1,61 @@
#pragma once
#include <cmath>
#include <cstdint>
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "esphome/core/preferences.h"
#include "esphome/components/sensor/sensor.h"
namespace esphome::exponential_moving_average {
/// Which value is assumed to apply during the time between two readings, when a time constant is used.
enum TimeWeighting : uint8_t {
TIME_WEIGHTING_NEW = 0,
TIME_WEIGHTING_PREVIOUS,
TIME_WEIGHTING_LINEAR,
};
const LogString *time_weighting_to_string(TimeWeighting weighting);
/// A duration in the largest of ms, s, min or h that keeps the value at 1 or more.
struct ScaledDuration {
float value;
const LogString *unit;
uint8_t decimals;
};
ScaledDuration scale_duration(uint32_t ms);
class ExponentialMovingAverageSensor : public sensor::Sensor, public Component {
public:
explicit ExponentialMovingAverageSensor(sensor::Sensor *source) : source_(source) {}
void setup() override;
void dump_config() override;
void set_alpha(float alpha) { this->alpha_ = alpha; }
/// When non-zero, each sample is weighted by the time since the previous one instead of by a fixed alpha.
void set_time_constant(uint32_t time_constant_ms) { this->time_constant_ms_ = time_constant_ms; }
void set_time_weighting(TimeWeighting weighting) { this->time_weighting_ = weighting; }
void set_restore(bool restore) { this->restore_ = restore; }
/// Clear the average; the next sample starts it again.
void reset();
protected:
void process_(float value, uint32_t now);
void publish_and_save_(float value);
sensor::Sensor *source_;
ESPPreferenceObject pref_;
float alpha_{0.1f};
float accumulator_{NAN};
float previous_value_{NAN};
uint32_t time_constant_ms_{0};
uint32_t last_update_{0};
TimeWeighting time_weighting_{TIME_WEIGHTING_NEW};
bool restore_{true};
};
} // namespace esphome::exponential_moving_average
@@ -0,0 +1,103 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import sensor
import esphome.config_validation as cv
from esphome.const import (
CONF_ACCURACY_DECIMALS,
CONF_ALPHA,
CONF_DEVICE_CLASS,
CONF_ICON,
CONF_ID,
CONF_RESTORE,
CONF_SENSOR,
CONF_STATE_CLASS,
CONF_TIME_CONSTANT,
CONF_UNIT_OF_MEASUREMENT,
)
from esphome.core.entity_helpers import inherit_property_from
from esphome.types import ConfigType
exponential_moving_average_ns = cg.esphome_ns.namespace("exponential_moving_average")
ExponentialMovingAverageSensor = exponential_moving_average_ns.class_(
"ExponentialMovingAverageSensor", sensor.Sensor, cg.Component
)
TimeWeighting = exponential_moving_average_ns.enum("TimeWeighting")
TIME_WEIGHTINGS: dict[str, cg.MockObj] = {
"new": TimeWeighting.TIME_WEIGHTING_NEW,
"previous": TimeWeighting.TIME_WEIGHTING_PREVIOUS,
"linear": TimeWeighting.TIME_WEIGHTING_LINEAR,
}
CONF_TIME_WEIGHTING: str = "time_weighting"
DEFAULT_ALPHA: float = 0.1
def inherit_accuracy_decimals(decimals: int, config: ConfigType) -> int:
# An average carries more precision than the individual readings.
return decimals + 1
def validate_time_weighting(config: ConfigType) -> ConfigType:
if CONF_TIME_WEIGHTING in config and CONF_TIME_CONSTANT not in config:
raise cv.Invalid(
f"'{CONF_TIME_WEIGHTING}' can only be used with '{CONF_TIME_CONSTANT}'",
path=[CONF_TIME_WEIGHTING],
)
return config
CONFIG_SCHEMA = cv.All(
sensor.sensor_schema(ExponentialMovingAverageSensor)
.extend(
{
cv.Required(CONF_SENSOR): cv.use_id(sensor.Sensor),
cv.Optional(CONF_ALPHA): cv.All(
cv.float_, cv.Range(min=0, min_included=False, max=1)
),
cv.Optional(CONF_TIME_CONSTANT): cv.positive_time_period_milliseconds,
cv.Optional(CONF_TIME_WEIGHTING): cv.enum(TIME_WEIGHTINGS, lower=True),
cv.Optional(CONF_RESTORE, default=True): cv.boolean,
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.has_at_most_one_key(CONF_ALPHA, CONF_TIME_CONSTANT),
validate_time_weighting,
)
FINAL_VALIDATE_SCHEMA = cv.All(
inherit_property_from(CONF_ICON, CONF_SENSOR),
inherit_property_from(CONF_UNIT_OF_MEASUREMENT, CONF_SENSOR),
inherit_property_from(
CONF_ACCURACY_DECIMALS, CONF_SENSOR, transform=inherit_accuracy_decimals
),
inherit_property_from(CONF_DEVICE_CLASS, CONF_SENSOR),
inherit_property_from(CONF_STATE_CLASS, CONF_SENSOR),
)
async def to_code(config: ConfigType) -> None:
source = await cg.get_variable(config[CONF_SENSOR])
var = cg.new_Pvariable(config[CONF_ID], source)
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
if (time_constant := config.get(CONF_TIME_CONSTANT)) is not None:
cg.add(var.set_time_constant(time_constant))
if (weighting := config.get(CONF_TIME_WEIGHTING)) is not None:
cg.add(var.set_time_weighting(weighting))
else:
cg.add(var.set_alpha(config.get(CONF_ALPHA, DEFAULT_ALPHA)))
cg.add(var.set_restore(config[CONF_RESTORE]))
automation.register_apply_action(
"sensor.exponential_moving_average.reset",
automation.maybe_simple_id(
{
cv.Required(CONF_ID): cv.use_id(ExponentialMovingAverageSensor),
}
),
automation.ApplyCall("reset()"),
)
@@ -0,0 +1,40 @@
esphome:
name: test
on_boot:
then:
- sensor.exponential_moving_average.reset: ema_default
esp32:
board: esp32dev
sensor:
- platform: template
id: source_sensor
unit_of_measurement: "°C"
accuracy_decimals: 1
device_class: temperature
state_class: measurement
lambda: return 1.0;
- platform: exponential_moving_average
id: ema_default
name: EMA Default
sensor: source_sensor
- platform: exponential_moving_average
id: ema_alpha
name: EMA Alpha
sensor: source_sensor
alpha: 0.25
unit_of_measurement: "K"
accuracy_decimals: 3
- platform: exponential_moving_average
id: ema_time_constant
name: EMA Time Constant
sensor: source_sensor
time_constant: 5min
restore: false
- platform: exponential_moving_average
id: ema_linear
name: EMA Linear
sensor: source_sensor
time_constant: 30s
time_weighting: linear
@@ -0,0 +1,166 @@
"""Tests for the exponential_moving_average sensor."""
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome import config_validation as cv
from esphome.components.exponential_moving_average.sensor import CONFIG_SCHEMA
def test_default_alpha_and_restore(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without alpha or time_constant, alpha defaults to 0.1 and restore is on."""
main_cpp = generate_main(
component_config_path("exponential_moving_average_test.yaml")
)
assert (
"new(ema_default) exponential_moving_average::ExponentialMovingAverageSensor(source_sensor);"
in main_cpp
)
assert "ema_default->set_alpha(0.1f);" in main_cpp
assert "ema_default->set_restore(true);" in main_cpp
def test_alpha(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(
component_config_path("exponential_moving_average_test.yaml")
)
assert "ema_alpha->set_alpha(0.25f);" in main_cpp
assert "ema_alpha->set_time_constant" not in main_cpp
def test_time_constant_replaces_alpha(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(
component_config_path("exponential_moving_average_test.yaml")
)
assert "ema_time_constant->set_time_constant(300000);" in main_cpp
assert "ema_time_constant->set_alpha" not in main_cpp
assert "ema_time_constant->set_restore(false);" in main_cpp
assert "ema_time_constant->set_time_weighting" not in main_cpp
def test_time_weighting(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(
component_config_path("exponential_moving_average_test.yaml")
)
assert "ema_linear->set_time_constant(30000);" in main_cpp
assert (
"ema_linear->set_time_weighting(exponential_moving_average::TIME_WEIGHTING_LINEAR);"
in main_cpp
)
def test_properties_inherited_from_source(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Unset properties come from the source sensor, with one extra decimal; set ones are kept."""
main_cpp = generate_main(
component_config_path("exponential_moving_average_test.yaml")
)
assert "ema_default->set_accuracy_decimals(2);" in main_cpp
assert "ema_alpha->set_accuracy_decimals(3);" in main_cpp
default_line = next(
line for line in main_cpp.splitlines() if '"EMA Default"' in line
)
alpha_line = next(line for line in main_cpp.splitlines() if '"EMA Alpha"' in line)
assert "°C" in default_line
assert "temperature" in default_line
assert "K" in alpha_line
def test_reset_action(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
main_cpp = generate_main(
component_config_path("exponential_moving_average_test.yaml")
)
assert "::ema_default->reset();" in main_cpp
def test_alpha_and_time_constant_are_exclusive() -> None:
with pytest.raises(cv.Invalid, match="Cannot specify more than one of"):
CONFIG_SCHEMA(
{
"id": "ema",
"name": "EMA",
"sensor": "source",
"alpha": 0.5,
"time_constant": "1min",
}
)
@pytest.mark.parametrize("alpha", [0, -0.1, 1.5])
def test_alpha_out_of_range(alpha: float) -> None:
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA({"id": "ema", "name": "EMA", "sensor": "source", "alpha": alpha})
@pytest.mark.parametrize("alpha", [0.01, 1])
def test_alpha_in_range(alpha: float) -> None:
config = CONFIG_SCHEMA(
{"id": "ema", "name": "EMA", "sensor": "source", "alpha": alpha}
)
assert config["alpha"] == alpha
def test_time_weighting_requires_time_constant() -> None:
with pytest.raises(cv.Invalid, match="can only be used with 'time_constant'"):
CONFIG_SCHEMA(
{
"id": "ema",
"name": "EMA",
"sensor": "source",
"time_weighting": "previous",
}
)
@pytest.mark.parametrize("weighting", ["new", "previous", "linear", "LINEAR"])
def test_time_weighting_values(weighting: str) -> None:
config = CONFIG_SCHEMA(
{
"id": "ema",
"name": "EMA",
"sensor": "source",
"time_constant": "1min",
"time_weighting": weighting,
}
)
assert config["time_weighting"] == weighting.lower()
def test_time_weighting_rejects_unknown_value() -> None:
with pytest.raises(cv.Invalid):
CONFIG_SCHEMA(
{
"id": "ema",
"name": "EMA",
"sensor": "source",
"time_constant": "1min",
"time_weighting": "trapezoid",
}
)
@@ -0,0 +1,52 @@
#pragma once
#include <gtest/gtest.h>
#include <cstdlib>
#include <filesystem>
#include <optional>
#include <string>
#include "esphome/components/exponential_moving_average/exponential_moving_average_sensor.h"
#include "esphome/core/preferences.h"
#ifdef USE_HOST
#include "esphome/components/host/preferences.h"
#endif
namespace esphome::exponential_moving_average::testing {
class TestableExponentialMovingAverageSensor : public ExponentialMovingAverageSensor {
public:
using ExponentialMovingAverageSensor::ExponentialMovingAverageSensor;
using ExponentialMovingAverageSensor::process_;
};
// Unnamed sensors share one preference key, so a second instance created after
// the first one behaves like the same sensor after a reboot.
class ExponentialMovingAverageTest : public ::testing::Test {
protected:
void SetUp() override {
if (const char *prefdir = getenv("ESPHOME_PREFDIR"); prefdir != nullptr)
this->saved_prefdir_ = prefdir;
// Keep preferences away from the user's home directory.
setenv("ESPHOME_PREFDIR", std::filesystem::temp_directory_path().c_str(), 1);
#ifdef USE_HOST
host::setup_preferences();
#endif
global_preferences->reset();
}
void TearDown() override {
global_preferences->reset();
if (this->saved_prefdir_.has_value()) {
setenv("ESPHOME_PREFDIR", this->saved_prefdir_->c_str(), 1);
} else {
unsetenv("ESPHOME_PREFDIR");
}
}
std::optional<std::string> saved_prefdir_;
sensor::Sensor source_;
};
} // namespace esphome::exponential_moving_average::testing
@@ -0,0 +1,25 @@
sensor:
- platform: template
id: ema_source
name: EMA Source
unit_of_measurement: "°C"
accuracy_decimals: 1
lambda: return 21.5;
update_interval: 10s
- platform: exponential_moving_average
id: ema_alpha
name: EMA Alpha
sensor: ema_source
alpha: 0.2
- platform: exponential_moving_average
name: EMA Time Constant
sensor: ema_source
time_constant: 5min
time_weighting: previous
restore: false
button:
- platform: template
name: EMA Reset
on_press:
- sensor.exponential_moving_average.reset: ema_alpha
@@ -0,0 +1,356 @@
#include <cmath>
#include "../common.h"
namespace esphome::exponential_moving_average::testing {
TEST_F(ExponentialMovingAverageTest, FirstValueStartsTheAverage) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.setup();
EXPECT_FALSE(ema.has_state());
ema.process_(10.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 10.0f);
}
TEST_F(ExponentialMovingAverageTest, AlphaWeightsEachValue) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_alpha(0.5f);
ema.setup();
ema.process_(10.0f, 0);
ema.process_(20.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 15.0f);
ema.process_(20.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 17.5f);
}
TEST_F(ExponentialMovingAverageTest, NanValuesAreIgnored) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_alpha(0.5f);
ema.setup();
ema.process_(10.0f, 0);
ema.process_(NAN, 0);
EXPECT_FLOAT_EQ(ema.state, 10.0f);
ema.process_(20.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 15.0f);
}
TEST_F(ExponentialMovingAverageTest, FollowsSourceSensor) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_alpha(0.25f);
ema.setup();
this->source_.publish_state(8.0f);
this->source_.publish_state(0.0f);
EXPECT_FLOAT_EQ(ema.state, 6.0f);
}
TEST_F(ExponentialMovingAverageTest, TimeConstantWeightsByElapsedTime) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.setup();
ema.process_(0.0f, 0);
ema.process_(1.0f, 1000);
EXPECT_NEAR(ema.state, 1.0f - std::exp(-1.0f), 1e-5f);
}
TEST_F(ExponentialMovingAverageTest, TimeConstantResultDoesNotDependOnSampleRate) {
TestableExponentialMovingAverageSensor fast(&this->source_);
fast.set_time_constant(1000);
fast.set_restore(false);
fast.setup();
fast.process_(0.0f, 0);
for (uint32_t t = 100; t <= 1000; t += 100)
fast.process_(1.0f, t);
TestableExponentialMovingAverageSensor slow(&this->source_);
slow.set_time_constant(1000);
slow.set_restore(false);
slow.setup();
slow.process_(0.0f, 0);
slow.process_(1.0f, 1000);
EXPECT_NEAR(fast.state, slow.state, 1e-5f);
}
TEST_F(ExponentialMovingAverageTest, TimeConstantIgnoresRepeatAtSameTime) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.setup();
ema.process_(5.0f, 0);
ema.process_(100.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 5.0f);
}
TEST_F(ExponentialMovingAverageTest, TimeConstantHandlesTimerWraparound) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.setup();
ema.process_(0.0f, UINT32_MAX - 499);
ema.process_(1.0f, 500);
EXPECT_NEAR(ema.state, 1.0f - std::exp(-1.0f), 1e-5f);
}
TEST_F(ExponentialMovingAverageTest, ResetStartsANewAverage) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_alpha(0.5f);
ema.setup();
ema.process_(10.0f, 0);
ema.reset();
EXPECT_TRUE(std::isnan(ema.state));
ema.process_(40.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 40.0f);
}
TEST_F(ExponentialMovingAverageTest, AverageIsRestoredAfterReboot) {
{
TestableExponentialMovingAverageSensor before(&this->source_);
before.set_alpha(0.5f);
before.setup();
before.process_(10.0f, 0);
before.process_(20.0f, 0);
}
TestableExponentialMovingAverageSensor after(&this->source_);
after.set_alpha(0.5f);
after.setup();
ASSERT_TRUE(after.has_state());
EXPECT_FLOAT_EQ(after.state, 15.0f);
// Continues from the restored value rather than starting again.
after.process_(25.0f, 0);
EXPECT_FLOAT_EQ(after.state, 20.0f);
}
TEST_F(ExponentialMovingAverageTest, NothingRestoredWhenRestoreIsOff) {
{
TestableExponentialMovingAverageSensor before(&this->source_);
before.setup();
before.process_(10.0f, 0);
}
TestableExponentialMovingAverageSensor after(&this->source_);
after.set_restore(false);
after.setup();
EXPECT_FALSE(after.has_state());
after.process_(30.0f, 0);
EXPECT_FLOAT_EQ(after.state, 30.0f);
}
TEST_F(ExponentialMovingAverageTest, ResetClearsTheSavedAverage) {
{
TestableExponentialMovingAverageSensor before(&this->source_);
before.setup();
before.process_(10.0f, 0);
before.reset();
}
TestableExponentialMovingAverageSensor after(&this->source_);
after.setup();
EXPECT_FALSE(after.has_state());
}
TEST_F(ExponentialMovingAverageTest, PreviousWeightingCountsGapAtPreviousValue) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.set_time_weighting(TIME_WEIGHTING_PREVIOUS);
ema.setup();
// The value stayed at 20 for an hour before changing to 25.
ema.process_(20.0f, 0);
ema.process_(25.0f, 3600000);
EXPECT_FLOAT_EQ(ema.state, 20.0f);
// The 25 is counted over the following interval.
ema.process_(25.0f, 3601000);
EXPECT_NEAR(ema.state, 20.0f + 5.0f * (1.0f - std::exp(-1.0f)), 1e-4f);
}
TEST_F(ExponentialMovingAverageTest, NewWeightingCountsGapAtNewValue) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.setup();
ema.process_(20.0f, 0);
ema.process_(25.0f, 3600000);
EXPECT_FLOAT_EQ(ema.state, 25.0f);
}
TEST_F(ExponentialMovingAverageTest, LinearWeightingFollowsStraightLine) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.set_time_weighting(TIME_WEIGHTING_LINEAR);
ema.setup();
// An average of a value rising steadily from 0 to 1 over one time constant ends at exp(-1).
ema.process_(0.0f, 0);
ema.process_(1.0f, 1000);
EXPECT_NEAR(ema.state, std::exp(-1.0f), 1e-5f);
}
TEST_F(ExponentialMovingAverageTest, LinearWeightingMatchesManySmallSteps) {
TestableExponentialMovingAverageSensor coarse(&this->source_);
coarse.set_time_constant(1000);
coarse.set_time_weighting(TIME_WEIGHTING_LINEAR);
coarse.set_restore(false);
coarse.setup();
coarse.process_(0.0f, 0);
coarse.process_(10.0f, 2000);
TestableExponentialMovingAverageSensor fine(&this->source_);
fine.set_time_constant(1000);
fine.set_time_weighting(TIME_WEIGHTING_LINEAR);
fine.set_restore(false);
fine.setup();
fine.process_(0.0f, 0);
for (uint32_t t = 10; t <= 2000; t += 10)
fine.process_(t / 200.0f, t);
EXPECT_NEAR(coarse.state, fine.state, 1e-3f);
}
TEST_F(ExponentialMovingAverageTest, LinearWeightingIgnoresRepeatAtSameTime) {
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(1000);
ema.set_time_weighting(TIME_WEIGHTING_LINEAR);
ema.setup();
ema.process_(5.0f, 0);
ema.process_(100.0f, 0);
EXPECT_FLOAT_EQ(ema.state, 5.0f);
}
TEST_F(ExponentialMovingAverageTest, FirstValueAfterRebootUsesNewValue) {
{
TestableExponentialMovingAverageSensor before(&this->source_);
before.setup();
before.process_(10.0f, 0);
}
// No reading from before the reboot is known, so the new value is used for the first interval.
TestableExponentialMovingAverageSensor after(&this->source_);
after.set_time_constant(1000);
after.set_time_weighting(TIME_WEIGHTING_PREVIOUS);
after.setup();
after.process_(20.0f, 1000);
EXPECT_NEAR(after.state, 10.0f + 10.0f * (1.0f - std::exp(-1.0f)), 1e-4f);
}
TEST_F(ExponentialMovingAverageTest, StartsFromSourceThatAlreadyHasAValue) {
sensor::Sensor source;
source.publish_state(12.0f);
TestableExponentialMovingAverageSensor ema(&source);
ema.set_alpha(0.5f);
ema.set_restore(false);
ema.setup();
ASSERT_TRUE(ema.has_state());
EXPECT_FLOAT_EQ(ema.state, 12.0f);
// The value read at setup is only counted once.
source.publish_state(20.0f);
EXPECT_FLOAT_EQ(ema.state, 16.0f);
}
TEST_F(ExponentialMovingAverageTest, SourceValueAtSetupBlendsWithRestoredAverage) {
{
TestableExponentialMovingAverageSensor before(&this->source_);
before.setup();
before.process_(10.0f, 0);
}
sensor::Sensor source;
source.publish_state(20.0f);
TestableExponentialMovingAverageSensor after(&source);
after.set_alpha(0.5f);
after.setup();
EXPECT_FLOAT_EQ(after.state, 15.0f);
}
TEST_F(ExponentialMovingAverageTest, SourceNanAtSetupIsIgnored) {
sensor::Sensor source;
source.publish_state(NAN);
TestableExponentialMovingAverageSensor ema(&source);
ema.set_restore(false);
ema.setup();
EXPECT_FALSE(ema.has_state());
}
// Reference weights from the Taylor series, accurate for the small ratios used below.
static double series_gain(double x) { return x - x * x / 2 + x * x * x / 6; }
static double series_weight_new(double x) { return x / 2 - x * x / 6 + x * x * x / 24; }
TEST_F(ExponentialMovingAverageTest, LinearWeightingAccurateWithLongTimeConstant) {
constexpr uint32_t time_constant = 43200000; // 12 hours
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(time_constant);
ema.set_time_weighting(TIME_WEIGHTING_LINEAR);
ema.set_restore(false);
ema.setup();
const double x = 1000.0 / time_constant;
const double weight_new = series_weight_new(x);
const double weight_previous = series_gain(x) - weight_new;
ema.process_(0.0f, 0);
ema.process_(10.0f, 1000);
const double expected = weight_new * 10.0;
EXPECT_NEAR(ema.state, expected, expected * 1e-4);
ema.process_(20.0f, 2000);
const double expected2 = expected + weight_previous * (10.0 - expected) + weight_new * (20.0 - expected);
EXPECT_NEAR(ema.state, expected2, expected2 * 1e-4);
}
TEST_F(ExponentialMovingAverageTest, VeryShortIntervalStillMovesAverage) {
constexpr uint32_t time_constant = 4 * 24 * 3600000; // 4 days, with a reading on every 16 ms loop
TestableExponentialMovingAverageSensor ema(&this->source_);
ema.set_time_constant(time_constant);
ema.set_restore(false);
ema.setup();
ema.process_(0.0f, 0);
ema.process_(1000.0f, 16);
const double expected = series_gain(16.0 / time_constant) * 1000.0;
EXPECT_NEAR(ema.state, expected, expected * 1e-4);
}
TEST(TimeWeightingTest, Names) {
EXPECT_STREQ(LOG_STR_ARG(time_weighting_to_string(TIME_WEIGHTING_NEW)), "new");
EXPECT_STREQ(LOG_STR_ARG(time_weighting_to_string(TIME_WEIGHTING_PREVIOUS)), "previous");
EXPECT_STREQ(LOG_STR_ARG(time_weighting_to_string(TIME_WEIGHTING_LINEAR)), "linear");
}
struct ScaleDurationCase {
uint32_t ms;
float value;
const char *unit;
uint8_t decimals;
};
class ScaleDurationTest : public ::testing::TestWithParam<ScaleDurationCase> {};
TEST_P(ScaleDurationTest, PicksLargestUnitOfAtLeastOne) {
const ScaleDurationCase &c = GetParam();
const ScaledDuration scaled = scale_duration(c.ms);
EXPECT_FLOAT_EQ(scaled.value, c.value);
EXPECT_STREQ(LOG_STR_ARG(scaled.unit), c.unit);
EXPECT_EQ(scaled.decimals, c.decimals);
}
INSTANTIATE_TEST_SUITE_P(
Units, ScaleDurationTest,
::testing::Values(ScaleDurationCase{1, 1.0f, "ms", 0}, ScaleDurationCase{999, 999.0f, "ms", 0},
ScaleDurationCase{1000, 1.0f, "s", 1}, ScaleDurationCase{95000, 1.5833334f, "min", 1},
ScaleDurationCase{59999, 59.999f, "s", 1}, ScaleDurationCase{60000, 1.0f, "min", 1},
ScaleDurationCase{300000, 5.0f, "min", 1}, ScaleDurationCase{3599999, 59.999983f, "min", 1},
ScaleDurationCase{3600000, 1.0f, "h", 1}, ScaleDurationCase{86400000, 24.0f, "h", 1}));
} // namespace esphome::exponential_moving_average::testing
@@ -0,0 +1,2 @@
packages:
exponential_moving_average: !include common.yaml