mirror of
https://github.com/esphome/esphome.git
synced 2026-10-07 11:26:39 +00:00
[exponential_moving_average] Add EMA preserved across reboots (#19882)
Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
co-authored by
J. Nick Koston
parent
6f2d2a2001
commit
d9b3f299e0
@@ -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()"),
|
||||
)
|
||||
+40
@@ -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
|
||||
+356
@@ -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
|
||||
Reference in New Issue
Block a user