Compare commits

...

10 Commits

Author SHA1 Message Date
Jean-Marc Collin
8719349241 Don't store datetime of fake datapoint 2023-11-28 22:19:16 +00:00
Jean-Marc Collin
5540f6e8a9 Try auto window new algo 2023-11-28 06:38:47 +00:00
Jean-Marc Collin
18a72bd907 Change open_window_detection fake datapoint threshold 2023-11-28 06:20:14 +00:00
Jean-Marc Collin
6a44d0dc4a With EMA entity and slope calculation optimisations 2023-11-22 10:31:14 +00:00
Jean-Marc Collin
9e15aa48b9 Maia comments: change MAX_ALPHA to 0.5, add slope calculation at each cycle. 2023-11-21 18:56:26 +00:00
Jean-Marc Collin
ae568c8be2 Take Maia feedbacks on the algo. 2023-11-18 23:33:40 +00:00
Jean-Marc Collin
8fe4eb7ac0 15 sec between two slope calculation 2023-11-18 18:10:51 +00:00
Jean-Marc Collin
328f5f7cb0 Fix ema_temp unknown and remove slope smoothing 2023-11-18 18:08:33 +00:00
Jean-Marc Collin
41ae572875 Removes circular dependency error 2023-11-18 17:10:06 +00:00
Jean-Marc Collin
cf64109232 Add ema calculation class, calculate an emo temperature, use the ema_temperature in auto_window dectection 2023-11-18 16:32:24 +00:00
10 changed files with 324 additions and 44 deletions

View File

@@ -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,
@@ -862,6 +880,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"""Return the unit of measurement.""" """Return the unit of measurement."""
return self._unit return self._unit
@property
def ema_temperature(self) -> str:
"""Return the EMA temperature."""
return self._ema_temp
@property @property
def hvac_mode(self) -> HVACMode | None: def hvac_mode(self) -> HVACMode | None:
"""Return current operation.""" """Return current operation."""
@@ -1476,6 +1499,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,
@@ -1648,7 +1676,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
for under in self._underlyings: for under in self._underlyings:
await under.turn_off() await under.turn_off()
async def _async_manage_window_auto(self): async def _async_manage_window_auto(self, in_cycle=False):
"""The management of the window auto feature""" """The management of the window auto feature"""
async def dearm_window_auto(_): async def dearm_window_auto(_):
@@ -1678,9 +1706,17 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
if not self._window_auto_algo: if not self._window_auto_algo:
return return
slope = self._window_auto_algo.add_temp_measurement( if in_cycle:
temperature=self._cur_temp, datetime_measure=self._last_temperature_mesure slope = self._window_auto_algo.check_age_last_measurement(
) temperature=self._ema_temp,
datetime_now=datetime.now(get_tz(self._hass)),
)
else:
slope = self._window_auto_algo.add_temp_measurement(
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",
self, self,
@@ -2029,6 +2065,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._attr_preset_mode, self._attr_preset_mode,
) )
# check auto_window conditions
await self._async_manage_window_auto(in_cycle=True)
# Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it # Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it
for under in self._underlyings: for under in self._underlyings:
if not under.is_initialized: if not under.is_initialized:
@@ -2155,6 +2194,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

View 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)

View File

