diff --git a/custom_components/versatile_thermostat/auto_start_stop_algorithm.py b/custom_components/versatile_thermostat/auto_start_stop_algorithm.py index d6700f6..6249580 100644 --- a/custom_components/versatile_thermostat/auto_start_stop_algorithm.py +++ b/custom_components/versatile_thermostat/auto_start_stop_algorithm.py @@ -3,6 +3,8 @@ """ import logging +from datetime import datetime, timedelta +from typing import Literal from homeassistant.components.climate import HVACMode @@ -12,57 +14,73 @@ from .const import ( AUTO_START_STOP_LEVEL_MEDIUM, AUTO_START_STOP_LEVEL_SLOW, CONF_AUTO_START_STOP_LEVELS, + TYPE_AUTO_START_STOP_LEVELS, ) _LOGGER = logging.getLogger(__name__) -# attribute name should be equal to AUTO_START_STOP_LEVEL_xxx constants (in const.yaml) -DTEMP = { - AUTO_START_STOP_LEVEL_NONE: 99, - AUTO_START_STOP_LEVEL_SLOW: 3, - AUTO_START_STOP_LEVEL_MEDIUM: 2, - AUTO_START_STOP_LEVEL_FAST: 1, -} +# Some constant to make algorithm depending of level DT_MIN = { - AUTO_START_STOP_LEVEL_NONE: 99, + AUTO_START_STOP_LEVEL_NONE: 0, # Not used AUTO_START_STOP_LEVEL_SLOW: 30, AUTO_START_STOP_LEVEL_MEDIUM: 15, AUTO_START_STOP_LEVEL_FAST: 7, } +# the measurement cycle (2 min) +CYCLE_SEC = 120 + +ERROR_THRESHOLD = { + AUTO_START_STOP_LEVEL_NONE: 0, # Not used + AUTO_START_STOP_LEVEL_SLOW: 10, # 10 cycle above 1° or 5 cycle above 2°, ... + AUTO_START_STOP_LEVEL_MEDIUM: 5, # 5 cycle above 1° or 3 cycle above 2°, ..., 1 cycle above 5° + AUTO_START_STOP_LEVEL_FAST: 2, # 2 cycle above 1° or 1 cycle above 2° +} + AUTO_START_STOP_ACTION_OFF = "turnOff" AUTO_START_STOP_ACTION_ON = "turnOn" AUTO_START_STOP_ACTION_NOTHING = "nothing" -AUTO_START_STOP_ACTIONS = [ +AUTO_START_STOP_ACTIONS = Literal[ # pylint: disable=invalid-name AUTO_START_STOP_ACTION_OFF, AUTO_START_STOP_ACTION_ON, AUTO_START_STOP_ACTION_NOTHING, ] - class AutoStartStopDetectionAlgorithm: """The class that implements the algorithm listed above""" - _dt: float - _dtemp: float - _level: str + _dt: float | None = None + _level: str = AUTO_START_STOP_LEVEL_NONE + _accumulated_error: float = 0 + _error_threshold: float | None = None + _last_calculation_date: datetime | None = None - def __init__(self, level: CONF_AUTO_START_STOP_LEVELS, vtherm_name) -> None: + def __init__(self, level: TYPE_AUTO_START_STOP_LEVELS, vtherm_name) -> None: """Initalize a new algorithm with the right constants""" - self._level = level - self._dt = DT_MIN[level] - self._dtemp = DTEMP[level] self._vtherm_name = vtherm_name + self._init_level(level) + + def _init_level(self, level: TYPE_AUTO_START_STOP_LEVELS): + """Initialize a new level""" + if level == self._level: + return + + self._level = level + if self._level != AUTO_START_STOP_LEVEL_NONE: + self._dt = DT_MIN[level] + self._error_threshold = ERROR_THRESHOLD[level] + # reset accumulated error if we change the level + self._accumulated_error = 0 def calculate_action( self, hvac_mode: HVACMode | None, saved_hvac_mode: HVACMode | None, - regulated_temp: float, target_temp: float, current_temp: float, slope_min: float, + now: datetime, ) -> AUTO_START_STOP_ACTIONS: """Calculate an eventual action to do depending of the value in parameter""" if self._level == AUTO_START_STOP_LEVEL_NONE: @@ -74,7 +92,6 @@ class AutoStartStopDetectionAlgorithm: if ( hvac_mode is None - or regulated_temp is None or target_temp is None or current_temp is None or slope_min is None @@ -86,18 +103,51 @@ class AutoStartStopDetectionAlgorithm: return AUTO_START_STOP_ACTION_NOTHING _LOGGER.debug( - "%s - calculate_action: hvac_mode=%s, saved_hvac_mode=%s, regulated_temp=%s, target_temp=%s, current_temp=%s, slope_min=%s", + "%s - calculate_action: hvac_mode=%s, saved_hvac_mode=%s, target_temp=%s, current_temp=%s, slope_min=%s at %s", self, hvac_mode, saved_hvac_mode, - regulated_temp, target_temp, current_temp, slope_min, + now, ) + # Calculate the error factor (P) + error = target_temp - current_temp + + # reduce the error considering the dt between the last measurement + if self._last_calculation_date is not None: + dtmin = (now - self._last_calculation_date).total_seconds() / CYCLE_SEC + # ignore two calls too near (< 2,5 min) + if dtmin <= 0.5: + _LOGGER.debug( + "%s - new calculation of auto_start_stop (%s) is too near of the last one (%s). Forget it", + self, + now, + self._last_calculation_date, + ) + return AUTO_START_STOP_ACTION_NOTHING + error = error * dtmin + + # If the error have change its sign, reset smoothly the accumulated error + if error * self._accumulated_error < 0: + self._accumulated_error = self._accumulated_error / 2.0 + + self._accumulated_error += error + + # Capping of the error + self._accumulated_error = min( + self._error_threshold, + max(-self._error_threshold, self._accumulated_error), + ) + + self._last_calculation_date = now + + # Check to turn-off + # When we hit the threshold, that mean we can turn off if hvac_mode == HVACMode.HEAT: - if regulated_temp + self._dtemp <= target_temp and slope_min >= 0: + if self._accumulated_error <= -self._error_threshold: _LOGGER.info( "%s - We need to stop, there is no need for heating for a long time.", ) @@ -109,7 +159,7 @@ class AutoStartStopDetectionAlgorithm: return AUTO_START_STOP_ACTION_NOTHING if hvac_mode == HVACMode.COOL: - if regulated_temp - self._dtemp >= target_temp and slope_min <= 0: + if self._accumulated_error >= self._error_threshold: _LOGGER.info( "%s - We need to stop, there is no need for cooling for a long time.", ) @@ -120,6 +170,7 @@ class AutoStartStopDetectionAlgorithm: ) return AUTO_START_STOP_ACTION_NOTHING + # check to turn on if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.HEAT: if current_temp + slope_min * self._dt <= target_temp: _LOGGER.info( @@ -149,5 +200,24 @@ class AutoStartStopDetectionAlgorithm: ) return AUTO_START_STOP_ACTION_NOTHING + def set_level(self, level: TYPE_AUTO_START_STOP_LEVELS): + """Set a new level""" + self._init_level(level) + + @property + def dt_min(self) -> float: + """Get the dt value""" + return self._dt + + @property + def accumulated_error(self) -> float: + """Get the accumulated error value""" + return self._accumulated_error + + @property + def level(self) -> TYPE_AUTO_START_STOP_LEVELS: + """Get the level value""" + return self._level + def __str__(self) -> str: return f"AutoStartStopDetectionAlgorithm-{self._vtherm_name}" diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 4935c0e..b862636 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -2,6 +2,7 @@ """Constants for the Versatile Thermostat integration.""" import logging +from typing import Literal from enum import Enum from homeassistant.const import CONF_NAME, Platform @@ -158,6 +159,14 @@ CONF_AUTO_START_STOP_LEVELS = [ AUTO_START_STOP_LEVEL_FAST, ] +# For explicit typing purpose only +TYPE_AUTO_START_STOP_LEVELS = Literal[ # pylint: disable=invalid-name + AUTO_START_STOP_LEVEL_FAST, + AUTO_START_STOP_LEVEL_MEDIUM, + AUTO_START_STOP_LEVEL_SLOW, + AUTO_START_STOP_LEVEL_NONE, +] + DEFAULT_SHORT_EMA_PARAMS = { "max_alpha": 0.5, # In sec @@ -458,6 +467,7 @@ class EventType(Enum): CENTRAL_BOILER_EVENT: str = "versatile_thermostat_central_boiler_event" PRESET_EVENT: str = "versatile_thermostat_preset_event" WINDOW_AUTO_EVENT: str = "versatile_thermostat_window_auto_event" + AUTO_START_STOP_EVENT: str = "versatile_thermostat_auto_start_stop_event" def send_vtherm_event(hass, event_type: EventType, entity, data: dict): diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 5c264ce..67a9d6f 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -49,11 +49,20 @@ from .const import ( RegulationParamStrong, AUTO_FAN_DTEMP_THRESHOLD, AUTO_FAN_DEACTIVATED_MODES, + CONF_AUTO_START_STOP_LEVEL, + AUTO_START_STOP_LEVEL_NONE, + TYPE_AUTO_START_STOP_LEVELS, UnknownEntity, + EventType, ) from .vtherm_api import VersatileThermostatAPI from .underlyings import UnderlyingClimate +from .auto_start_stop_algorithm import ( + AutoStartStopDetectionAlgorithm, + AUTO_START_STOP_ACTION_OFF, + AUTO_START_STOP_ACTION_ON, +) _LOGGER = logging.getLogger(__name__) @@ -64,7 +73,6 @@ HVAC_ACTION_ON = [ # pylint: disable=invalid-name HVACAction.HEATING, ] - class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): """Representation of a base class for a Versatile Thermostat over a climate""" @@ -81,6 +89,8 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): # The fan_mode name depending of the current_mode _auto_activated_fan_mode: str | None = None _auto_deactivated_fan_mode: str | None = None + _auto_start_stop_level: TYPE_AUTO_START_STOP_LEVELS = AUTO_START_STOP_LEVEL_NONE + _auto_start_stop_algo: AutoStartStopDetectionAlgorithm | None = None _entity_component_unrecorded_attributes = ( BaseThermostat._entity_component_unrecorded_attributes.union( @@ -99,6 +109,9 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): "auto_activated_fan_mode", "auto_deactivated_fan_mode", "auto_regulation_use_device_temp", + "auto_start_stop_level", + "auto_start_stop_dtemp", + "auto_start_stop_dtmin", } ) ) @@ -113,6 +126,61 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): self._regulated_target_temp = self.target_temperature self._last_regulation_change = NowClass.get_now(hass) + @overrides + def post_init(self, config_entry: ConfigData): + """Initialize the Thermostat""" + + super().post_init(config_entry) + for climate in [ + CONF_CLIMATE, + CONF_CLIMATE_2, + CONF_CLIMATE_3, + CONF_CLIMATE_4, + ]: + if config_entry.get(climate): + self._underlyings.append( + UnderlyingClimate( + hass=self._hass, + thermostat=self, + climate_entity_id=config_entry.get(climate), + ) + ) + + self.choose_auto_regulation_mode( + config_entry.get(CONF_AUTO_REGULATION_MODE) + if config_entry.get(CONF_AUTO_REGULATION_MODE) is not None + else CONF_AUTO_REGULATION_NONE + ) + + self._auto_regulation_dtemp = ( + config_entry.get(CONF_AUTO_REGULATION_DTEMP) + if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None + else 0.5 + ) + self._auto_regulation_period_min = ( + config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) + if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None + else 5 + ) + + self._auto_fan_mode = ( + config_entry.get(CONF_AUTO_FAN_MODE) + if config_entry.get(CONF_AUTO_FAN_MODE) is not None + else CONF_AUTO_FAN_NONE + ) + + self._auto_regulation_use_device_temp = config_entry.get( + CONF_AUTO_REGULATION_USE_DEVICE_TEMP, False + ) + + self._auto_start_stop_level = config_entry.get( + CONF_AUTO_START_STOP_LEVEL, AUTO_START_STOP_LEVEL_NONE + ) + # Instanciate the auto start stop algo + self._auto_start_stop_algo = AutoStartStopDetectionAlgorithm( + self._auto_start_stop_level, self.name + ) + @property def is_over_climate(self) -> bool: """True if the Thermostat is over_climate""" @@ -292,53 +360,6 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): ) await self.async_set_fan_mode(self._auto_deactivated_fan_mode) - @overrides - def post_init(self, config_entry: ConfigData): - """Initialize the Thermostat""" - - super().post_init(config_entry) - for climate in [ - CONF_CLIMATE, - CONF_CLIMATE_2, - CONF_CLIMATE_3, - CONF_CLIMATE_4, - ]: - if config_entry.get(climate): - self._underlyings.append( - UnderlyingClimate( - hass=self._hass, - thermostat=self, - climate_entity_id=config_entry.get(climate), - ) - ) - - self.choose_auto_regulation_mode( - config_entry.get(CONF_AUTO_REGULATION_MODE) - if config_entry.get(CONF_AUTO_REGULATION_MODE) is not None - else CONF_AUTO_REGULATION_NONE - ) - - self._auto_regulation_dtemp = ( - config_entry.get(CONF_AUTO_REGULATION_DTEMP) - if config_entry.get(CONF_AUTO_REGULATION_DTEMP) is not None - else 0.5 - ) - self._auto_regulation_period_min = ( - config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) - if config_entry.get(CONF_AUTO_REGULATION_PERIOD_MIN) is not None - else 5 - ) - - self._auto_fan_mode = ( - config_entry.get(CONF_AUTO_FAN_MODE) - if config_entry.get(CONF_AUTO_FAN_MODE) is not None - else CONF_AUTO_FAN_NONE - ) - - self._auto_regulation_use_device_temp = config_entry.get( - CONF_AUTO_REGULATION_USE_DEVICE_TEMP, False - ) - def choose_auto_regulation_mode(self, auto_regulation_mode: str): """Choose or change the regulation mode""" self._auto_regulation_mode = auto_regulation_mode @@ -551,6 +572,13 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): self.auto_regulation_use_device_temp ) + self._attr_extra_state_attributes["auto_start_stop_level"] = ( + self._auto_start_stop_algo.level + ) + self._attr_extra_state_attributes["auto_start_stop_dtmin"] = ( + self._auto_start_stop_algo.dt_min + ) + self.async_write_ha_state() _LOGGER.debug( "%s - Calling update_custom_attributes: %s", @@ -869,6 +897,48 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): """The main function used to run the calculation at each cycle""" ret = await super().async_control_heating(force, _) + # Check if we need to auto start/stop the Vtherm + action = self._auto_start_stop_algo.calculate_action( + self.hvac_mode, + self._saved_hvac_mode, + self.target_temperature, + self.current_temperature, + self._window_auto_algo.last_slope, + self.now + ) + _LOGGER.debug("%s - auto_start_stop action is %s", self, action) + if action == AUTO_START_STOP_ACTION_OFF: + _LOGGER.info( + "%s - Turning OFF the Vtherm due to auto-start-stop conditions", self + ) + # Send an event + self.send_event( + EventType.AUTO_START_STOP_EVENT, + { + "type": "stop", + "cause": "Auto start conditions reached", + "hvac_mode": self.hvac_mode, + "saved_hvac_mode": self._saved_hvac_mode, + "regulated_target_temp": self.regulated_target_temp, + "target_temperature": self.target_temperature, + "current_temperature": self.current_temperature, + "temperature_slope": self._window_auto_algo.last_slope, + }, + ) + await self.async_turn_off() + + # Stop here + return ret + elif action == AUTO_START_STOP_ACTION_ON: + _LOGGER.info( + "%s - Turning ON the Vtherm due to auto-start-stop conditions", self + ) + await self.async_turn_on() + # Send an event + + # Continue the normal async_control_heating + + # Send the regulated temperature to the underlyings await self._send_regulated_temperature() if self._auto_fan_mode and self._auto_fan_mode != CONF_AUTO_FAN_NONE: @@ -1022,6 +1092,11 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): return False return True + @property + def auto_start_stop_level(self) -> TYPE_AUTO_START_STOP_LEVELS: + """Return the auto start/stop level.""" + return self._auto_start_stop_level + @overrides def init_underlyings(self): """Init the underlyings if not already done""" diff --git a/tests/test_auto_start_stop.py b/tests/test_auto_start_stop.py index d5453a6..f8d670b 100644 --- a/tests/test_auto_start_stop.py +++ b/tests/test_auto_start_stop.py @@ -3,7 +3,7 @@ """ Test the Auto Start Stop algorithm management """ from datetime import datetime, timedelta import logging -from unittest.mock import patch +from unittest.mock import patch, call from homeassistant.components.climate import HVACMode @@ -21,376 +21,165 @@ from .commons import * # pylint: disable=wildcard-import, unused-wildcard-impor logging.getLogger().setLevel(logging.DEBUG) -async def test_auto_start_stop_algo_slow(hass: HomeAssistant): +async def test_auto_start_stop_algo_slow_heat_off(hass: HomeAssistant): """Testing directly the algorithm in Slow level""" algo: AutoStartStopDetectionAlgorithm = AutoStartStopDetectionAlgorithm( AUTO_START_STOP_LEVEL_SLOW, "testu" ) - assert algo._dtemp == 3 + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + assert algo._dt == 30 assert algo._vtherm_name == "testu" - # 1. In heating we should stop + # 1. should not stop (accumulated_error too low) ret = algo.calculate_action( hvac_mode=HVACMode.HEAT, saved_hvac_mode=HVACMode.OFF, - regulated_temp=18, target_temp=21, current_temp=22, slope_min=0.1, + now=now, ) - assert ret == AUTO_START_STOP_ACTION_OFF + assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.accumulated_error == -1 - # 2. In heating we should do nothing + # 2. should not stop (accumulated_error too low) + now = now + timedelta(minutes=5) ret = algo.calculate_action( hvac_mode=HVACMode.HEAT, saved_hvac_mode=HVACMode.OFF, - regulated_temp=20, target_temp=21, - current_temp=21, - slope_min=0.0, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 3. In Cooling we should stop - ret = algo.calculate_action( - hvac_mode=HVACMode.COOL, - saved_hvac_mode=HVACMode.OFF, - regulated_temp=24, - target_temp=21, - current_temp=22, - slope_min=-0.1, - ) - assert ret == AUTO_START_STOP_ACTION_OFF - - # 4. In Colling we should do nothing - ret = algo.calculate_action( - hvac_mode=HVACMode.COOL, - saved_hvac_mode=HVACMode.OFF, - regulated_temp=22, - target_temp=21, - current_temp=22, - slope_min=0.0, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 5. In Off, we should start heating - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.HEAT, - regulated_temp=22, - target_temp=21, - current_temp=22, - slope_min=-0.1, - ) - assert ret == AUTO_START_STOP_ACTION_ON - - # 6. In Off we should not heat - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.HEAT, - regulated_temp=23, - target_temp=21, - current_temp=24, - slope_min=0.5, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 7. In Off we still should not heat (slope too low) - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.HEAT, - regulated_temp=22, - target_temp=21, - current_temp=22, - slope_min=-0.01, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 8. In Off, we should start cooling - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.COOL, - regulated_temp=25, - target_temp=24, - current_temp=25, - slope_min=0.1, - ) - assert ret == AUTO_START_STOP_ACTION_ON - - # 9. In Off we should not cool - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.COOL, - regulated_temp=20, - target_temp=24, - current_temp=21, - slope_min=0.01, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - # 9.1 In Off and slow we should cool - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.COOL, - regulated_temp=20, - target_temp=24, - current_temp=21, - slope_min=0.1, - ) - assert ret == AUTO_START_STOP_ACTION_ON - - # 10. In Off we still should not cool (slope too low) - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.COOL, - regulated_temp=25, - target_temp=24, current_temp=23, - slope_min=0.01, + slope_min=0.1, + now=now, ) assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.accumulated_error == -6 + + # 3. should not stop (accumulated_error too low) + now = now + timedelta(minutes=2) + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=21, + current_temp=23, + slope_min=0.1, + now=now, + ) + assert algo.accumulated_error == -8 + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 4 .No change on accumulated error because the new measure is too near the last one + now = now + timedelta(minutes=1) + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=21, + current_temp=23, + slope_min=0.1, + now=now, + ) + assert algo.accumulated_error == -8 + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 5. should stop now because accumulated_error is > ERROR_THRESHOLD for slow (10) + now = now + timedelta(minutes=4) + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=21, + current_temp=22, + slope_min=0.1, + now=now, + ) + assert algo.accumulated_error == -10 + assert ret == AUTO_START_STOP_ACTION_OFF + + # 6. inverse the temperature (target > current) -> accumulated_error should be divided by 2 + now = now + timedelta(minutes=2) + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=22, + current_temp=21, + slope_min=-0.1, + now=now, + ) + assert algo.accumulated_error == -4 # -10/2 + 1 + assert ret == AUTO_START_STOP_ACTION_NOTHING + + # 7. change level to slow (no real change) -> error_accumulated should not reset to 0 + algo.set_level(AUTO_START_STOP_LEVEL_SLOW) + assert algo.accumulated_error == -4 + + # 8. change level -> error_accumulated should reset to 0 + algo.set_level(AUTO_START_STOP_LEVEL_FAST) + assert algo.accumulated_error == 0 -async def test_auto_start_stop_algo_medium(hass: HomeAssistant): +async def test_auto_start_stop_algo_medium_cool_off(hass: HomeAssistant): """Testing directly the algorithm in Slow level""" algo: AutoStartStopDetectionAlgorithm = AutoStartStopDetectionAlgorithm( AUTO_START_STOP_LEVEL_MEDIUM, "testu" ) - assert algo._dtemp == 2 + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + assert algo._dt == 15 assert algo._vtherm_name == "testu" - # 1. In heating we should stop - ret = algo.calculate_action( - hvac_mode=HVACMode.HEAT, - saved_hvac_mode=HVACMode.OFF, - regulated_temp=18, - target_temp=21, - current_temp=22, - slope_min=0.1, - ) - assert ret == AUTO_START_STOP_ACTION_OFF - - # 2. In heating we should do nothing - ret = algo.calculate_action( - hvac_mode=HVACMode.HEAT, - saved_hvac_mode=HVACMode.OFF, - regulated_temp=20, - target_temp=21, - current_temp=21, - slope_min=0.0, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 3. In Cooling we should stop + # 1. should not stop (accumulated_error too low) ret = algo.calculate_action( hvac_mode=HVACMode.COOL, saved_hvac_mode=HVACMode.OFF, - regulated_temp=24, - target_temp=21, - current_temp=22, - slope_min=-0.1, + target_temp=22, + current_temp=21, + slope_min=0.1, + now=now, ) - assert ret == AUTO_START_STOP_ACTION_OFF + assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.accumulated_error == 1 - # 4. In Colling we should do nothing + # 2. should not stop (accumulated_error too low) + now = now + timedelta(minutes=3) ret = algo.calculate_action( hvac_mode=HVACMode.COOL, saved_hvac_mode=HVACMode.OFF, - regulated_temp=22, - target_temp=21, - current_temp=22, - slope_min=0.0, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 5. In Off, we should start heating - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.HEAT, - regulated_temp=22, - target_temp=21, - current_temp=22, - slope_min=-0.1, - ) - assert ret == AUTO_START_STOP_ACTION_ON - - # 6. In Off we should not heat - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.HEAT, - regulated_temp=23, - target_temp=21, - current_temp=24, - slope_min=0.5, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 7. In Off we still should not heat (slope too low) - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.HEAT, - regulated_temp=22, - target_temp=21, - current_temp=22, - slope_min=-0.01, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 8. In Off, we should start cooling - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.COOL, - regulated_temp=25, - target_temp=24, - current_temp=25, - slope_min=0.1, - ) - assert ret == AUTO_START_STOP_ACTION_ON - - # 9. In Off we should not cool - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.COOL, - regulated_temp=20, - target_temp=24, + target_temp=23, current_temp=21, slope_min=0.1, + now=now, ) assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.accumulated_error == 4 - # 10. In Off we still should not cool (slope too low) - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.COOL, - regulated_temp=25, - target_temp=24, - current_temp=23, - slope_min=0.01, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - -async def test_auto_start_stop_algo_high(hass: HomeAssistant): - """Testing directly the algorithm in Slow level""" - algo: AutoStartStopDetectionAlgorithm = AutoStartStopDetectionAlgorithm( - AUTO_START_STOP_LEVEL_FAST, "testu" - ) - - assert algo._dtemp == 1 - assert algo._dt == 7 - assert algo._vtherm_name == "testu" - - # 1. In heating we should stop - ret = algo.calculate_action( - hvac_mode=HVACMode.HEAT, - saved_hvac_mode=HVACMode.OFF, - regulated_temp=18, - target_temp=21, - current_temp=22, - slope_min=0.1, - ) - assert ret == AUTO_START_STOP_ACTION_OFF - - # 2. In heating and fast we should turn off - ret = algo.calculate_action( - hvac_mode=HVACMode.HEAT, - saved_hvac_mode=HVACMode.OFF, - regulated_temp=20, - target_temp=21, - current_temp=21, - slope_min=0.0, - ) - assert ret == AUTO_START_STOP_ACTION_OFF - - # 3. In Cooling we should stop + # 2. should stop + now = now + timedelta(minutes=5) ret = algo.calculate_action( hvac_mode=HVACMode.COOL, saved_hvac_mode=HVACMode.OFF, - regulated_temp=24, - target_temp=21, - current_temp=22, - slope_min=-0.1, + target_temp=23, + current_temp=21, + slope_min=0.1, + now=now, ) assert ret == AUTO_START_STOP_ACTION_OFF + assert algo.accumulated_error == 5 # should be 9 but is capped at error threshold - # 4. In Cooling and fast we should turn off + # 6. inverse the temperature (target > current) -> accumulated_error should be divided by 2 + now = now + timedelta(minutes=2) ret = algo.calculate_action( hvac_mode=HVACMode.COOL, saved_hvac_mode=HVACMode.OFF, - regulated_temp=22, - target_temp=21, - current_temp=22, - slope_min=0.0, - ) - assert ret == AUTO_START_STOP_ACTION_OFF - - # 5. In Off and fast , we should do nothing - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.HEAT, - regulated_temp=22, target_temp=21, current_temp=22, slope_min=-0.1, + now=now, ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 6. In Off we should not heat - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.HEAT, - regulated_temp=23, - target_temp=21, - current_temp=24, - slope_min=0.5, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 7. In Off we still should not heat (slope too low) - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.HEAT, - regulated_temp=22, - target_temp=21, - current_temp=22, - slope_min=-0.01, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 8. In Off, we should start cooling - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.COOL, - regulated_temp=25, - target_temp=24, - current_temp=25, - slope_min=0.1, - ) - assert ret == AUTO_START_STOP_ACTION_ON - - # 9. In Off we should not cool - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.COOL, - regulated_temp=20, - target_temp=24, - current_temp=21, - slope_min=0.1, - ) - assert ret == AUTO_START_STOP_ACTION_NOTHING - - # 10. In Off we still should not cool (slope too low) - ret = algo.calculate_action( - hvac_mode=HVACMode.OFF, - saved_hvac_mode=HVACMode.COOL, - regulated_temp=25, - target_temp=24, - current_temp=23, - slope_min=0.01, - ) + assert algo.accumulated_error == 1.5 # 5/2 - 1 assert ret == AUTO_START_STOP_ACTION_NOTHING @@ -467,8 +256,15 @@ async def test_auto_start_stop_none_vtherm( # Initialize all temps await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate") + # Check correct initialization of auto_start_stop attributes + assert ( + vtherm._attr_extra_state_attributes["auto_start_stop_level"] + == AUTO_START_STOP_LEVEL_NONE + ) - # 1. Vtherm auto-start/stop should be in MEDIUM mode + assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] is None + + # 1. Vtherm auto-start/stop should be in NONE mode assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_NONE @@ -546,38 +342,75 @@ async def test_auto_start_stop_medium_vtherm( # Initialize all temps await set_all_climate_preset_temp(hass, vtherm, temps, "overclimate") + # Check correct initialization of auto_start_stop attributes + assert ( + vtherm._attr_extra_state_attributes["auto_start_stop_level"] + == AUTO_START_STOP_LEVEL_MEDIUM + ) + + assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 15 + # 1. Vtherm auto-start/stop should be in MEDIUM mode assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_MEDIUM - # 1. Set mode to Heat and preset to Comfort - await send_presence_change_event(vtherm, True, False, datetime.now()) + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + # 2. Set mode to Heat and preset to Comfort + await send_presence_change_event(vtherm, True, False, now) + await send_temperature_change_event(vtherm, 18, now, True) await vtherm.async_set_hvac_mode(HVACMode.HEAT) await vtherm.async_set_preset_mode(PRESET_COMFORT) await hass.async_block_till_done() assert vtherm.target_temperature == 19.0 + # VTherm should be heating + assert vtherm.hvac_mode == HVACMode.HEAT - # 2. Only change the HVAC_MODE (and keep preset to comfort) - await vtherm.async_set_hvac_mode(HVACMode.COOL) - await hass.async_block_till_done() - assert vtherm.target_temperature == 25.0 + # 3. Set current temperature to 19 5 min later + now = now + timedelta(minutes=5) + with patch( + "custom_components.versatile_thermostat.binary_sensor.send_vtherm_event" + ) as mock_send_event: + await send_temperature_change_event(vtherm, 19, now, True) + await hass.async_block_till_done() - # 3. Only change the HVAC_MODE (and keep preset to comfort) - await vtherm.async_set_hvac_mode(HVACMode.HEAT) - await hass.async_block_till_done() - assert vtherm.target_temperature == 19.0 + # VTherm should still be heating + assert vtherm.hvac_mode == HVACMode.HEAT + assert mock_send_event.call_count == 0 - # 4. Change presence to off - await send_presence_change_event(vtherm, False, True, datetime.now()) - await hass.async_block_till_done() - assert vtherm.target_temperature == 19.1 + # 4. Set current temperature to 20 5 min later + now = now + timedelta(minutes=5) + with patch( + "custom_components.versatile_thermostat.binary_sensor.send_vtherm_event" + ) as mock_send_event: + await send_temperature_change_event(vtherm, 20, now, True) + await hass.async_block_till_done() - # 5. Change hvac_mode to AC - await vtherm.async_set_hvac_mode(HVACMode.COOL) - await hass.async_block_till_done() - assert vtherm.target_temperature == 25.1 + # VTherm should still be heating + assert vtherm.hvac_mode == HVACMode.HEAT + assert mock_send_event.call_count == 0 - # 6. Change presence to on - await send_presence_change_event(vtherm, True, False, datetime.now()) - await hass.async_block_till_done() - assert vtherm.target_temperature == 25 + # 5. Set current temperature to 21 5 min later + with patch( + "custom_components.versatile_thermostat.binary_sensor.send_vtherm_event" + ) as mock_send_event: + now = now + timedelta(minutes=5) + await send_temperature_change_event(vtherm, 21, now, True) + await hass.async_block_till_done() + + # VTherm should have been stopped + assert vtherm.hvac_mode == HVACMode.OFF + + # a message should have been sent + assert mock_send_event.call_count == 1 + mock_send_event.assert_has_calls( + [ + call.send_vtherm_event( + hass=hass, + event_type=EventType.AUTO_START_STOP_EVENT, + entity=vtherm.entity_id, + data={}, + ) + ] + ) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 1c0df19..7020d74 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -27,7 +27,7 @@ async def test_show_form(hass: HomeAssistant, init_vtherm_api) -> None: @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) # Disable this test which don't work anymore (kill the pytest !) -# @pytest.mark.skip +@pytest.mark.skip async def test_user_config_flow_over_switch( hass: HomeAssistant, skip_hass_states_get, init_central_config ): # pylint: disable=unused-argument @@ -280,6 +280,7 @@ async def test_user_config_flow_over_switch( CONF_USE_POWER_CENTRAL_CONFIG: True, CONF_USE_PRESENCE_CENTRAL_CONFIG: True, CONF_USE_ADVANCED_CENTRAL_CONFIG: True, + CONF_USE_AUTO_START_STOP_FEATURE: False, CONF_USE_CENTRAL_MODE: True, CONF_USED_BY_CENTRAL_BOILER: False, CONF_USE_WINDOW_FEATURE: True, @@ -299,11 +300,11 @@ async def test_user_config_flow_over_switch( @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) # TODO this test fails when run in // but works alone -@pytest.mark.skip +# @pytest.mark.skip async def test_user_config_flow_over_climate( hass: HomeAssistant, skip_hass_states_get ): # pylint: disable=unused-argument - """Test the config flow with all thermostat_over_switch features and never use central config. + """Test the config flow with all thermostat_over_climate features and never use central config. We don't use any features""" # await create_central_config(hass) @@ -499,6 +500,7 @@ async def test_user_config_flow_over_climate( CONF_USE_POWER_FEATURE: False, CONF_USE_PRESENCE_FEATURE: False, CONF_USE_WINDOW_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: False, CONF_USE_CENTRAL_BOILER_FEATURE: False, CONF_USE_TPI_CENTRAL_CONFIG: False, CONF_USE_WINDOW_CENTRAL_CONFIG: False, @@ -877,3 +879,251 @@ async def test_user_config_flow_over_4_switches( assert result["result"].version == 1 assert result["result"].title == "TheOver4SwitchMockName" assert isinstance(result["result"], ConfigEntry) + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +# TODO this test fails when run in // but works alone +# @pytest.mark.skip +async def test_user_config_flow_over_climate_auto_start_stop( + hass: HomeAssistant, skip_hass_states_get +): # pylint: disable=unused-argument + """Test the config flow with auto_start_stop thermostat_over_climate features.""" + # await create_central_config(hass) + + # 1. start a config flow in over_climate + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == SOURCE_USER + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result["menu_options"] == [ + "main", + "features", + "type", + "presets", + "advanced", + "configuration_not_complete", + ] + assert result.get("errors") is None + + # 2. Add auto-start-stop feature + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "features"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "features" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: True, + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result.get("errors") is None + assert result["menu_options"] == [ + "main", + "features", + "type", + "presets", + "auto_start_stop", + "advanced", + "configuration_not_complete", + # "finalize", finalize is not present waiting for advanced configuration + ] + + # 3. Configure auto-start-stop attributes + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "auto_start_stop"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "auto_start_stop" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_MEDIUM, + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result.get("errors") is None + + # 4. Configure main attributes + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "main"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "main" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_NAME: "TheOverClimateMockName", + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_DEVICE_POWER: 1, + CONF_USE_MAIN_CENTRAL_CONFIG: False, + CONF_USE_CENTRAL_MODE: True, + # Keep default values which are False + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "main" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_STEP_TEMPERATURE: 0.1, + # Keep default values which are False + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result.get("errors") is None + + # 5. Configure type attributes + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "type"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "type" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CLIMATE: "climate.mock_climate", + CONF_AC_MODE: False, + CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_STRONG, + CONF_AUTO_REGULATION_DTEMP: 0.5, + CONF_AUTO_REGULATION_PERIOD_MIN: 2, + CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_HIGH, + CONF_AUTO_REGULATION_USE_DEVICE_TEMP: False, + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result["menu_options"] == [ + "main", + "features", + "type", + "presets", + "auto_start_stop", + "advanced", + "configuration_not_complete", + # "finalize", # because we need Advanced default parameters + ] + assert result.get("errors") is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "presets"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "presets" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_USE_PRESETS_CENTRAL_CONFIG: False} + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result.get("errors") is None + + # 6. configure advanced attributes + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "advanced"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "advanced" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_USE_ADVANCED_CENTRAL_CONFIG: False}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "advanced" + assert result.get("errors") == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_MINIMAL_ACTIVATION_DELAY: 10, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.4, + CONF_SECURITY_DEFAULT_ON_PERCENT: 0.3, + }, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "menu" + assert result.get("errors") is None + assert result["menu_options"] == [ + "main", + "features", + "type", + "presets", + "auto_start_stop", + "advanced", + "finalize", # Now finalize is present + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "finalize"} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result.get("errors") is None + assert result[ + "data" + ] == MOCK_TH_OVER_CLIMATE_USER_CONFIG | MOCK_TH_OVER_CLIMATE_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_CENTRAL_MAIN_CONFIG | MOCK_TH_OVER_CLIMATE_TYPE_CONFIG | { + CONF_MINIMAL_ACTIVATION_DELAY: 10, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.4, + CONF_SECURITY_DEFAULT_ON_PERCENT: 0.3, + } | MOCK_DEFAULT_FEATURE_CONFIG | { + CONF_USE_MAIN_CENTRAL_CONFIG: False, + CONF_USE_TPI_CENTRAL_CONFIG: False, + CONF_USE_PRESETS_CENTRAL_CONFIG: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_PRESENCE_FEATURE: False, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: False, + CONF_USE_CENTRAL_BOILER_FEATURE: False, + CONF_USE_TPI_CENTRAL_CONFIG: False, + CONF_USE_WINDOW_CENTRAL_CONFIG: False, + CONF_USE_MOTION_CENTRAL_CONFIG: False, + CONF_USE_POWER_CENTRAL_CONFIG: False, + CONF_USE_PRESENCE_CENTRAL_CONFIG: False, + CONF_USE_ADVANCED_CENTRAL_CONFIG: False, + CONF_USED_BY_CENTRAL_BOILER: False, + CONF_USE_AUTO_START_STOP_FEATURE: True, + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_MEDIUM, + } + assert result["result"] + assert result["result"].domain == DOMAIN + assert result["result"].version == 1 + assert result["result"].title == "TheOverClimateMockName" + assert isinstance(result["result"], ConfigEntry)