From 23f9c7c52fcee2b5c83d560a0d4ad1de57be0580 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Fri, 1 Dec 2023 21:02:53 +0100 Subject: [PATCH] Feature 181 & 242 - improve auto window detection (#243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add ema calculation class, calculate an emo temperature, use the ema_temperature in auto_window dectection * Removes circular dependency error * Fix ema_temp unknown and remove slope smoothing * 15 sec between two slope calculation * Take Maia feedbacks on the algo. * Maia comments: change MAX_ALPHA to 0.5, add slope calculation at each cycle. * With EMA entity and slope calculation optimisations * Change open_window_detection fake datapoint threshold * Try auto window new algo * Don't store datetime of fake datapoint * Change auto window threshold in °/hour --------- Co-authored-by: Jean-Marc Collin --- .../versatile_thermostat/base_thermostat.py | 48 ++++- custom_components/versatile_thermostat/ema.py | 85 ++++++++ .../open_window_algorithm.py | 67 ++++-- .../versatile_thermostat/pi_algorithm.py | 3 +- .../versatile_thermostat/sensor.py | 70 ++++++- .../versatile_thermostat/strings.json | 12 +- .../versatile_thermostat/translations/en.json | 12 +- .../versatile_thermostat/translations/fr.json | 12 +- .../versatile_thermostat/translations/it.json | 16 +- .../versatile_thermostat/translations/sk.json | 12 +- tests/test_auto_regulation.py | 4 +- tests/test_ema.py | 54 +++++ tests/test_multiple_switch.py | 2 + tests/test_open_window_algo.py | 198 +++++++++++++----- tests/test_window.py | 135 +++++++----- 15 files changed, 569 insertions(+), 161 deletions(-) create mode 100644 custom_components/versatile_thermostat/ema.py create mode 100644 tests/test_ema.py diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 6bf5781..d957fcd 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -113,10 +113,17 @@ from .underlyings import UnderlyingEntity from .prop_algorithm import PropAlgorithm from .open_window_algorithm import WindowOpenDetectionAlgorithm +from .ema import ExponentialMovingAverage _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): """Representation of a base class for all Versatile Thermostat device.""" @@ -246,6 +253,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._underlyings = [] + self._ema_temp = None + self._ema_algo = None self.post_init(entry_infos) def post_init(self, entry_infos): @@ -450,6 +459,15 @@ class BaseThermostat(ClimateEntity, RestoreEntity): 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( "%s - Creation of a new VersatileThermostat entity: unique_id=%s", self, @@ -862,6 +880,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity): """Return the unit of measurement.""" return self._unit + @property + def ema_temperature(self) -> str: + """Return the EMA temperature.""" + return self._ema_temp + @property def hvac_mode(self) -> HVACMode | None: """Return current operation.""" @@ -1476,6 +1499,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity): 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( "%s - After setting _last_temperature_mesure %s , state.last_changed.replace=%s", self, @@ -1648,7 +1676,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity): for under in self._underlyings: 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""" async def dearm_window_auto(_): @@ -1678,9 +1706,17 @@ class BaseThermostat(ClimateEntity, RestoreEntity): if not self._window_auto_algo: return - slope = self._window_auto_algo.add_temp_measurement( - temperature=self._cur_temp, datetime_measure=self._last_temperature_mesure - ) + if in_cycle: + 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( "%s - Window auto is on, check the alert. last slope is %.3f", self, @@ -2029,6 +2065,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity): 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 for under in self._underlyings: if not under.is_initialized: @@ -2155,6 +2194,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity): "max_power_sensor_entity_id": self._max_power_sensor_entity_id, "temperature_unit": self.temperature_unit, "is_device_active": self.is_device_active, + "ema_temp": self._ema_temp, } @callback diff --git a/custom_components/versatile_thermostat/ema.py b/custom_components/versatile_thermostat/ema.py new file mode 100644 index 0000000..eb5bca4 --- /dev/null +++ b/custom_components/versatile_thermostat/ema.py @@ -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) diff --git a/custom_components/versatile_thermostat/open_window_algorithm.py b/custom_components/versatile_thermostat/open_window_algorithm.py index 1e61f20..2e83aa6 100644 --- a/custom_components/versatile_thermostat/open_window_algorithm.py +++ b/custom_components/versatile_thermostat/open_window_algorithm.py @@ -1,3 +1,4 @@ +# pylint: disable=line-too-long """ This file implements the Open Window by temperature algorithm This algo works the following way: - each time a new temperature is measured @@ -12,8 +13,14 @@ from datetime import datetime _LOGGER = logging.getLogger(__name__) # To filter bad values -MIN_DELTA_T_SEC = 30 # two temp mesure should be > 10 sec -MAX_SLOPE_VALUE = 2 # slope cannot be > 2 or < -2 -> else this is an aberrant point +MIN_DELTA_T_SEC = 0 # two temp mesure should be > 0 sec +MAX_SLOPE_VALUE = ( + 120 # slope cannot be > 2°/min or < -2°/min -> 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: @@ -24,6 +31,7 @@ class WindowOpenDetectionAlgorithm: _last_slope: float _last_datetime: datetime _last_temperature: float + _nb_point: int def __init__(self, alert_threshold, end_alert_threshold) -> None: """Initalize a new algorithm with the both threshold""" @@ -31,9 +39,24 @@ class WindowOpenDetectionAlgorithm: self._end_alert_threshold = end_alert_threshold self._last_slope = 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( - self, temperature: float, datetime_measure: datetime + self, temperature: float, datetime_measure: datetime, store_date: bool = True ) -> float: """Add a new temperature measurement returns the last slope @@ -42,6 +65,7 @@ class WindowOpenDetectionAlgorithm: _LOGGER.debug("First initialisation") self._last_datetime = datetime_measure self._last_temperature = temperature + self._nb_point = self._nb_point + 1 return None _LOGGER.debug( @@ -61,8 +85,10 @@ class WindowOpenDetectionAlgorithm: ) return lspe + delta_t_hour = delta_t / 60.0 + delta_temp = float(temperature - self._last_temperature) - new_slope = delta_temp / delta_t + new_slope = delta_temp / delta_t_hour if new_slope > MAX_SLOPE_VALUE or new_slope < -MAX_SLOPE_VALUE: _LOGGER.debug( "New_slope is abs(%.2f) > %.2f which should be not possible. We don't consider this value", @@ -72,21 +98,28 @@ class WindowOpenDetectionAlgorithm: return lspe if self._last_slope is None: - self._last_slope = new_slope + self._last_slope = round(new_slope, 2) 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), 2) + + # 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._nb_point = self._nb_point + 1 _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_temp, new_slope, lspe, self._last_slope, + self._nb_point, ) + return self._last_slope def is_window_open_detected(self) -> bool: @@ -94,22 +127,20 @@ class WindowOpenDetectionAlgorithm: if self._alert_threshold is None: return False - return ( - self._last_slope < -self._alert_threshold - if self._last_slope is not None - else False - ) + if self._nb_point < MIN_NB_POINT or self._last_slope is None: + return False + + return self._last_slope < -self._alert_threshold def is_window_close_detected(self) -> bool: """True if the last calculated slope is above (cause negative) the _end_alert_threshold""" if self._end_alert_threshold is None: return False - return ( - self._last_slope >= self._end_alert_threshold - if self._last_slope is not None - else False - ) + if self._nb_point < MIN_NB_POINT or self._last_slope is None: + return False + + return self._last_slope >= self._end_alert_threshold @property def last_slope(self) -> float: diff --git a/custom_components/versatile_thermostat/pi_algorithm.py b/custom_components/versatile_thermostat/pi_algorithm.py index e36967e..a5f79fe 100644 --- a/custom_components/versatile_thermostat/pi_algorithm.py +++ b/custom_components/versatile_thermostat/pi_algorithm.py @@ -49,7 +49,8 @@ class PITemperatureRegulator: self.target_temp = target_temp # Do not reset the accumulated error # 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( self, internal_temp: float, external_temp: float diff --git a/custom_components/versatile_thermostat/sensor.py b/custom_components/versatile_thermostat/sensor.py index f02ebdf..f11d734 100644 --- a/custom_components/versatile_thermostat/sensor.py +++ b/custom_components/versatile_thermostat/sensor.py @@ -51,6 +51,7 @@ async def async_setup_entry( LastTemperatureSensor(hass, unique_id, name, entry.data), LastExtTemperatureSensor(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): entities.append(EnergySensor(hass, unique_id, name, entry.data)) @@ -483,7 +484,7 @@ class TemperatureSlopeSensor(VersatileThermostatBaseEntity, SensorEntity): if not self.my_climate: return None - return self.my_climate.temperature_unit + "/min" + return self.my_climate.temperature_unit + "/hour" @property def suggested_display_precision(self) -> int | None: @@ -505,17 +506,15 @@ class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): """Called when my climate have change""" _LOGGER.debug("%s - climate state change", self._attr_unique_id) - if math.isnan(self.my_climate.regulated_target_temp) or math.isinf( - self.my_climate.regulated_target_temp - ): - raise ValueError( - f"Sensor has illegal state {self.my_climate.regulated_target_temp}" - ) + new_temp = self.my_climate.regulated_target_temp + if new_temp is None: + return + + 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 - self._attr_native_value = round( - self.my_climate.regulated_target_temp, self.suggested_display_precision - ) + self._attr_native_value = round(new_temp, self.suggested_display_precision) if old_state != self._attr_native_value: self.async_write_ha_state() return @@ -542,3 +541,54 @@ class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity): def suggested_display_precision(self) -> int | None: """Return the suggested number of decimal digits for display.""" 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 diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index d41eb0b..2e0391f 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -91,14 +91,14 @@ "data": { "window_sensor_entity_id": "Window sensor entity id", "window_delay": "Window sensor delay (seconds)", - "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)", - "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)", + "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)", + "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)", "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)" }, "data_description": { "window_sensor_entity_id": "Leave empty if no window sensor should be use", "window_delay": "The delay in seconds before sensor detection is taken into account", - "window_auto_open_threshold": "Recommended value: between 0.05 and 0.1. Leave empty if automatic window open detection is not use", + "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not use", "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use", "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use" } @@ -260,14 +260,14 @@ "data": { "window_sensor_entity_id": "Window sensor entity id", "window_delay": "Window sensor delay (seconds)", - "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)", - "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)", + "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)", + "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)", "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)" }, "data_description": { "window_sensor_entity_id": "Leave empty if no window sensor should be use", "window_delay": "The delay in seconds before sensor detection is taken into account", - "window_auto_open_threshold": "Recommended value: between 0.05 and 0.1. Leave empty if automatic window open detection is not use", + "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not use", "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not use", "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not use" } diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 4b5fa0b..633260a 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -91,14 +91,14 @@ "data": { "window_sensor_entity_id": "Window sensor entity id", "window_delay": "Window sensor delay (seconds)", - "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)", - "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)", + "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)", + "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)", "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)" }, "data_description": { "window_sensor_entity_id": "Leave empty if no window sensor should be used", "window_delay": "The delay in seconds before sensor detection is taken into account", - "window_auto_open_threshold": "Recommended value: between 0.05 and 0.1. Leave empty if automatic window open detection is not used", + "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not used", "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not used", "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not used" } @@ -260,14 +260,14 @@ "data": { "window_sensor_entity_id": "Window sensor entity id", "window_delay": "Window sensor delay (seconds)", - "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/min)", - "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/min)", + "window_auto_open_threshold": "Temperature decrease threshold for automatic window open detection (in °/hours)", + "window_auto_close_threshold": "Temperature increase threshold for end of automatic detection (in °/hours)", "window_auto_max_duration": "Maximum duration of automatic window open detection (in min)" }, "data_description": { "window_sensor_entity_id": "Leave empty if no window sensor should be used", "window_delay": "The delay in seconds before sensor detection is taken into account", - "window_auto_open_threshold": "Recommended value: between 0.05 and 0.1. Leave empty if automatic window open detection is not used", + "window_auto_open_threshold": "Recommended value: between 3 and 10. Leave empty if automatic window open detection is not used", "window_auto_close_threshold": "Recommended value: 0. Leave empty if automatic window open detection is not used", "window_auto_max_duration": "Recommended value: 60 (one hour). Leave empty if automatic window open detection is not used" } diff --git a/custom_components/versatile_thermostat/translations/fr.json b/custom_components/versatile_thermostat/translations/fr.json index e7a4477..777f3f3 100644 --- a/custom_components/versatile_thermostat/translations/fr.json +++ b/custom_components/versatile_thermostat/translations/fr.json @@ -91,14 +91,14 @@ "data": { "window_sensor_entity_id": "Détecteur d'ouverture (entity id)", "window_delay": "Délai avant extinction (secondes)", - "window_auto_open_threshold": "Seuil haut de chute de température pour la détection automatique (en °/min)", - "window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/min)", + "window_auto_open_threshold": "Seuil haut de chute de température pour la détection automatique (en °/heure)", + "window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/heure)", "window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)" }, "data_description": { "window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur", "window_delay": "Le délai (en secondes) avant que le changement du détecteur soit pris en compte", - "window_auto_open_threshold": "Valeur recommandée: entre 0.05 et 0.1. Laissez vide si vous n'utilisez pas la détection automatique", + "window_auto_open_threshold": "Valeur recommandée: entre 3 et 10. Laissez vide si vous n'utilisez pas la détection automatique", "window_auto_close_threshold": "Valeur recommandée: 0. Laissez vide si vous n'utilisez pas la détection automatique", "window_auto_max_duration": "Valeur recommandée: 60 (1 heure). Laissez vide si vous n'utilisez pas la détection automatique" } @@ -261,14 +261,14 @@ "data": { "window_sensor_entity_id": "Détecteur d'ouverture (entity id)", "window_delay": "Délai avant extinction (secondes)", - "window_auto_open_threshold": "seuil haut de chute de température pour la détection automatique (en °/min)", - "window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/min)", + "window_auto_open_threshold": "Seuil haut de chute de température pour la détection automatique (en °/heure)", + "window_auto_close_threshold": "Seuil bas de chute de température pour la fin de détection automatique (en °/heure)", "window_auto_max_duration": "Durée maximum d'une extinction automatique (en min)" }, "data_description": { "window_sensor_entity_id": "Laissez vide si vous n'avez de détecteur", "window_delay": "Le délai (en secondes) avant que le changement du détecteur soit pris en compte", - "window_auto_open_threshold": "Valeur recommandée: entre 0.05 et 0.1. Laissez vide si vous n'utilisez pas la détection automatique", + "window_auto_open_threshold": "Valeur recommandée: entre 3 et 10. Laissez vide si vous n'utilisez pas la détection automatique", "window_auto_close_threshold": "Valeur recommandée: 0. Laissez vide si vous n'utilisez pas la détection automatique", "window_auto_max_duration": "Valeur recommandée: 60 (1 heure). Laissez vide si vous n'utilisez pas la détection automatique" } diff --git a/custom_components/versatile_thermostat/translations/it.json b/custom_components/versatile_thermostat/translations/it.json index acbd7c8..43c9a66 100644 --- a/custom_components/versatile_thermostat/translations/it.json +++ b/custom_components/versatile_thermostat/translations/it.json @@ -87,14 +87,14 @@ "data": { "window_sensor_entity_id": "Entity id sensore finestra", "window_delay": "Ritardo sensore finestra (secondi)", - "window_auto_open_threshold": "Soglia di diminuzione della temperatura per il rilevamento automatico della finestra aperta (in °/min)", - "window_auto_close_threshold": "Soglia di aumento della temperatura per la fine del rilevamento automatico (in °/min)", + "window_auto_open_threshold": "Soglia di diminuzione della temperatura per il rilevamento automatico della finestra aperta (in °/ora)", + "window_auto_close_threshold": "Soglia di aumento della temperatura per la fine del rilevamento automatico (in °/ora)", "window_auto_max_duration": "Durata massima del rilevamento automatico della finestra aperta (in min)" }, "data_description": { "window_sensor_entity_id": "Lasciare vuoto se non deve essere utilizzato alcun sensore finestra", "window_delay": "Ritardo in secondi prima che il rilevamento del sensore sia preso in considerazione", - "window_auto_open_threshold": "Valore consigliato: tra 0.05 e 0.1. Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato", + "window_auto_open_threshold": "Valore consigliato: tra 3 e 10. Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato", "window_auto_close_threshold": "Valore consigliato: 0. Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato", "window_auto_max_duration": "Valore consigliato: 60 (un'ora). Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato" } @@ -245,16 +245,16 @@ "data": { "window_sensor_entity_id": "Entity id sensore finestra", "window_delay": "Ritardo sensore finestra (secondi)", - "window_auto_open_threshold": "Soglia di diminuzione della temperatura per il rilevamento automatico della finestra aperta (in °/min)", - "window_auto_close_threshold": "Soglia di aumento della temperatura per la fine del rilevamento automatico (in °/min)", + "window_auto_open_threshold": "Soglia di diminuzione della temperatura per il rilevamento automatico della finestra aperta (in °/ora)", + "window_auto_close_threshold": "Soglia di aumento della temperatura per la fine del rilevamento automatico (in °/ora)", "window_auto_max_duration": "Durata massima del rilevamento automatico della finestra aperta (in min)" }, "data_description": { "window_sensor_entity_id": "Lasciare vuoto se non deve essere utilizzato alcun sensore finestra", "window_delay": "Ritardo in secondi prima che il rilevamento del sensore sia preso in considerazione", - "window_auto_open_threshold": "Valore consigliato: tra 0.05 e 0.1 - Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato", - "window_auto_close_threshold": "Valore consigliato: 0 - Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato", - "window_auto_max_duration": "Valore consigliato: 60 minuti. Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato" + "window_auto_open_threshold": "Valore consigliato: tra 3 e 10. Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato", + "window_auto_close_threshold": "Valore consigliato: 0. Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato", + "window_auto_max_duration": "Valore consigliato: 60 (un'ora). Lasciare vuoto se il rilevamento automatico della finestra aperta non è utilizzato" } }, "motion": { diff --git a/custom_components/versatile_thermostat/translations/sk.json b/custom_components/versatile_thermostat/translations/sk.json index 818d34c..3dc7415 100644 --- a/custom_components/versatile_thermostat/translations/sk.json +++ b/custom_components/versatile_thermostat/translations/sk.json @@ -91,14 +91,14 @@ "data": { "window_sensor_entity_id": "ID entity snímača okna", "window_delay": "Oneskorenie snímača okna (sekundy)", - "window_auto_open_threshold": "Prah poklesu teploty pre automatickú detekciu otvoreného okna (v °/min)", - "window_auto_close_threshold": "Prahová hodnota zvýšenia teploty pre koniec automatickej detekcie (v °/min)", + "window_auto_open_threshold": "Prah poklesu teploty pre automatickú detekciu otvoreného okna (v °/hodina)", + "window_auto_close_threshold": "Prahová hodnota zvýšenia teploty pre koniec automatickej detekcie (v °/hodina)", "window_auto_max_duration": "Maximálne trvanie automatickej detekcie otvoreného okna (v min)" }, "data_description": { "window_sensor_entity_id": "Nechajte prázdne, ak nemáte použiť žiadny okenný senzor", "window_delay": "Zohľadňuje sa oneskorenie v sekundách pred detekciou snímača", - "window_auto_open_threshold": "Odporúčaná hodnota: medzi 0,05 a 0,1. Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", + "window_auto_open_threshold": "Odporúčaná hodnota: medzi 3 a 10. Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", "window_auto_close_threshold": "Odporúčaná hodnota: 0. Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", "window_auto_max_duration": "Odporúčaná hodnota: 60 (jedna hodina). Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne" } @@ -260,14 +260,14 @@ "data": { "window_sensor_entity_id": "ID entity snímača okna", "window_delay": "Oneskorenie snímača okna (sekundy)", - "window_auto_open_threshold": "Prah poklesu teploty pre automatickú detekciu otvoreného okna (v °/min)", - "window_auto_close_threshold": "Prahová hodnota zvýšenia teploty pre koniec automatickej detekcie (v °/min)", + "window_auto_open_threshold": "Prah poklesu teploty pre automatickú detekciu otvoreného okna (v °/hodina)", + "window_auto_close_threshold": "Prahová hodnota zvýšenia teploty pre koniec automatickej detekcie (v °/hodina)", "window_auto_max_duration": "Maximálne trvanie automatickej detekcie otvoreného okna (v min)" }, "data_description": { "window_sensor_entity_id": "Nechajte prázdne, ak nemáte použiť žiadny okenný senzor", "window_delay": "Zohľadňuje sa oneskorenie v sekundách pred detekciou snímača", - "window_auto_open_threshold": "Odporúčaná hodnota: medzi 0,05 a 0,1. Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", + "window_auto_open_threshold": "Odporúčaná hodnota: medzi 3 a 10. Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", "window_auto_close_threshold": "Odporúčaná hodnota: 0. Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne", "window_auto_max_duration": "Odporúčaná hodnota: 60 (jedna hodina). Ak sa nepoužíva automatická detekcia otvoreného okna, nechajte prázdne" } diff --git a/tests/test_auto_regulation.py b/tests/test_auto_regulation.py index 327c7f2..cce901a 100644 --- a/tests/test_auto_regulation.py +++ b/tests/test_auto_regulation.py @@ -363,12 +363,12 @@ async def test_over_climate_regulation_limitations( "custom_components.versatile_thermostat.commons.NowClass.get_now", 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) # the regulated should have been done assert entity.regulated_target_temp != old_regulated_temp assert entity.regulated_target_temp >= entity.target_temperature assert ( - entity.regulated_target_temp == 17 + 0.5 + entity.regulated_target_temp == 17 + 1.5 ) # 0.7 without round_to_nearest diff --git a/tests/test_ema.py b/tests/test_ema.py new file mode 100644 index 0000000..81d0fa6 --- /dev/null +++ b/tests/test_ema.py @@ -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 diff --git a/tests/test_multiple_switch.py b/tests/test_multiple_switch.py index 97f0f67..b9ff945 100644 --- a/tests/test_multiple_switch.py +++ b/tests/test_multiple_switch.py @@ -386,6 +386,7 @@ async def test_multiple_climates( CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 8, CONF_TEMP_MIN: 15, CONF_TEMP_MAX: 30, "eco_temp": 17, @@ -486,6 +487,7 @@ async def test_multiple_climates_underlying_changes( CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 8, CONF_TEMP_MIN: 15, CONF_TEMP_MAX: 30, "eco_temp": 17, diff --git a/tests/test_open_window_algo.py b/tests/test_open_window_algo.py index fea12eb..48a85e0 100644 --- a/tests/test_open_window_algo.py +++ b/tests/test_open_window_algo.py @@ -2,7 +2,9 @@ """ Test the OpenWindow algorithm """ 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 @@ -13,24 +15,34 @@ async def test_open_window_algo( ): """Tests the Algo""" - the_algo = WindowOpenDetectionAlgorithm(1.0, 0.0) + the_algo = WindowOpenDetectionAlgorithm(60.0, 0.0) assert the_algo.last_slope is None tz = get_tz(hass) # pylint: disable=invalid-name now = datetime.now(tz) - event_timestamp = now - timedelta(minutes=5) + event_timestamp = now - timedelta(minutes=10) last_slope = the_algo.add_temp_measurement( temperature=10, datetime_measure=event_timestamp ) - # We need at least 2 measurement + # We need at least 4 measurement assert 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_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( temperature=10, datetime_measure=event_timestamp ) @@ -41,62 +53,62 @@ async def test_open_window_algo( assert the_algo.is_window_close_detected() is True 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( temperature=9, datetime_measure=event_timestamp ) # A slope is calculated - assert last_slope == -0.5 - assert the_algo.last_slope == -0.5 + assert last_slope == -48.0 + assert the_algo.last_slope == -48.0 assert the_algo.is_window_close_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) + event_timestamp = now - timedelta(minutes=5) + last_slope = the_algo.add_temp_measurement( + temperature=7, datetime_measure=event_timestamp + ) + + # A slope is calculated + assert last_slope == (-48.0 * 0.2 - 120.0 * 0.8) + assert the_algo.last_slope == -105.6 + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is True + + # A new temperature with 1 degre less + event_timestamp = now - timedelta(minutes=4) + last_slope = the_algo.add_temp_measurement( + temperature=6, datetime_measure=event_timestamp + ) + + # A slope is calculated + assert last_slope == -105.6 * 0.2 - 60.0 * 0.8 + assert the_algo.last_slope == -69.12 + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is True + + # A new temperature with 0 degre less + event_timestamp = now - timedelta(minutes=3) + last_slope = the_algo.add_temp_measurement( + temperature=6, datetime_measure=event_timestamp + ) + + # A slope is calculated + assert last_slope == round(-69.12 * 0.2 - 0.0 * 0.8, 2) + assert the_algo.last_slope == -13.82 + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is False + + # A new temperature with 1 degre more event_timestamp = now - timedelta(minutes=2) last_slope = the_algo.add_temp_measurement( temperature=7, datetime_measure=event_timestamp ) # A slope is calculated - assert last_slope == -0.5 / 2.0 - 2.0 / 2.0 - assert the_algo.last_slope == -1.25 - assert the_algo.is_window_close_detected() is False - assert the_algo.is_window_open_detected() is True - - # A new temperature with 1 degre less - event_timestamp = now - timedelta(minutes=1) - last_slope = the_algo.add_temp_measurement( - temperature=6, datetime_measure=event_timestamp - ) - - # A slope is calculated - assert last_slope == -1.25 / 2 - 1.0 / 2.0 - assert the_algo.last_slope == -1.125 - assert the_algo.is_window_close_detected() is False - assert the_algo.is_window_open_detected() is True - - # A new temperature with 0 degre less - event_timestamp = now - timedelta(minutes=0) - last_slope = the_algo.add_temp_measurement( - temperature=6, datetime_measure=event_timestamp - ) - - # A slope is calculated - assert last_slope == -1.125 / 2 - assert the_algo.last_slope == -1.125 / 2 - assert the_algo.is_window_close_detected() is False - assert the_algo.is_window_open_detected() is False - - # A new temperature with 1 degre more - event_timestamp = now + timedelta(minutes=1) - last_slope = the_algo.add_temp_measurement( - temperature=7, datetime_measure=event_timestamp - ) - - # A slope is calculated - assert last_slope == -1.125 / 4 + 0.5 - assert the_algo.last_slope == 0.21875 + assert last_slope == round(-13.82 * 0.2 + 60.0 * 0.8, 2) + assert the_algo.last_slope == 45.24 assert the_algo.is_window_close_detected() is True assert the_algo.is_window_open_detected() is False @@ -106,7 +118,7 @@ async def test_open_window_algo_wrong( skip_hass_states_is_state, ): """Tests the Algo with wrong date""" - the_algo = WindowOpenDetectionAlgorithm(1.0, 0.0) + the_algo = WindowOpenDetectionAlgorithm(60.0, 0.0) assert the_algo.last_slope is None tz = get_tz(hass) # pylint: disable=invalid-name @@ -134,3 +146,95 @@ async def test_open_window_algo_wrong( assert the_algo.last_slope is None assert the_algo.is_window_close_detected() is False assert the_algo.is_window_open_detected() is False + + +async def test_open_window_algo_fake_point( + hass: HomeAssistant, + skip_hass_states_is_state, +): + """Tests the Algo with adding fake point""" + + the_algo = WindowOpenDetectionAlgorithm(3.0, 0.1) + assert the_algo.last_slope is None + + tz = get_tz(hass) # pylint: disable=invalid-name + now = datetime.now(tz) + + event_timestamp = now + last_slope = the_algo.check_age_last_measurement( + temperature=10, datetime_now=event_timestamp + ) + + # We need at least 4 measurement + assert 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_open_detected() is False + + event_timestamp = now + timedelta(minutes=1) + last_slope = the_algo.add_temp_measurement( + temperature=10, datetime_measure=event_timestamp + ) + + event_timestamp = now + timedelta(minutes=2) + last_slope = the_algo.add_temp_measurement( + temperature=10, datetime_measure=event_timestamp + ) + + event_timestamp = now + timedelta(minutes=3) + last_slope = the_algo.add_temp_measurement( + temperature=10, datetime_measure=event_timestamp + ) + + # No slope because same temperature + assert last_slope == 0 + assert the_algo.last_slope == 0 + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is False + + event_timestamp = now + timedelta(minutes=4) + last_slope = the_algo.add_temp_measurement( + temperature=9, datetime_measure=event_timestamp + ) + + # A slope is calculated + assert last_slope == -48.0 + assert the_algo.last_slope == -48.0 + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is True # One degre in one minute + + # 1 Add a fake point one minute later + event_timestamp = now + timedelta(minutes=5) + last_slope = the_algo.check_age_last_measurement( + temperature=8, datetime_now=event_timestamp + ) + + # The slope not have change (fake point is ignored) + assert last_slope == -48.0 + assert the_algo.last_slope == -48.0 + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is True # One degre in one minute + + # 2 Add a fake point 31 minute later -> +2 degres in 32 minutes + event_timestamp = event_timestamp + timedelta(minutes=31) + last_slope = the_algo.check_age_last_measurement( + temperature=10, datetime_now=event_timestamp + ) + + # The slope should have change (fake point is added) + assert last_slope == -8.1 + assert the_algo.last_slope == -8.1 + assert the_algo.is_window_close_detected() is False + assert the_algo.is_window_open_detected() is True + + # 3 Add a 2nd fake point 30 minute later -> +3 degres in 30 minutes + event_timestamp = event_timestamp + timedelta(minutes=31) + last_slope = the_algo.check_age_last_measurement( + temperature=13, datetime_now=event_timestamp + ) + + # The slope should have change (fake point is added) + assert last_slope == 0.67 + assert the_algo.last_slope == 0.67 + assert the_algo.is_window_close_detected() is True + assert the_algo.is_window_open_detected() is False diff --git a/tests/test_window.py b/tests/test_window.py index c651eae..4bb6477 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -296,6 +296,14 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): assert entity.window_state is STATE_OFF + # Initialize the slope algo with 2 measurements + event_timestamp = now + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + # Make the temperature down with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" @@ -307,13 +315,13 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", return_value=True, ): - event_timestamp = now - timedelta(minutes=4) + event_timestamp = event_timestamp + timedelta(minutes=1) await send_temperature_change_event(entity, 19, event_timestamp) # The heater turns on assert mock_send_event.call_count == 0 - assert mock_heater_on.call_count == 1 - assert entity.last_temperature_slope is None + assert entity.is_device_active is True + assert entity.last_temperature_slope == 0.0 assert entity._window_auto_algo.is_window_open_detected() is False assert entity._window_auto_algo.is_window_close_detected() is False assert entity.hvac_mode is HVACMode.HEAT @@ -329,14 +337,14 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", return_value=True, ): - event_timestamp = now - timedelta(minutes=3) + event_timestamp = event_timestamp + timedelta(minutes=1) await send_temperature_change_event(entity, 18, event_timestamp) # The heater turns on assert mock_send_event.call_count == 2 assert mock_heater_on.call_count == 0 assert mock_heater_off.call_count >= 1 - assert entity.last_temperature_slope == -1 + assert entity.last_temperature_slope == -6.24 assert entity._window_auto_algo.is_window_open_detected() is True assert entity._window_auto_algo.is_window_close_detected() is False assert entity.window_auto_state == STATE_ON @@ -347,7 +355,7 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): call.send_event(EventType.HVAC_MODE_EVENT, {"hvac_mode": HVACMode.OFF}), call.send_event( EventType.WINDOW_AUTO_EVENT, - {"type": "start", "cause": "slope alert", "curve_slope": -1.0}, + {"type": "start", "cause": "slope alert", "curve_slope": -6.24}, ), ], any_order=True, @@ -365,14 +373,14 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): new_callable=PropertyMock, return_value=False, ): - event_timestamp = now - timedelta(minutes=2) + event_timestamp = event_timestamp + timedelta(minutes=1) await send_temperature_change_event(entity, 17.9, event_timestamp) # The heater turns on assert mock_send_event.call_count == 0 assert mock_heater_on.call_count == 0 assert mock_heater_off.call_count == 0 - assert round(entity.last_temperature_slope, 3) == -0.1 * 0.5 - 1 * 0.5 + assert round(entity.last_temperature_slope, 3) == -7.49 assert entity._window_auto_algo.is_window_open_detected() is True assert entity._window_auto_algo.is_window_close_detected() is False assert entity.window_auto_state == STATE_ON @@ -390,7 +398,7 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): new_callable=PropertyMock, return_value=False, ): - event_timestamp = now - timedelta(minutes=1) + event_timestamp = event_timestamp + timedelta(minutes=1) await send_temperature_change_event(entity, 19, event_timestamp) # The heater turns on @@ -405,7 +413,7 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): { "type": "end", "cause": "end of slope alert", - "curve_slope": 0.27500000000000036, + "curve_slope": 0.42, }, ), ], @@ -413,7 +421,7 @@ async def test_window_auto_fast(hass: HomeAssistant, skip_hass_states_is_state): ) assert mock_heater_on.call_count == 1 assert mock_heater_off.call_count == 0 - assert round(entity.last_temperature_slope, 3) == 0.275 + assert entity.last_temperature_slope == 0.42 assert entity._window_auto_algo.is_window_open_detected() is False assert entity._window_auto_algo.is_window_close_detected() is True assert entity.window_auto_state == STATE_OFF @@ -451,8 +459,8 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st CONF_MINIMAL_ACTIVATION_DELAY: 30, CONF_SECURITY_DELAY_MIN: 5, CONF_SECURITY_MIN_ON_PERCENT: 0.3, - CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1, - CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1, + CONF_WINDOW_AUTO_OPEN_THRESHOLD: 6, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 6, CONF_WINDOW_AUTO_MAX_DURATION: 0, # Should be 0 for test }, ) @@ -477,6 +485,14 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st assert entity.window_state is STATE_OFF + # Initialize the slope algo with 2 measurements + event_timestamp = now + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + # Make the temperature down with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" @@ -486,12 +502,13 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.is_device_active", return_value=True, ): - event_timestamp = now - timedelta(minutes=4) + # This is the 3rd measurment. Slope is not ready + event_timestamp = event_timestamp + timedelta(minutes=1) await send_temperature_change_event(entity, 19, event_timestamp) # The climate turns on but was alredy on assert mock_set_hvac_mode.call_count == 0 - assert entity.last_temperature_slope is None + assert entity.last_temperature_slope == 0.0 assert entity._window_auto_algo.is_window_open_detected() is False assert entity._window_auto_algo.is_window_close_detected() is False assert entity.hvac_mode is HVACMode.HEAT @@ -505,9 +522,13 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", return_value=True, ): - event_timestamp = now - timedelta(minutes=3) + event_timestamp = event_timestamp + timedelta(minutes=1) await send_temperature_change_event(entity, 18, event_timestamp, sleep=False) + assert entity.last_temperature_slope == -6.24 + assert entity._window_auto_algo.is_window_open_detected() is True + assert entity._window_auto_algo.is_window_close_detected() is False + assert mock_send_event.call_count == 2 # The heater turns off mock_send_event.assert_has_calls( @@ -518,20 +539,22 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st { "type": "start", "cause": "slope alert", - "curve_slope": -1.0, + "curve_slope": -6.24, }, ), ], any_order=True, ) assert mock_set_hvac_mode.call_count >= 1 - assert entity.last_temperature_slope == -1 - assert entity._window_auto_algo.is_window_open_detected() is True - assert entity._window_auto_algo.is_window_close_detected() is False assert entity.window_auto_state == STATE_ON assert entity.hvac_mode is HVACMode.OFF - # Waits for automatic disable + # This is to avoid that the slope stayx under 6, else we will reactivate the window immediatly + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp, sleep=False) + assert entity.last_temperature_slope > -6.0 + + # Waits for automatic disable with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event, patch( @@ -542,14 +565,14 @@ async def test_window_auto_auto_stop(hass: HomeAssistant, skip_hass_states_is_st ): await asyncio.sleep(0.3) - assert mock_set_hvac_mode.call_count == 1 - assert round(entity.last_temperature_slope, 3) == -1 - # Because the algorithm is not aware of the expiration, for the algo we are still in alert - assert entity._window_auto_algo.is_window_open_detected() is True - assert entity._window_auto_algo.is_window_close_detected() is False - assert entity.window_auto_state == STATE_OFF assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_BOOST + assert entity.window_auto_state == STATE_OFF + + assert mock_set_hvac_mode.call_count == 1 + assert round(entity.last_temperature_slope, 3) == -0.29 + assert entity._window_auto_algo.is_window_open_detected() is False + assert entity._window_auto_algo.is_window_close_detected() is False # Clean the entity entity.remove_thermostat() @@ -576,7 +599,7 @@ async def test_window_auto_no_on_percent( CONF_TEMP_MAX: 30, "eco_temp": 17, "comfort_temp": 18, - "boost_temp": 21, + "boost_temp": 20, CONF_USE_WINDOW_FEATURE: True, CONF_USE_MOTION_FEATURE: False, CONF_USE_POWER_FEATURE: False, @@ -588,8 +611,8 @@ async def test_window_auto_no_on_percent( CONF_MINIMAL_ACTIVATION_DELAY: 30, CONF_SECURITY_DELAY_MIN: 5, CONF_SECURITY_MIN_ON_PERCENT: 0.3, - CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1, - CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1, + CONF_WINDOW_AUTO_OPEN_THRESHOLD: 6, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 6, CONF_WINDOW_AUTO_MAX_DURATION: 0, # Should be 0 for test }, ) @@ -610,10 +633,18 @@ async def test_window_auto_no_on_percent( assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_BOOST assert entity.overpowering_state is None - assert entity.target_temperature == 21 + assert entity.target_temperature == 20 assert entity.window_state is STATE_OFF + # Initialize the slope algo with 2 measurements + event_timestamp = now + timedelta(minutes=1) + await send_temperature_change_event(entity, 21, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 21, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 21, event_timestamp) + # Make the temperature down with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" @@ -625,12 +656,12 @@ async def test_window_auto_no_on_percent( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", return_value=True, ): - event_timestamp = now - timedelta(minutes=4) - await send_temperature_change_event(entity, 21.5, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 21, event_timestamp) - # The heater turns on + # The heater don't turns on assert mock_heater_on.call_count == 0 - assert entity.last_temperature_slope is None + assert entity.last_temperature_slope == 0.0 assert entity._window_auto_algo.is_window_open_detected() is False assert entity._window_auto_algo.is_window_close_detected() is False assert entity.hvac_mode is HVACMode.HEAT @@ -647,16 +678,19 @@ async def test_window_auto_no_on_percent( "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", return_value=True, ): - event_timestamp = now - timedelta(minutes=3) + event_timestamp = event_timestamp + timedelta(minutes=1) await send_temperature_change_event(entity, 20, event_timestamp) # The heater turns on but no alert because the heater was not heating + assert entity.proportional_algorithm.on_percent == 0.0 assert mock_send_event.call_count == 0 - assert mock_heater_on.call_count == 1 - assert mock_heater_off.call_count == 0 - assert entity.last_temperature_slope == -1.5 + assert mock_heater_on.call_count == 0 + assert mock_heater_off.call_count == 1 + assert entity.last_temperature_slope == -6.24 + # The algo calculate open ... assert entity._window_auto_algo.is_window_open_detected() is True assert entity._window_auto_algo.is_window_close_detected() is False + # But the entity is still on assert entity.window_auto_state == STATE_OFF assert entity.hvac_mode is HVACMode.HEAT @@ -831,8 +865,8 @@ async def test_window_auto_bypass(hass: HomeAssistant, skip_hass_states_is_state CONF_MINIMAL_ACTIVATION_DELAY: 30, CONF_SECURITY_DELAY_MIN: 5, CONF_SECURITY_MIN_ON_PERCENT: 0.3, - CONF_WINDOW_AUTO_OPEN_THRESHOLD: 0.1, - CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 0.1, + CONF_WINDOW_AUTO_OPEN_THRESHOLD: 6, + CONF_WINDOW_AUTO_CLOSE_THRESHOLD: 6, CONF_WINDOW_AUTO_MAX_DURATION: 0, # Should be 0 for test }, ) @@ -857,6 +891,14 @@ async def test_window_auto_bypass(hass: HomeAssistant, skip_hass_states_is_state assert entity.window_state is STATE_OFF + # Initialize the slope algo with 2 measurements + event_timestamp = now + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + event_timestamp = event_timestamp + timedelta(minutes=1) + await send_temperature_change_event(entity, 19, event_timestamp) + # Make the temperature down with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" @@ -868,12 +910,12 @@ async def test_window_auto_bypass(hass: HomeAssistant, skip_hass_states_is_state "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", return_value=True, ): - event_timestamp = now - timedelta(minutes=4) + event_timestamp = event_timestamp + timedelta(minutes=1) await send_temperature_change_event(entity, 19, event_timestamp) # The heater turns on - assert mock_heater_on.call_count == 1 - assert entity.last_temperature_slope is None + assert entity.is_device_active is True + assert entity.last_temperature_slope == 0.0 assert entity._window_auto_algo.is_window_open_detected() is False assert entity._window_auto_algo.is_window_close_detected() is False assert entity.hvac_mode is HVACMode.HEAT @@ -881,7 +923,6 @@ async def test_window_auto_bypass(hass: HomeAssistant, skip_hass_states_is_state # send one degre down in one minute with window bypass on await entity.service_set_window_bypass_state(True) assert entity.window_bypass_state is True - # entity._window_bypass_state = True with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" @@ -893,7 +934,7 @@ async def test_window_auto_bypass(hass: HomeAssistant, skip_hass_states_is_state "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.is_device_active", return_value=True, ): - event_timestamp = now - timedelta(minutes=3) + event_timestamp = event_timestamp + timedelta(minutes=1) await send_temperature_change_event(entity, 18, event_timestamp, sleep=False) # No change should have been done @@ -901,7 +942,7 @@ async def test_window_auto_bypass(hass: HomeAssistant, skip_hass_states_is_state assert mock_heater_on.call_count == 0 assert mock_heater_off.call_count == 0 - assert entity.last_temperature_slope == -1 + assert entity.last_temperature_slope == -6.24 assert entity._window_auto_algo.is_window_open_detected() is True assert entity._window_auto_algo.is_window_close_detected() is False assert entity.window_auto_state == STATE_OFF