@@ -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,9 +13,13 @@ 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 = 0 # two temp mesure should be > 0 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
MAX_DURATION_MIN = 30 # a fake data point is added in the cycle if last measurement was older than 30 min
MIN_NB_POINT = 4 # do not calculate slope until we have enough point
class WindowOpenDetectionAlgorithm: class WindowOpenDetectionAlgorithm:
"""The class that implements the algorithm listed above""" """The class that implements the algorithm listed above"""
@@ -24,6 +29,7 @@ class WindowOpenDetectionAlgorithm:
_last_slope: float _last_slope: float
_last_datetime: datetime _last_datetime: datetime
_last_temperature: float _last_temperature: float
_nb_point: int
def __init__(self, alert_threshold, end_alert_threshold) -> None: def __init__(self, alert_threshold, end_alert_threshold) -> None:
"""Initalize a new algorithm with the both threshold""" """Initalize a new algorithm with the both threshold"""
@@ -31,9 +37,24 @@ class WindowOpenDetectionAlgorithm:
self._end_alert_threshold = end_alert_threshold self._end_alert_threshold = end_alert_threshold
self._last_slope = None self._last_slope = None
self._last_datetime = None self._last_datetime = None
self._nb_point = 0
def check_age_last_measurement(self, temperature, datetime_now) -> float:
""" " Check if last measurement is old and add
a fake measurement point if this is the case
"""
if self._last_datetime is None:
return self.add_temp_measurement(temperature, datetime_now)
delta_t_sec = float((datetime_now - self._last_datetime).total_seconds()) / 60.0
if delta_t_sec >= MAX_DURATION_MIN:
return self.add_temp_measurement(temperature, datetime_now, False)
else:
# do nothing
return self._last_slope
def add_temp_measurement( def add_temp_measurement(
self, temperature: float, datetime_measure: datetime self, temperature: float, datetime_measure: datetime, store_date: bool = True
) -> float: ) -> float:
"""Add a new temperature measurement """Add a new temperature measurement
returns the last slope returns the last slope
@@ -42,6 +63,7 @@ class WindowOpenDetectionAlgorithm:
_LOGGER.debug("First initialisation") _LOGGER.debug("First initialisation")
self._last_datetime = datetime_measure self._last_datetime = datetime_measure
self._last_temperature = temperature self._last_temperature = temperature
self._nb_point = self._nb_point + 1
return None return None
_LOGGER.debug( _LOGGER.debug(
@@ -72,21 +94,28 @@ 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 = round((0.2 * self._last_slope) + (0.8 * new_slope), 4)
# if we are in cycle check and so adding a fake datapoint, we don't store the event datetime
# so that, when we will receive a real temperature point we will not calculate a wrong slope
if store_date:
self._last_datetime = datetime_measure
self._last_datetime = datetime_measure
self._last_temperature = temperature self._last_temperature = temperature
self._nb_point = self._nb_point + 1
_LOGGER.debug( _LOGGER.debug(
"delta_t=%.3f delta_temp=%.3f new_slope=%.3f last_slope=%s slope=%.3f", "delta_t=%.3f delta_temp=%.3f new_slope=%.3f last_slope=%s slope=%.3f nb_point=%s",
delta_t, delta_t,
delta_temp, delta_temp,
new_slope, new_slope,
lspe, lspe,
self._last_slope, self._last_slope,
self._nb_point,
) )
return self._last_slope return self._last_slope
def is_window_open_detected(self) -> bool: def is_window_open_detected(self) -> bool:
@@ -94,22 +123,20 @@ class WindowOpenDetectionAlgorithm:
if self._alert_threshold is None: if self._alert_threshold is None:
return False return False
return ( if self._nb_point < MIN_NB_POINT or self._last_slope is None:
self._last_slope < -self._alert_threshold return False
if self._last_slope is not None
else False return self._last_slope < -self._alert_threshold
)
def is_window_close_detected(self) -> bool: def is_window_close_detected(self) -> bool:
"""True if the last calculated slope is above (cause negative) the _end_alert_threshold""" """True if the last calculated slope is above (cause negative) the _end_alert_threshold"""
if self._end_alert_threshold is None: if self._end_alert_threshold is None:
return False return False
return ( if self._nb_point < MIN_NB_POINT or self._last_slope is None:
self._last_slope >= self._end_alert_threshold return False
if self._last_slope is not None
else False return self._last_slope >= self._end_alert_threshold
)
@property @property
def last_slope(self) -> float: def last_slope(self) -> float:

View File

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

View File

@@ -51,6 +51,7 @@ async def async_setup_entry(
LastTemperatureSensor(hass, unique_id, name, entry.data), LastTemperatureSensor(hass, unique_id, name, entry.data),
LastExtTemperatureSensor(hass, unique_id, name, entry.data), LastExtTemperatureSensor(hass, unique_id, name, entry.data),
TemperatureSlopeSensor(hass, unique_id, name, entry.data), TemperatureSlopeSensor(hass, unique_id, name, entry.data),
EMATemperatureSensor(hass, unique_id, name, entry.data),
] ]
if entry.data.get(CONF_DEVICE_POWER): if entry.data.get(CONF_DEVICE_POWER):
entities.append(EnergySensor(hass, unique_id, name, entry.data)) entities.append(EnergySensor(hass, unique_id, name, entry.data))
@@ -505,17 +506,15 @@ class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Called when my climate have change""" """Called when my climate have change"""
_LOGGER.debug("%s - climate state change", self._attr_unique_id) _LOGGER.debug("%s - climate state change", self._attr_unique_id)
if math.isnan(self.my_climate.regulated_target_temp) or math.isinf( new_temp = self.my_climate.regulated_target_temp
self.my_climate.regulated_target_temp if new_temp is None:
): return
raise ValueError(
f"Sensor has illegal state {self.my_climate.regulated_target_temp}" if math.isnan(new_temp) or math.isinf(new_temp):
) raise ValueError(f"Sensor has illegal state {new_temp}")
old_state = self._attr_native_value old_state = self._attr_native_value
self._attr_native_value = round( self._attr_native_value = round(new_temp, self.suggested_display_precision)
self.my_climate.regulated_target_temp, self.suggested_display_precision
)
if old_state != self._attr_native_value: if old_state != self._attr_native_value:
self.async_write_ha_state() self.async_write_ha_state()
return return
@@ -542,3 +541,54 @@ class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
def suggested_display_precision(self) -> int | None: def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display.""" """Return the suggested number of decimal digits for display."""
return 1 return 1
class EMATemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a Exponential Moving Average temp"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the regulated temperature sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "EMA temperature"
self._attr_unique_id = f"{self._device_name}_ema_temperature"
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
new_ema = self.my_climate.ema_temperature
if new_ema is None:
return
if math.isnan(new_ema) or math.isinf(new_ema):
raise ValueError(f"Sensor has illegal state {new_ema}")
old_state = self._attr_native_value
self._attr_native_value = new_ema
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:thermometer-lines"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.TEMPERATURE
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self) -> str | None:
if not self.my_climate:
return UnitOfTemperature.CELSIUS
return self.my_climate.temperature_unit
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 2

