From e9dcb21093f6a92b3efd2e52617e79be8db9b58d Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Sun, 8 Dec 2024 19:22:24 +0100 Subject: [PATCH] Issue #625 - auto-start/stop change sometimes too fast (#693) Co-authored-by: Jean-Marc Collin --- .../auto_start_stop_algorithm.py | 31 +++++- .../thermostat_climate.py | 14 +-- tests/test_auto_start_stop.py | 105 ++++++++++++++++++ 3 files changed, 139 insertions(+), 11 deletions(-) diff --git a/custom_components/versatile_thermostat/auto_start_stop_algorithm.py b/custom_components/versatile_thermostat/auto_start_stop_algorithm.py index d1203d95..773d6892 100644 --- a/custom_components/versatile_thermostat/auto_start_stop_algorithm.py +++ b/custom_components/versatile_thermostat/auto_start_stop_algorithm.py @@ -57,10 +57,13 @@ class AutoStartStopDetectionAlgorithm: _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) -> None: """Initalize a new algorithm with the right constants""" self._vtherm_name = vtherm_name + self._last_calculation_date = None + self._last_switch_date = None self._init_level(level) def _init_level(self, level: TYPE_AUTO_START_STOP_LEVELS): @@ -143,17 +146,26 @@ def calculate_action( 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 if hvac_mode == HVACMode.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) @@ -163,11 +175,13 @@ def calculate_action( 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( @@ -178,11 +192,15 @@ def calculate_action( # check to turn on if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.HEAT: - if temp_at_dt <= target_temp - TEMP_HYSTERESIS: + 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( @@ -192,11 +210,15 @@ def calculate_action( return AUTO_START_STOP_ACTION_NOTHING if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.COOL: - if temp_at_dt >= target_temp + TEMP_HYSTERESIS: + 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( @@ -235,5 +257,10 @@ 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}" diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 7bf8d50b..f3bf9294 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -60,6 +60,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): "auto_start_stop_enable", "auto_start_stop_accumulated_error", "auto_start_stop_accumulated_error_threshold", + "auto_start_stop_last_switch_date", "follow_underlying_temp_change", } ) @@ -555,6 +556,10 @@ def update_custom_attributes(self): "auto_start_stop_accumulated_error_threshold" ] = self._auto_start_stop_algo.accumulated_error_threshold + self._attr_extra_state_attributes["auto_start_stop_last_switch_date"] = ( + self._auto_start_stop_algo.last_switch_date + ) + self._attr_extra_state_attributes["follow_underlying_temp_change"] = ( self._follow_underlying_temp_change ) @@ -1114,15 +1119,6 @@ def supported_features(self): return self._support_flags - # We keep the step configured for the VTherm and not the step of the underlying - # @property - # def target_temperature_step(self) -> float | None: - # """Return the supported step of target temperature.""" - # if self.underlying_entity(0): - # return self.underlying_entity(0).target_temperature_step - # - # return None - @property def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach. diff --git a/tests/test_auto_start_stop.py b/tests/test_auto_start_stop.py index 7f1513d1..9448d006 100644 --- a/tests/test_auto_start_stop.py +++ b/tests/test_auto_start_stop.py @@ -15,6 +15,7 @@ AutoStartStopDetectionAlgorithm, AUTO_START_STOP_ACTION_NOTHING, AUTO_START_STOP_ACTION_OFF, + AUTO_START_STOP_ACTION_ON, ) from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import @@ -44,6 +45,7 @@ async def test_auto_start_stop_algo_slow_heat_off(hass: HomeAssistant): ) assert ret == AUTO_START_STOP_ACTION_NOTHING assert algo.accumulated_error == -1 + assert algo.last_switch_date is None # 2. should not stop (accumulated_error too low) now = now + timedelta(minutes=5) @@ -57,6 +59,7 @@ async def test_auto_start_stop_algo_slow_heat_off(hass: HomeAssistant): ) assert ret == AUTO_START_STOP_ACTION_NOTHING assert algo.accumulated_error == -6 + assert algo.last_switch_date is None # 3. should not stop (accumulated_error too low) now = now + timedelta(minutes=2) @@ -70,6 +73,7 @@ async def test_auto_start_stop_algo_slow_heat_off(hass: HomeAssistant): ) assert algo.accumulated_error == -8 assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.last_switch_date is None # 4 .No change on accumulated error because the new measure is too near the last one now = now + timedelta(seconds=11) @@ -83,6 +87,7 @@ async def test_auto_start_stop_algo_slow_heat_off(hass: HomeAssistant): ) assert algo.accumulated_error == -8 assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.last_switch_date is None # 5. should stop now because accumulated_error is > ERROR_THRESHOLD for slow (10) now = now + timedelta(minutes=4) @@ -96,6 +101,9 @@ async def test_auto_start_stop_algo_slow_heat_off(hass: HomeAssistant): ) assert algo.accumulated_error == -10 assert ret == AUTO_START_STOP_ACTION_OFF + assert algo.last_switch_date is not None + assert algo.last_switch_date == now + last_now = now # 6. inverse the temperature (target > current) -> accumulated_error should be divided by 2 now = now + timedelta(minutes=2) @@ -109,14 +117,111 @@ async def test_auto_start_stop_algo_slow_heat_off(hass: HomeAssistant): ) assert algo.accumulated_error == -4 # -10/2 + 1 assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.last_switch_date == last_now # 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 + assert algo.last_switch_date == last_now # 8. change level -> error_accumulated should reset to 0 algo.set_level(AUTO_START_STOP_LEVEL_FAST) assert algo.accumulated_error == 0 + assert algo.last_switch_date == last_now + + +async def test_auto_start_stop_too_fast_change(hass: HomeAssistant): + """Testing directly the algorithm in Slow level""" + algo: AutoStartStopDetectionAlgorithm = AutoStartStopDetectionAlgorithm( + AUTO_START_STOP_LEVEL_SLOW, "testu" + ) + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + assert algo._dt == 30 + assert algo._vtherm_name == "testu" + + # + # Testing with turn_on + # + + # 1. should stop + algo._accumulated_error = -100 + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=10, + current_temp=21, + slope_min=0.5, + now=now, + ) + + assert ret == AUTO_START_STOP_ACTION_OFF + assert algo.last_switch_date is not None + assert algo.last_switch_date == now + last_now = now + + # 2. now we should turn on but to near the last change -> no nothing to do + now = now + timedelta(minutes=2) + algo._accumulated_error = -100 + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.HEAT, + target_temp=21, + current_temp=17, + slope_min=-0.1, + now=now, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.last_switch_date == last_now + + # 3. now we should turn on and now is much later -> + now = now + timedelta(minutes=30) + algo._accumulated_error = -100 + ret = algo.calculate_action( + hvac_mode=HVACMode.OFF, + saved_hvac_mode=HVACMode.HEAT, + target_temp=21, + current_temp=17, + slope_min=-0.1, + now=now, + ) + assert ret == AUTO_START_STOP_ACTION_ON + assert algo.last_switch_date == now + last_now = now + + # + # Testing with turn_off + # + + # 4. try to turn_off but too speed (29 min) + now = now + timedelta(minutes=29) + algo._accumulated_error = -100 + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=17, + current_temp=21, + slope_min=0.5, + now=now, + ) + assert ret == AUTO_START_STOP_ACTION_NOTHING + assert algo.last_switch_date == last_now + + # 5. turn_off much later (29 min + 1 min) + now = now + timedelta(minutes=1) + algo._accumulated_error = -100 + ret = algo.calculate_action( + hvac_mode=HVACMode.HEAT, + saved_hvac_mode=HVACMode.OFF, + target_temp=17, + current_temp=21, + slope_min=0.5, + now=now, + ) + assert ret == AUTO_START_STOP_ACTION_OFF + assert algo.last_switch_date == now async def test_auto_start_stop_algo_medium_cool_off(hass: HomeAssistant):