From f547647e24ed01741b242b6651fe144a25efdbc1 Mon Sep 17 00:00:00 2001 From: Jean-Marc Collin Date: Fri, 1 Nov 2024 10:24:35 +0000 Subject: [PATCH] With enable + tests + hysteresis in calculation --- .../auto_start_stop_algorithm.py | 14 +- .../versatile_thermostat/base_thermostat.py | 2 + .../versatile_thermostat/switch.py | 2 +- .../thermostat_climate.py | 22 ++- tests/test_auto_start_stop.py | 187 ++++++++++++++++-- 5 files changed, 198 insertions(+), 29 deletions(-) diff --git a/custom_components/versatile_thermostat/auto_start_stop_algorithm.py b/custom_components/versatile_thermostat/auto_start_stop_algorithm.py index d52ac8b..d1203d9 100644 --- a/custom_components/versatile_thermostat/auto_start_stop_algorithm.py +++ b/custom_components/versatile_thermostat/auto_start_stop_algorithm.py @@ -3,7 +3,7 @@ """ import logging -from datetime import datetime, timedelta +from datetime import datetime from typing import Literal from homeassistant.components.climate import HVACMode @@ -13,7 +13,6 @@ from .const import ( AUTO_START_STOP_LEVEL_FAST, AUTO_START_STOP_LEVEL_MEDIUM, AUTO_START_STOP_LEVEL_SLOW, - CONF_AUTO_START_STOP_LEVELS, TYPE_AUTO_START_STOP_LEVELS, ) @@ -31,6 +30,9 @@ DT_MIN = { # 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_SLOW: 10, # 10 cycle above 1° or 5 cycle above 2°, ... @@ -146,7 +148,7 @@ class AutoStartStopDetectionAlgorithm: if hvac_mode == HVACMode.HEAT: if ( self._accumulated_error <= -self._error_threshold - and temp_at_dt >= target_temp + and temp_at_dt >= target_temp + TEMP_HYSTERESIS ): _LOGGER.info( "%s - We need to stop, there is no need for heating for a long time.", @@ -160,7 +162,7 @@ class AutoStartStopDetectionAlgorithm: if hvac_mode == HVACMode.COOL: if ( self._accumulated_error >= self._error_threshold - and temp_at_dt <= target_temp + and temp_at_dt <= target_temp - TEMP_HYSTERESIS ): _LOGGER.info( "%s - We need to stop, there is no need for cooling for a long time.", @@ -176,7 +178,7 @@ class AutoStartStopDetectionAlgorithm: # check to turn on if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.HEAT: - if temp_at_dt <= target_temp: + if temp_at_dt <= target_temp - TEMP_HYSTERESIS: _LOGGER.info( "%s - We need to start, because it will be time to heat", self, @@ -190,7 +192,7 @@ class AutoStartStopDetectionAlgorithm: return AUTO_START_STOP_ACTION_NOTHING if hvac_mode == HVACMode.OFF and saved_hvac_mode == HVACMode.COOL: - if temp_at_dt >= target_temp: + if temp_at_dt >= target_temp + TEMP_HYSTERESIS: _LOGGER.info( "%s - We need to start, because it will be time to cool", self, diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 2d59d51..a9b06ad 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -199,6 +199,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "is_device_active", "target_temperature_step", "is_used_by_central_boiler", + "temperature_slope" } ) ) @@ -2633,6 +2634,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): "is_device_active": self.is_device_active, "ema_temp": self._ema_temp, "is_used_by_central_boiler": self.is_used_by_central_boiler, + "temperature_slope": round(self.last_temperature_slope or 0, 3), } @callback diff --git a/custom_components/versatile_thermostat/switch.py b/custom_components/versatile_thermostat/switch.py index 204304f..e1b7218 100644 --- a/custom_components/versatile_thermostat/switch.py +++ b/custom_components/versatile_thermostat/switch.py @@ -48,7 +48,7 @@ class AutoStartStopEnable(VersatileThermostatBaseEntity, SwitchEntity, RestoreEn ): super().__init__(hass, unique_id, name) self._attr_name = "Enable auto start/stop" - self._attr_unique_id = f"{self._device_name}_enbale_auto_start_stop" + self._attr_unique_id = f"{self._device_name}_enable_auto_start_stop" self._default_value = ( entry_infos.data.get(CONF_AUTO_START_STOP_LEVEL) != AUTO_START_STOP_LEVEL_NONE diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index a8953ad..fc915c6 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -49,6 +49,7 @@ from .const import ( RegulationParamStrong, AUTO_FAN_DTEMP_THRESHOLD, AUTO_FAN_DEACTIVATED_MODES, + CONF_USE_AUTO_START_STOP_FEATURE, CONF_AUTO_START_STOP_LEVEL, AUTO_START_STOP_LEVEL_NONE, TYPE_AUTO_START_STOP_LEVELS, @@ -176,9 +177,14 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): 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 - ) + use_auto_start_stop = config_entry.get(CONF_USE_AUTO_START_STOP_FEATURE, False) + if use_auto_start_stop: + self._auto_start_stop_level = config_entry.get( + CONF_AUTO_START_STOP_LEVEL, AUTO_START_STOP_LEVEL_NONE + ) + else: + self._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 @@ -914,7 +920,7 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): # Check if we need to auto start/stop the Vtherm if self.auto_start_stop_enable: slope = ( - self._window_auto_algo.last_slope or 0 + self.last_temperature_slope or 0 ) / 60 # to have the slope in °/min action = self._auto_start_stop_algo.calculate_action( self.hvac_mode, @@ -937,13 +943,13 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): event_type=EventType.AUTO_START_STOP_EVENT, data={ "type": "stop", - "name:": self.name, + "name": self.name, "cause": "Auto stop conditions reached", "hvac_mode": self.hvac_mode, "saved_hvac_mode": self._saved_hvac_mode, "target_temperature": self.target_temperature, "current_temperature": self.current_temperature, - "temperature_slope": slope, + "temperature_slope": round(slope, 3), }, ) @@ -960,13 +966,13 @@ class ThermostatOverClimate(BaseThermostat[UnderlyingClimate]): event_type=EventType.AUTO_START_STOP_EVENT, data={ "type": "start", - "name:": self.name, + "name": self.name, "cause": "Auto start conditions reached", "hvac_mode": self.hvac_mode, "saved_hvac_mode": self._saved_hvac_mode, "target_temperature": self.target_temperature, "current_temperature": self.current_temperature, - "temperature_slope": slope, + "temperature_slope": round(slope, 3), }, ) diff --git a/tests/test_auto_start_stop.py b/tests/test_auto_start_stop.py index bb356fb..b4f1369 100644 --- a/tests/test_auto_start_stop.py +++ b/tests/test_auto_start_stop.py @@ -6,6 +6,7 @@ import logging from unittest.mock import patch, call from homeassistant.components.climate import HVACMode +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from custom_components.versatile_thermostat.thermostat_climate import ( ThermostatOverClimate, @@ -14,7 +15,6 @@ from custom_components.versatile_thermostat.auto_start_stop_algorithm import ( 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 @@ -72,7 +72,7 @@ async def test_auto_start_stop_algo_slow_heat_off(hass: HomeAssistant): 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) + now = now + timedelta(seconds=11) ret = algo.calculate_action( hvac_mode=HVACMode.HEAT, saved_hvac_mode=HVACMode.OFF, @@ -225,6 +225,7 @@ async def test_auto_start_stop_none_vtherm( CONF_USE_WINDOW_FEATURE: False, CONF_USE_MOTION_FEATURE: False, CONF_USE_POWER_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: False, CONF_USE_PRESENCE_FEATURE: True, CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", CONF_CLIMATE: "climate.mock_climate", @@ -267,6 +268,12 @@ async def test_auto_start_stop_none_vtherm( # 1. Vtherm auto-start/stop should be in NONE mode assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_NONE + # 2. We should not find any switch Enable entity + assert ( + search_entity(hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN) + is None + ) + @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize("expected_lingering_timers", [True]) @@ -310,6 +317,7 @@ async def test_auto_start_stop_medium_heat_vtherm( CONF_USE_WINDOW_FEATURE: False, CONF_USE_MOTION_FEATURE: False, CONF_USE_POWER_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True, CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", CONF_CLIMATE: "climate.mock_climate", @@ -350,8 +358,13 @@ async def test_auto_start_stop_medium_heat_vtherm( assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 15 - # 1. Vtherm auto-start/stop should be in MEDIUM mode + # 1. Vtherm auto-start/stop should be in MEDIUM mode and an enable entity should exists assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_MEDIUM + enable_entity = search_entity( + hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN + ) + assert enable_entity is not None + assert enable_entity.state == STATE_ON tz = get_tz(hass) # pylint: disable=invalid-name now: datetime = datetime.now(tz=tz) @@ -369,6 +382,8 @@ async def test_auto_start_stop_medium_heat_vtherm( # 3. Set current temperature to 19 5 min later now = now + timedelta(minutes=5) + # reset accumulated error (only for testing) + vtherm._auto_start_stop_algo._accumulated_error = 0 with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event: @@ -421,12 +436,13 @@ async def test_auto_start_stop_medium_heat_vtherm( event_type=EventType.AUTO_START_STOP_EVENT, data={ "type": "stop", + "name": "overClimate", "cause": "Auto stop conditions reached", "hvac_mode": HVACMode.OFF, "saved_hvac_mode": HVACMode.HEAT, "target_temperature": 19.0, "current_temperature": 21.0, - "temperature_slope": 10.03, + "temperature_slope": 0.167, }, ) ] @@ -480,12 +496,13 @@ async def test_auto_start_stop_medium_heat_vtherm( event_type=EventType.AUTO_START_STOP_EVENT, data={ "type": "start", + "name": "overClimate", "cause": "Auto start conditions reached", "hvac_mode": HVACMode.HEAT, "saved_hvac_mode": HVACMode.HEAT, # saved don't change "target_temperature": 19.0, "current_temperature": 18.0, - "temperature_slope": -2.06, + "temperature_slope": -0.034, }, ) ] @@ -545,6 +562,7 @@ async def test_auto_start_stop_fast_ac_vtherm( CONF_USE_WINDOW_FEATURE: False, CONF_USE_MOTION_FEATURE: False, CONF_USE_POWER_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True, CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", CONF_CLIMATE: "climate.mock_climate", @@ -599,11 +617,13 @@ async def test_auto_start_stop_fast_ac_vtherm( await hass.async_block_till_done() assert vtherm.target_temperature == 25.0 - # VTherm should be heating + # VTherm should be cooling assert vtherm.hvac_mode == HVACMode.COOL # 3. Set current temperature to 19 5 min later now = now + timedelta(minutes=5) + # reset accumulated error for test + vtherm._auto_start_stop_algo._accumulated_error = 0 with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event: @@ -611,7 +631,7 @@ async def test_auto_start_stop_fast_ac_vtherm( await send_temperature_change_event(vtherm, 25, now, True) await hass.async_block_till_done() - # VTherm should still be heating + # VTherm should still be cooling assert vtherm.hvac_mode == HVACMode.COOL assert mock_send_event.call_count == 0 assert ( @@ -641,12 +661,13 @@ async def test_auto_start_stop_fast_ac_vtherm( event_type=EventType.AUTO_START_STOP_EVENT, data={ "type": "stop", + "name": "overClimate", "cause": "Auto stop conditions reached", "hvac_mode": HVACMode.OFF, "saved_hvac_mode": HVACMode.COOL, "target_temperature": 25.0, "current_temperature": 23.0, - "temperature_slope": -16.8, + "temperature_slope": -0.28, }, ) ] @@ -664,18 +685,18 @@ async def test_auto_start_stop_fast_ac_vtherm( ) # 5. Set temperature to over the target, but slope is too low -> no change - now = now + timedelta(minutes=20) + now = now + timedelta(minutes=30) with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event: vtherm._set_now(now) - await send_temperature_change_event(vtherm, 26, now, True) + await send_temperature_change_event(vtherm, 25.5, now, True) await hass.async_block_till_done() # accumulated_error = 2/2 + target - current = -1 x 20 min / 2 capped to 2 assert vtherm._auto_start_stop_algo.accumulated_error == -2 - # VTherm should have been stopped + # VTherm should stay stopped assert vtherm.hvac_mode == HVACMode.OFF # a message should have been sent assert mock_send_event.call_count == 0 @@ -702,12 +723,13 @@ async def test_auto_start_stop_fast_ac_vtherm( event_type=EventType.AUTO_START_STOP_EVENT, data={ "type": "start", + "name": "overClimate", "cause": "Auto start conditions reached", "hvac_mode": HVACMode.COOL, "saved_hvac_mode": HVACMode.COOL, # saved don't change "target_temperature": 25.0, "current_temperature": 26.5, - "temperature_slope": 5.74, + "temperature_slope": 0.112, }, ) ] @@ -767,6 +789,7 @@ async def test_auto_start_stop_medium_heat_vtherm_preset_change( CONF_USE_WINDOW_FEATURE: False, CONF_USE_MOTION_FEATURE: False, CONF_USE_POWER_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: True, CONF_USE_PRESENCE_FEATURE: True, CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", CONF_CLIMATE: "climate.mock_climate", @@ -826,6 +849,7 @@ async def test_auto_start_stop_medium_heat_vtherm_preset_change( # 3. Set current temperature to 21 5 min later to auto-stop now = now + timedelta(minutes=5) + vtherm._auto_start_stop_algo._accumulated_error = 0 with patch( "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" ) as mock_send_event: @@ -846,12 +870,13 @@ async def test_auto_start_stop_medium_heat_vtherm_preset_change( event_type=EventType.AUTO_START_STOP_EVENT, data={ "type": "stop", + "name": "overClimate", "cause": "Auto stop conditions reached", "hvac_mode": HVACMode.OFF, "saved_hvac_mode": HVACMode.HEAT, "target_temperature": 17.0, "current_temperature": 19.0, - "temperature_slope": 18.0, + "temperature_slope": 0.3, }, ) ] @@ -900,12 +925,13 @@ async def test_auto_start_stop_medium_heat_vtherm_preset_change( event_type=EventType.AUTO_START_STOP_EVENT, data={ "type": "start", + "name": "overClimate", "cause": "Auto start conditions reached", "hvac_mode": HVACMode.HEAT, "saved_hvac_mode": HVACMode.HEAT, # saved don't change "target_temperature": 21.0, "current_temperature": 17.0, - "temperature_slope": -5.19, + "temperature_slope": -0.087, }, ) ] @@ -921,3 +947,136 @@ async def test_auto_start_stop_medium_heat_vtherm_preset_change( ) ] ) + + +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize("expected_lingering_timers", [True]) +async def test_auto_start_stop_medium_heat_vtherm_preset_change_enable_false( + hass: HomeAssistant, skip_hass_states_is_state +): + """Test than auto-start/stop restart a VTherm stopped upon preset_change (in fast mode)""" + + # vtherm_api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass) + + # The temperatures to set + temps = { + "frost": 7.0, + "eco": 17.0, + "comfort": 19.0, + "boost": 21.0, + "eco_ac": 27.0, + "comfort_ac": 25.0, + "boost_ac": 23.0, + "frost_away": 7.1, + "eco_away": 17.1, + "comfort_away": 19.1, + "boost_away": 21.1, + "eco_ac_away": 27.1, + "comfort_ac_away": 25.1, + "boost_ac_away": 23.1, + } + + config_entry = MockConfigEntry( + domain=DOMAIN, + title="TheOverClimateMockName", + unique_id="overClimateUniqueId", + data={ + CONF_NAME: "overClimate", + CONF_TEMP_SENSOR: "sensor.mock_temp_sensor", + CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE, + CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor", + CONF_CYCLE_MIN: 5, + CONF_TEMP_MIN: 15, + CONF_TEMP_MAX: 30, + CONF_USE_WINDOW_FEATURE: False, + CONF_USE_MOTION_FEATURE: False, + CONF_USE_POWER_FEATURE: False, + CONF_USE_AUTO_START_STOP_FEATURE: True, + CONF_USE_PRESENCE_FEATURE: True, + CONF_PRESENCE_SENSOR: "binary_sensor.presence_sensor", + CONF_CLIMATE: "climate.mock_climate", + CONF_MINIMAL_ACTIVATION_DELAY: 30, + CONF_SECURITY_DELAY_MIN: 5, + CONF_SECURITY_MIN_ON_PERCENT: 0.3, + CONF_AUTO_FAN_MODE: CONF_AUTO_FAN_TURBO, + CONF_AC_MODE: True, + CONF_AUTO_START_STOP_LEVEL: AUTO_START_STOP_LEVEL_FAST, + }, + ) + + fake_underlying_climate = MockClimate( + hass=hass, + unique_id="mock_climate", + name="mock_climate", + hvac_modes=[HVACMode.OFF, HVACMode.COOL, HVACMode.HEAT], + ) + + with patch( + "custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate", + return_value=fake_underlying_climate, + ): + vtherm: ThermostatOverClimate = await create_thermostat( + hass, config_entry, "climate.overclimate" + ) + + assert vtherm is not None + + # 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_FAST + ) + + assert vtherm._attr_extra_state_attributes["auto_start_stop_dtmin"] == 7 + + # 1. Vtherm auto-start/stop should be in FAST mode and enable should be on + assert vtherm.auto_start_stop_level == AUTO_START_STOP_LEVEL_FAST + enable_entity = search_entity( + hass, "switch.overclimate_enable_auto_start_stop", SWITCH_DOMAIN + ) + assert enable_entity is not None + assert enable_entity.state == STATE_ON + + assert vtherm._attr_extra_state_attributes.get("auto_start_stop_enable") is True + + # 2. set enable to false + enable_entity.turn_off() + await hass.async_block_till_done() + assert enable_entity.state == STATE_OFF + assert vtherm._attr_extra_state_attributes.get("auto_start_stop_enable") is False + + tz = get_tz(hass) # pylint: disable=invalid-name + now: datetime = datetime.now(tz=tz) + + # 3. Set mode to Heat and preset to Comfort + await send_presence_change_event(vtherm, True, False, now) + await send_temperature_change_event(vtherm, 16, now, True) + await vtherm.async_set_hvac_mode(HVACMode.HEAT) + await vtherm.async_set_preset_mode(PRESET_ECO) + await hass.async_block_till_done() + + assert vtherm.target_temperature == 17.0 + # VTherm should be heating + assert vtherm.hvac_mode == HVACMode.HEAT + + # 3. Set current temperature to 21 5 min later to auto-stop + now = now + timedelta(minutes=5) + vtherm._auto_start_stop_algo._accumulated_error = 0 + with patch( + "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" + ) as mock_send_event: + vtherm._set_now(now) + await send_temperature_change_event(vtherm, 19, now, True) + await hass.async_block_till_done() + + # VTherm should not have been stopped cause enable is false + assert vtherm.hvac_mode == HVACMode.HEAT + + # Not calculated cause enable = false + assert vtherm._auto_start_stop_algo.accumulated_error == 0 + + # a message should have been sent + assert mock_send_event.call_count == 0