Compare commits
6 Commits
main
...
4.2.0.alph
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e15aa48b9 | ||
|
|
ae568c8be2 | ||
|
|
8fe4eb7ac0 | ||
|
|
328f5f7cb0 | ||
|
|
41ae572875 | ||
|
|
cf64109232 |
@@ -113,10 +113,17 @@ from .underlyings import UnderlyingEntity
|
|||||||
|
|
||||||
from .prop_algorithm import PropAlgorithm
|
from .prop_algorithm import PropAlgorithm
|
||||||
from .open_window_algorithm import WindowOpenDetectionAlgorithm
|
from .open_window_algorithm import WindowOpenDetectionAlgorithm
|
||||||
|
from .ema import ExponentialMovingAverage
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tz(hass: HomeAssistant):
|
||||||
|
"""Get the current timezone"""
|
||||||
|
|
||||||
|
return dt_util.get_time_zone(hass.config.time_zone)
|
||||||
|
|
||||||
|
|
||||||
class BaseThermostat(ClimateEntity, RestoreEntity):
|
class BaseThermostat(ClimateEntity, RestoreEntity):
|
||||||
"""Representation of a base class for all Versatile Thermostat device."""
|
"""Representation of a base class for all Versatile Thermostat device."""
|
||||||
|
|
||||||
@@ -246,6 +253,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
|
|||||||
|
|
||||||
self._underlyings = []
|
self._underlyings = []
|
||||||
|
|
||||||
|
self._ema_temp = None
|
||||||
|
self._ema_algo = None
|
||||||
self.post_init(entry_infos)
|
self.post_init(entry_infos)
|
||||||
|
|
||||||
def post_init(self, entry_infos):
|
def post_init(self, entry_infos):
|
||||||
@@ -450,6 +459,15 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
|
|||||||
|
|
||||||
self._total_energy = 0
|
self._total_energy = 0
|
||||||
|
|
||||||
|
self._ema_algo = ExponentialMovingAverage(
|
||||||
|
self.name,
|
||||||
|
self._cycle_min * 60,
|
||||||
|
# Needed for time calculation
|
||||||
|
get_tz(self._hass),
|
||||||
|
# two digits after the coma for temperature slope calculation
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s - Creation of a new VersatileThermostat entity: unique_id=%s",
|
"%s - Creation of a new VersatileThermostat entity: unique_id=%s",
|
||||||
self,
|
self,
|
||||||
@@ -1476,6 +1494,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
|
|||||||
|
|
||||||
self._last_temperature_mesure = self.get_state_date_or_now(state)
|
self._last_temperature_mesure = self.get_state_date_or_now(state)
|
||||||
|
|
||||||
|
# calculate the smooth_temperature with EMA calculation
|
||||||
|
self._ema_temp = self._ema_algo.calculate_ema(
|
||||||
|
self._cur_temp, self._last_temperature_mesure
|
||||||
|
)
|
||||||
|
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s - After setting _last_temperature_mesure %s , state.last_changed.replace=%s",
|
"%s - After setting _last_temperature_mesure %s , state.last_changed.replace=%s",
|
||||||
self,
|
self,
|
||||||
@@ -1679,7 +1702,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
|
|||||||
return
|
return
|
||||||
|
|
||||||
slope = self._window_auto_algo.add_temp_measurement(
|
slope = self._window_auto_algo.add_temp_measurement(
|
||||||
temperature=self._cur_temp, datetime_measure=self._last_temperature_mesure
|
temperature=self._ema_temp, datetime_measure=self._last_temperature_mesure
|
||||||
)
|
)
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s - Window auto is on, check the alert. last slope is %.3f",
|
"%s - Window auto is on, check the alert. last slope is %.3f",
|
||||||
@@ -2072,6 +2095,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
|
|||||||
force,
|
force,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# calculate the smooth_temperature with EMA calculation
|
||||||
|
await self._async_manage_window_auto()
|
||||||
|
|
||||||
self.update_custom_attributes()
|
self.update_custom_attributes()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -2155,6 +2181,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
|
|||||||
"max_power_sensor_entity_id": self._max_power_sensor_entity_id,
|
"max_power_sensor_entity_id": self._max_power_sensor_entity_id,
|
||||||
"temperature_unit": self.temperature_unit,
|
"temperature_unit": self.temperature_unit,
|
||||||
"is_device_active": self.is_device_active,
|
"is_device_active": self.is_device_active,
|
||||||
|
"ema_temp": self._ema_temp,
|
||||||
}
|
}
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
|||||||
85
custom_components/versatile_thermostat/ema.py
Normal file
85
custom_components/versatile_thermostat/ema.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# pylint: disable=line-too-long
|
||||||
|
"""The Estimated Mobile Average calculation used for temperature slope
|
||||||
|
and maybe some others feature"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
from datetime import datetime, tzinfo
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MIN_TIME_DECAY_SEC = 0
|
||||||
|
# As for the EMA calculation of irregular time series, I've seen that it might be useful to
|
||||||
|
# have an upper limit for alpha in case the last measurement was too long ago.
|
||||||
|
# For example when using a half life of 10 minutes a measurement that is 60 minutes ago
|
||||||
|
# (if there's nothing inbetween) would contribute to the smoothed value with 1,5%,
|
||||||
|
# giving the current measurement 98,5% relevance. It could be wise to limit the alpha to e.g. 4x the half life (=0.9375).
|
||||||
|
MAX_ALPHA = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class ExponentialMovingAverage:
|
||||||
|
"""A class that will do the Estimated Mobile Average calculation"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, vterm_name: str, halflife: float, timezone: tzinfo, precision: int = 3
|
||||||
|
):
|
||||||
|
"""The halflife is the duration in secondes of a normal cycle"""
|
||||||
|
self._halflife: float = halflife
|
||||||
|
self._timezone = timezone
|
||||||
|
self._current_ema: float = None
|
||||||
|
self._last_timestamp: datetime = datetime.now(self._timezone)
|
||||||
|
self._name = vterm_name
|
||||||
|
self._precision = precision
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"EMA-{self._name}"
|
||||||
|
|
||||||
|
def calculate_ema(self, measurement: float, timestamp: datetime) -> float | None:
|
||||||
|
"""Calculate the new EMA from a new measurement measured at timestamp
|
||||||
|
Return the EMA or None if all parameters are not initialized now
|
||||||
|
"""
|
||||||
|
|
||||||
|
if measurement is None or timestamp is None:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s - Cannot calculate EMA: measurement and timestamp are mandatory. This message can be normal at startup but should not persist",
|
||||||
|
self,
|
||||||
|
)
|
||||||
|
return measurement
|
||||||
|
|
||||||
|
if self._current_ema is None:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - First init of the EMA",
|
||||||
|
self,
|
||||||
|
)
|
||||||
|
self._current_ema = measurement
|
||||||
|
self._last_timestamp = timestamp
|
||||||
|
return self._current_ema
|
||||||
|
|
||||||
|
time_decay = (timestamp - self._last_timestamp).total_seconds()
|
||||||
|
if time_decay < MIN_TIME_DECAY_SEC:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - time_decay %s is too small (< %s). Forget the measurement",
|
||||||
|
self,
|
||||||
|
time_decay,
|
||||||
|
MIN_TIME_DECAY_SEC,
|
||||||
|
)
|
||||||
|
return self._current_ema
|
||||||
|
|
||||||
|
alpha = 1 - math.exp(math.log(0.5) * time_decay / self._halflife)
|
||||||
|
# capping alpha to avoid gap if last measurement was long time ago
|
||||||
|
alpha = min(alpha, MAX_ALPHA)
|
||||||
|
new_ema = alpha * measurement + (1 - alpha) * self._current_ema
|
||||||
|
|
||||||
|
self._last_timestamp = timestamp
|
||||||
|
self._current_ema = new_ema
|
||||||
|
_LOGGER.debug(
|
||||||
|
"%s - timestamp=%s alpha=%.2f measurement=%.2f current_ema=%.2f new_ema=%.2f",
|
||||||
|
self,
|
||||||
|
timestamp,
|
||||||
|
alpha,
|
||||||
|
measurement,
|
||||||
|
self._current_ema,
|
||||||
|
new_ema,
|
||||||
|
)
|
||||||
|
|
||||||
|
return round(self._current_ema, self._precision)
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
# pylint: disable=line-too-long
|
||||||
""" This file implements the Open Window by temperature algorithm
|
""" This file implements the Open Window by temperature algorithm
|
||||||
This algo works the following way:
|
This algo works the following way:
|
||||||
- each time a new temperature is measured
|
- each time a new temperature is measured
|
||||||
@@ -12,7 +13,7 @@ from datetime import datetime
|
|||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
# To filter bad values
|
# To filter bad values
|
||||||
MIN_DELTA_T_SEC = 30 # two temp mesure should be > 10 sec
|
MIN_DELTA_T_SEC = 15 # two temp mesure should be > 10 sec
|
||||||
MAX_SLOPE_VALUE = 2 # slope cannot be > 2 or < -2 -> else this is an aberrant point
|
MAX_SLOPE_VALUE = 2 # slope cannot be > 2 or < -2 -> else this is an aberrant point
|
||||||
|
|
||||||
|
|
||||||
@@ -71,10 +72,10 @@ class WindowOpenDetectionAlgorithm:
|
|||||||
)
|
)
|
||||||
return lspe
|
return lspe
|
||||||
|
|
||||||
if self._last_slope is None:
|
# if self._last_slope is None:
|
||||||
self._last_slope = new_slope
|
self._last_slope = round(new_slope, 4)
|
||||||
else:
|
# else:
|
||||||
self._last_slope = (0.5 * self._last_slope) + (0.5 * new_slope)
|
# self._last_slope = (0.5 * self._last_slope) + (0.5 * new_slope)
|
||||||
|
|
||||||
self._last_datetime = datetime_measure
|
self._last_datetime = datetime_measure
|
||||||
self._last_temperature = temperature
|
self._last_temperature = temperature
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ class PITemperatureRegulator:
|
|||||||
self.target_temp = target_temp
|
self.target_temp = target_temp
|
||||||
# Do not reset the accumulated error
|
# Do not reset the accumulated error
|
||||||
# Discussion #191. After a target change we should reset the accumulated error which is certainly wrong now.
|
# Discussion #191. After a target change we should reset the accumulated error which is certainly wrong now.
|
||||||
self.accumulated_error = 0
|
if self.accumulated_error < 0:
|
||||||
|
self.accumulated_error = 0
|
||||||
|
|
||||||
def calculate_regulated_temperature(
|
def calculate_regulated_temperature(
|
||||||
self, internal_temp: float, external_temp: float
|
self, internal_temp: float, external_temp: float
|
||||||
|
|||||||
54
tests/test_ema.py
Normal file
54
tests/test_ema.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# pylint: disable=line-too-long
|
||||||
|
""" Tests de EMA calculation"""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from custom_components.versatile_thermostat.ema import ExponentialMovingAverage
|
||||||
|
|
||||||
|
from .commons import get_tz
|
||||||
|
|
||||||
|
|
||||||
|
def test_ema_basics(hass: HomeAssistant):
|
||||||
|
"""Test the EMA calculation with basic features"""
|
||||||
|
|
||||||
|
tz = get_tz(hass) # pylint: disable=invalid-name
|
||||||
|
now: datetime = datetime.now(tz=tz)
|
||||||
|
|
||||||
|
the_ema = ExponentialMovingAverage(
|
||||||
|
"test",
|
||||||
|
# 5 minutes
|
||||||
|
300,
|
||||||
|
# Needed for time calculation
|
||||||
|
get_tz(hass),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert the_ema
|
||||||
|
|
||||||
|
current_timestamp = now
|
||||||
|
# First initialization
|
||||||
|
assert the_ema.calculate_ema(20, current_timestamp) == 20
|
||||||
|
|
||||||
|
current_timestamp = current_timestamp + timedelta(minutes=1)
|
||||||
|
# One minute later, same temperature. EMA temperature should not have change
|
||||||
|
assert the_ema.calculate_ema(20, current_timestamp) == 20
|
||||||
|
|
||||||
|
# Too short measurement should be ignored
|
||||||
|
assert the_ema.calculate_ema(2000, current_timestamp) == 20
|
||||||
|
|
||||||
|
current_timestamp = current_timestamp + timedelta(seconds=4)
|
||||||
|
assert the_ema.calculate_ema(20, current_timestamp) == 20
|
||||||
|
|
||||||
|
# a new normal measurement 5 minutes later
|
||||||
|
current_timestamp = current_timestamp + timedelta(minutes=5)
|
||||||
|
ema = the_ema.calculate_ema(25, current_timestamp)
|
||||||
|
assert ema > 20
|
||||||
|
assert ema == 22.5
|
||||||
|
|
||||||
|
# a big change in a short time does have a limited effect
|
||||||
|
current_timestamp = current_timestamp + timedelta(seconds=5)
|
||||||
|
ema = the_ema.calculate_ema(30, current_timestamp)
|
||||||
|
assert ema > 22.5
|
||||||
|
assert ema < 23
|
||||||
|
assert ema == 22.6
|
||||||
@@ -386,6 +386,7 @@ async def test_multiple_climates(
|
|||||||
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
|
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
|
||||||
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
|
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
|
||||||
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
|
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
|
||||||
|
CONF_CYCLE_MIN: 8,
|
||||||
CONF_TEMP_MIN: 15,
|
CONF_TEMP_MIN: 15,
|
||||||
CONF_TEMP_MAX: 30,
|
CONF_TEMP_MAX: 30,
|
||||||
"eco_temp": 17,
|
"eco_temp": 17,
|
||||||
@@ -486,6 +487,7 @@ async def test_multiple_climates_underlying_changes(
|
|||||||
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
|
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
|
||||||
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
|
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
|
||||||
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
|
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
|
||||||
|
CONF_CYCLE_MIN: 8,
|
||||||
CONF_TEMP_MIN: 15,
|
CONF_TEMP_MIN: 15,
|
||||||
CONF_TEMP_MAX: 30,
|
CONF_TEMP_MAX: 30,
|
||||||
"eco_temp": 17,
|
"eco_temp": 17,
|
||||||
|
|||||||
Reference in New Issue
Block a user