diff --git a/CODEOWNERS b/CODEOWNERS index b453cf4538..80f243e82e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/esphome/components/exponential_moving_average/__init__.py b/esphome/components/exponential_moving_average/__init__.py new file mode 100644 index 0000000000..c58ce8a01e --- /dev/null +++ b/esphome/components/exponential_moving_average/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@clydebarrow"] diff --git a/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp new file mode 100644 index 0000000000..243e2cf31e --- /dev/null +++ b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.cpp @@ -0,0 +1,116 @@ +#include "exponential_moving_average_sensor.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include + +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(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 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(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(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 diff --git a/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h new file mode 100644 index 0000000000..98bd88553b --- /dev/null +++ b/esphome/components/exponential_moving_average/exponential_moving_average_sensor.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +#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 diff --git a/esphome/components/exponential_moving_average/sensor.py b/esphome/components/exponential_moving_average/sensor.py new file mode 100644 index 0000000000..20c8135636 --- /dev/null +++ b/esphome/components/exponential_moving_average/sensor.py @@ -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()"), +) diff --git a/tests/component_tests/exponential_moving_average/__init__.py b/tests/component_tests/exponential_moving_average/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/component_tests/exponential_moving_average/config/exponential_moving_average_test.yaml b/tests/component_tests/exponential_moving_average/config/exponential_moving_average_test.yaml new file mode 100644 index 0000000000..88735777d2 --- /dev/null +++ b/tests/component_tests/exponential_moving_average/config/exponential_moving_average_test.yaml @@ -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 diff --git a/tests/component_tests/exponential_moving_average/test_exponential_moving_average.py b/tests/component_tests/exponential_moving_average/test_exponential_moving_average.py new file mode 100644 index 0000000000..fea5dddb00 --- /dev/null +++ b/tests/component_tests/exponential_moving_average/test_exponential_moving_average.py @@ -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", + } + ) diff --git a/tests/components/exponential_moving_average/common.h b/tests/components/exponential_moving_average/common.h new file mode 100644 index 0000000000..599c36bb0d --- /dev/null +++ b/tests/components/exponential_moving_average/common.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +#include +#include +#include +#include + +#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 saved_prefdir_; + sensor::Sensor source_; +}; + +} // namespace esphome::exponential_moving_average::testing diff --git a/tests/components/exponential_moving_average/common.yaml b/tests/components/exponential_moving_average/common.yaml new file mode 100644 index 0000000000..c3cf9da5f9 --- /dev/null +++ b/tests/components/exponential_moving_average/common.yaml @@ -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 diff --git a/tests/components/exponential_moving_average/sensor/test_exponential_moving_average.cpp b/tests/components/exponential_moving_average/sensor/test_exponential_moving_average.cpp new file mode 100644 index 0000000000..d601374990 --- /dev/null +++ b/tests/components/exponential_moving_average/sensor/test_exponential_moving_average.cpp @@ -0,0 +1,356 @@ +#include + +#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 {}; + +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 diff --git a/tests/components/exponential_moving_average/test.esp32-idf.yaml b/tests/components/exponential_moving_average/test.esp32-idf.yaml new file mode 100644 index 0000000000..2f4b6a0f57 --- /dev/null +++ b/tests/components/exponential_moving_average/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + exponential_moving_average: !include common.yaml