View File

@@ -363,12 +363,12 @@ async def test_over_climate_regulation_limitations(
"custom_components.versatile_thermostat.commons.NowClass.get_now", "custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp, return_value=event_timestamp,
): ):
await send_temperature_change_event(entity, 16, event_timestamp) await send_temperature_change_event(entity, 15, event_timestamp)
await send_ext_temperature_change_event(entity, 12, event_timestamp) await send_ext_temperature_change_event(entity, 12, event_timestamp)
# the regulated should have been done # the regulated should have been done
assert entity.regulated_target_temp != old_regulated_temp assert entity.regulated_target_temp != old_regulated_temp
assert entity.regulated_target_temp >= entity.target_temperature assert entity.regulated_target_temp >= entity.target_temperature
assert ( assert (
entity.regulated_target_temp == 17 + 0.5 entity.regulated_target_temp == 17 + 1.5
) # 0.7 without round_to_nearest ) # 0.7 without round_to_nearest

54
tests/test_ema.py Normal file
View 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

View File

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

View File

@@ -2,7 +2,9 @@
""" Test the OpenWindow algorithm """ """ Test the OpenWindow algorithm """
from datetime import datetime, timedelta from datetime import datetime, timedelta
from custom_components.versatile_thermostat.open_window_algorithm import WindowOpenDetectionAlgorithm from custom_components.versatile_thermostat.open_window_algorithm import (
WindowOpenDetectionAlgorithm,
)
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
@@ -19,18 +21,28 @@ async def test_open_window_algo(
tz = get_tz(hass) # pylint: disable=invalid-name tz = get_tz(hass) # pylint: disable=invalid-name
now = datetime.now(tz) now = datetime.now(tz)
event_timestamp = now - timedelta(minutes=5) event_timestamp = now - timedelta(minutes=10)
last_slope = the_algo.add_temp_measurement( last_slope = the_algo.add_temp_measurement(
temperature=10, datetime_measure=event_timestamp temperature=10, datetime_measure=event_timestamp
) )
# We need at least 2 measurement # We need at least 4 measurement
assert last_slope is None assert last_slope is None
assert the_algo.last_slope is None assert the_algo.last_slope is None
assert the_algo.is_window_close_detected() is False assert the_algo.is_window_close_detected() is False
assert the_algo.is_window_open_detected() is False assert the_algo.is_window_open_detected() is False
event_timestamp = now - timedelta(minutes=4) event_timestamp = now - timedelta(minutes=9)
last_slope = the_algo.add_temp_measurement(
temperature=10, datetime_measure=event_timestamp
)
event_timestamp = now - timedelta(minutes=8)
last_slope = the_algo.add_temp_measurement(
temperature=10, datetime_measure=event_timestamp
)
event_timestamp = now - timedelta(minutes=7)
last_slope = the_algo.add_temp_measurement( last_slope = the_algo.add_temp_measurement(
temperature=10, datetime_measure=event_timestamp temperature=10, datetime_measure=event_timestamp
) )
@@ -41,19 +53,19 @@ async def test_open_window_algo(
assert the_algo.is_window_close_detected() is True assert the_algo.is_window_close_detected() is True
assert the_algo.is_window_open_detected() is False assert the_algo.is_window_open_detected() is False
event_timestamp = now - timedelta(minutes=3) event_timestamp = now - timedelta(minutes=6)
last_slope = the_algo.add_temp_measurement( last_slope = the_algo.add_temp_measurement(
temperature=9, datetime_measure=event_timestamp temperature=9, datetime_measure=event_timestamp
) )
# A slope is calculated # A slope is calculated
assert last_slope == -0.5 assert last_slope == -0.8
assert the_algo.last_slope == -0.5 assert the_algo.last_slope == -0.8
assert the_algo.is_window_close_detected() is False assert the_algo.is_window_close_detected() is False
assert the_algo.is_window_open_detected() is False assert the_algo.is_window_open_detected() is False
# A new temperature with 2 degre less in one minute (value will be rejected) # A new temperature with 2 degre less in one minute (value will be rejected)
event_timestamp = now - timedelta(minutes=2) event_timestamp = now - timedelta(minutes=5)
last_slope = the_algo.add_temp_measurement( last_slope = the_algo.add_temp_measurement(
temperature=7, datetime_measure=event_timestamp temperature=7, datetime_measure=event_timestamp
) )
@@ -65,7 +77,7 @@ async def test_open_window_algo(
assert the_algo.is_window_open_detected() is True assert the_algo.is_window_open_detected() is True
# A new temperature with 1 degre less # A new temperature with 1 degre less
event_timestamp = now - timedelta(minutes=1) event_timestamp = now - timedelta(minutes=4)
last_slope = the_algo.add_temp_measurement( last_slope = the_algo.add_temp_measurement(
temperature=6, datetime_measure=event_timestamp temperature=6, datetime_measure=event_timestamp
) )
@@ -77,7 +89,7 @@ async def test_open_window_algo(
assert the_algo.is_window_open_detected() is True assert the_algo.is_window_open_detected() is True
# A new temperature with 0 degre less # A new temperature with 0 degre less
event_timestamp = now - timedelta(minutes=0) event_timestamp = now - timedelta(minutes=3)
last_slope = the_algo.add_temp_measurement( last_slope = the_algo.add_temp_measurement(
temperature=6, datetime_measure=event_timestamp temperature=6, datetime_measure=event_timestamp
) )
@@ -89,7 +101,7 @@ async def test_open_window_algo(
assert the_algo.is_window_open_detected() is False assert the_algo.is_window_open_detected() is False
# A new temperature with 1 degre more # A new temperature with 1 degre more
event_timestamp = now + timedelta(minutes=1) event_timestamp = now + timedelta(minutes=2)
last_slope = the_algo.add_temp_measurement( last_slope = the_algo.add_temp_measurement(
temperature=7, datetime_measure=event_timestamp temperature=7, datetime_measure=event_timestamp
) )

View File

@@ -477,6 +477,14 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
assert entity.window_state is STATE_OFF assert entity.window_state is STATE_OFF
# Initialize the slope algo with 2 measurements
# event_timestamp = now - timedelta(minutes=9)
# await send_temperature_change_event(entity, 19, event_timestamp)
# event_timestamp = now - timedelta(minutes=8)
# await send_temperature_change_event(entity, 19, event_timestamp)
# event_timestamp = now - timedelta(minutes=7)
# await send_temperature_change_event(entity, 19, event_timestamp)
# Make the temperature down # Make the temperature down
with patch( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
@@ -486,6 +494,7 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.is_device_active", "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.is_device_active",
return_value=True, return_value=True,
): ):
# This is the 3rd measurment. Slope is not ready
event_timestamp = now - timedelta(minutes=4) event_timestamp = now - timedelta(minutes=4)
await send_temperature_change_event(entity, 19, event_timestamp) await send_temperature_change_event(entity, 19, event_timestamp)
@@ -531,7 +540,7 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st
assert entity.window_auto_state == STATE_ON assert entity.window_auto_state == STATE_ON
assert entity.hvac_mode is HVACMode.OFF assert entity.hvac_mode is HVACMode.OFF
# Waits for automatic disable # Waits for automatic disable
with patch( with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch( ) as mock_send_event, patch(