# pylint: disable=line-too-long """ This file implements the Auto start/stop algorithm as described here: https://github.com/jmcollin78/versatile_thermostat/issues/585 """ import logging from vtherm_api.log_collector import get_vtherm_logger from datetime import datetime from typing import Literal from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_HEAT, VThermHvacMode_COOL from .const import ( AUTO_START_STOP_LEVEL_NONE, AUTO_START_STOP_LEVEL_FAST, AUTO_START_STOP_LEVEL_MEDIUM, AUTO_START_STOP_LEVEL_SLOW, AUTO_START_STOP_LEVEL_VERY_SLOW, TYPE_AUTO_START_STOP_LEVELS, ) _LOGGER = get_vtherm_logger(__name__) # Some constant to make algorithm depending of level DT_MIN = { AUTO_START_STOP_LEVEL_NONE: 0, # Not used AUTO_START_STOP_LEVEL_VERY_SLOW: 60, 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 # A temp hysteresis to avoid rapid OFF/ON TEMP_HYSTERESIS = 0.5 ERROR_THRESHOLD = { AUTO_START_STOP_LEVEL_NONE: 0, # Not used AUTO_START_STOP_LEVEL_VERY_SLOW: 20, # 20 cycle above 1° or 10 cycle above 2°, ... 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 = 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 | None = None _level: str = AUTO_START_STOP_LEVEL_NONE _accumulated_error: float = 0 _error_threshold: float | None = None _last_calculation_date: datetime | None = None _last_switch_date: datetime | None = None def __init__(self, level: TYPE_AUTO_START_STOP_LEVELS, vtherm_name: str): """Initalize a new algorithm with the right constants""" self._vtherm_name: str = vtherm_name self._last_calculation_date: datetime | None = None self._last_switch_date: datetime | None = None self._init_level(level) self._last_should_be_off: bool = False 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 should_be_turned_off(self, requested_hvac_mode: VThermHvacMode, target_temp: float, current_temp: float, slope_min: float | None, now: datetime) -> bool | None: """Check auto-start/stop state should be triggered Return True if the device should be turned off, False if the device should not be turned off """ if self._level == AUTO_START_STOP_LEVEL_NONE: _LOGGER.debug( "%s - auto-start/stop is disabled", self, ) self._last_should_be_off = False return self._last_should_be_off _LOGGER.debug( "%s - calculate_action: requested_hvac_mode=%s, target_temp=%s, current_temp=%s, slope_min=%s at %s", self, requested_hvac_mode, target_temp, current_temp, slope_min, now, ) if requested_hvac_mode is None or target_temp is None or current_temp is None: _LOGGER.debug( "%s - No all mandatory parameters are set. Disable auto-start/stop", self, ) self._last_should_be_off = False return self._last_should_be_off # 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 (< 24 sec) if dtmin <= 0.2: _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 self._last_should_be_off 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 temp_at_dt = current_temp + slope_min * self._dt # Calculate the number of minute from last_switch nb_minutes_since_last_switch = 999 if self._last_switch_date is not None: nb_minutes_since_last_switch = ( now - self._last_switch_date ).total_seconds() / 60 # Check to turn-off # When we hit the threshold, that mean we can turn off previous_should_be_off = self._last_should_be_off if requested_hvac_mode == VThermHvacMode_HEAT: if self._accumulated_error <= -self._error_threshold and temp_at_dt >= target_temp + TEMP_HYSTERESIS and nb_minutes_since_last_switch >= self._dt: _LOGGER.info( "%s - auto-start/stop: We need to stop, there is no need for heating for a long time.", self, ) self._last_should_be_off = True elif temp_at_dt <= target_temp - TEMP_HYSTERESIS and nb_minutes_since_last_switch >= self._dt: _LOGGER.info( "%s - auto-start/stop: We need to start heating.", self, ) self._last_should_be_off = False elif requested_hvac_mode == VThermHvacMode_COOL: if self._accumulated_error >= self._error_threshold and temp_at_dt <= target_temp - TEMP_HYSTERESIS and nb_minutes_since_last_switch >= self._dt: _LOGGER.info( "%s - We need to stop, there is no need for cooling for a long time.", self, ) self._last_should_be_off = True elif temp_at_dt >= target_temp + TEMP_HYSTERESIS and nb_minutes_since_last_switch >= self._dt: _LOGGER.info( "%s - We need to start cooling.", self, ) self._last_should_be_off = False _LOGGER.debug("%s - nothing to do, requested hvac_mode is %s", self, requested_hvac_mode) if previous_should_be_off != self._last_should_be_off: self._last_switch_date = now return self._last_should_be_off # def calculate_action( # self, # hvac_mode: VThermHvacMode | None, # saved_hvac_mode: VThermHvacMode | None, # target_temp: float, # current_temp: float, # slope_min: float | None, # now: datetime, # ) -> AUTO_START_STOP_ACTIONS: # """Calculate an eventual action to do depending of the value in parameter""" # # Check to turn-off # # When we hit the threshold, that mean we can turn off # if hvac_mode == VThermHvacMode_HEAT: # if ( # self._accumulated_error <= -self._error_threshold # and temp_at_dt >= target_temp + TEMP_HYSTERESIS # and nb_minutes_since_last_switch >= self._dt # ): # _LOGGER.info( # "%s - We need to stop, there is no need for heating for a long time.", # self, # ) # self._last_switch_date = now # return AUTO_START_STOP_ACTION_OFF # else: # _LOGGER.debug("%s - nothing to do, we are heating", self) # return AUTO_START_STOP_ACTION_NOTHING # if hvac_mode == VThermHvacMode_COOL: # if ( # self._accumulated_error >= self._error_threshold # and temp_at_dt <= target_temp - TEMP_HYSTERESIS # and nb_minutes_since_last_switch >= self._dt # ): # _LOGGER.info( # "%s - We need to stop, there is no need for cooling for a long time.", # self, # ) # self._last_switch_date = now # return AUTO_START_STOP_ACTION_OFF # else: # _LOGGER.debug( # "%s - nothing to do, we are cooling", # self, # ) # return AUTO_START_STOP_ACTION_NOTHING # # check to turn on # if hvac_mode == VThermHvacMode_OFF and saved_hvac_mode == VThermHvacMode_HEAT: # if ( # temp_at_dt <= target_temp - TEMP_HYSTERESIS # and nb_minutes_since_last_switch >= self._dt # ): # _LOGGER.info( # "%s - We need to start, because it will be time to heat", # self, # ) # self._last_switch_date = now # return AUTO_START_STOP_ACTION_ON # else: # _LOGGER.debug( # "%s - nothing to do, we don't need to heat soon", # self, # ) # return AUTO_START_STOP_ACTION_NOTHING # if hvac_mode == VThermHvacMode_OFF and saved_hvac_mode == VThermHvacMode_COOL: # if ( # temp_at_dt >= target_temp + TEMP_HYSTERESIS # and nb_minutes_since_last_switch >= self._dt # ): # _LOGGER.info( # "%s - We need to start, because it will be time to cool", # self, # ) # self._last_switch_date = now # return AUTO_START_STOP_ACTION_ON # else: # _LOGGER.debug( # "%s - nothing to do, we don't need to cool soon", # self, # ) # return AUTO_START_STOP_ACTION_NOTHING # _LOGGER.debug( # "%s - nothing to do, no conditions applied", # self, # ) # return AUTO_START_STOP_ACTION_NOTHING def set_level(self, level: TYPE_AUTO_START_STOP_LEVELS): """Set a new level""" self._init_level(level) def reset_switch_delay(self): """Reset the switch delay to allow immediate restart. Should be called when target temperature changes significantly (preset change, manual temp change). This prevents the VTherm from staying off when the user explicitly requests a higher temperature. """ _LOGGER.debug("%s - Resetting switch delay to allow immediate start/stop", self) self._last_switch_date = None # Also reset _last_calculation_date to allow immediate recalculation # This is needed because the check for too-rapid calculations would otherwise # ignore the next call if it happens within 24 seconds self._last_calculation_date = None # Reset the last decision so a user-initiated preset/temperature change always exits # the auto-stop state. Without this, if neither the "turn on" nor the "turn off" # condition is met (e.g. room temp near target with a slightly positive slope), # _last_should_be_off would stay True and the VTherm would remain stopped even though # the user explicitly asked for a different preset. self._last_should_be_off = False @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 accumulated_error_threshold(self) -> float: """Get the accumulated error threshold value""" return self._error_threshold @property def level(self) -> TYPE_AUTO_START_STOP_LEVELS: """Get the level value""" return self._level @property def last_switch_date(self) -> datetime | None: """Get the last of the last switch""" return self._last_switch_date def __str__(self) -> str: return f"AutoStartStopDetectionAlgorithm-{self._vtherm_name}"