[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
@@ -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