diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index 49250a0..61faa85 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -198,7 +198,6 @@ class BaseThermostat(ClimateEntity, RestoreEntity): self._saved_hvac_mode = None self._window_call_cancel = None self._motion_call_cancel = None - self._cur_ext_temp = None self._cur_temp = None self._ac_mode = None self._last_ext_temperature_mesure = None diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index e6a036c..3da815b 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -219,6 +219,33 @@ DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1 ATTR_TOTAL_ENERGY = "total_energy" ATTR_MEAN_POWER_CYCLE = "mean_cycle_power" +class RegulationParamLight: + """ Light parameters for regulation""" + kp:float = 0.2 + ki:float = 0.05 + k_ext:float = 0.1 + offset_max:float = 2 + stabilization_threshold:float = 0.1 + accumulated_error_threshold:float = 20 + + +class RegulationParamMedium: + """ Medium parameters for regulation""" + kp:float = 0.5 + ki:float = 0.1 + k_ext:float = 0.1 + offset_max:float = 3 + stabilization_threshold:float = 0.1 + accumulated_error_threshold:float = 30 + +class RegulationParamStrong: + """ Strong parameters for regulation""" + kp:float = 0.6 + ki:float = 0.2 + k_ext:float = 0.2 + offset_max:float = 4 + stabilization_threshold:float = 0.1 + accumulated_error_threshold:float = 40 class EventType(Enum): """The event type that can be sent""" diff --git a/custom_components/versatile_thermostat/pi_algorithm.py b/custom_components/versatile_thermostat/pi_algorithm.py index 60f0d30..0f2505f 100644 --- a/custom_components/versatile_thermostat/pi_algorithm.py +++ b/custom_components/versatile_thermostat/pi_algorithm.py @@ -16,23 +16,27 @@ class PITemperatureRegulator: - call set_target_temp when the target temperature change. """ - def __init__(self, target_temp, kp, ki, k_ext, offset_max, stabilization_threshold, accumulated_error_threshold): - self.target_temp = target_temp - self.kp = kp # proportionnel gain - self.ki = ki # integral gain - self.k_ext = k_ext # exterior gain - self.offset_max = offset_max - self.stabilization_threshold = stabilization_threshold - self.accumulated_error = 0 - self.accumulated_error_threshold = accumulated_error_threshold + def __init__(self, target_temp: float, kp: float, ki: float, k_ext: float, offset_max: float, stabilization_threshold: float, accumulated_error_threshold: float): + self.target_temp:float = target_temp + self.kp:float = kp # proportionnel gain + self.ki:float = ki # integral gain + self.k_ext:float = k_ext # exterior gain + self.offset_max:float = offset_max + self.stabilization_threshold:float = stabilization_threshold + self.accumulated_error:float = 0 + self.accumulated_error_threshold:float = accumulated_error_threshold def set_target_temp(self, target_temp): """ Set the new target_temp""" self.target_temp = target_temp self.accumulated_error = 0 - def calculate_regulated_temperature(self, internal_temp, external_temp): # pylint: disable=unused-argument + def calculate_regulated_temperature(self, internal_temp: float, external_temp:float): # pylint: disable=unused-argument """ Calculate a new target_temp given some temperature""" + if internal_temp is None or external_temp is None: + _LOGGER.warning("Internal_temp or external_temp are not set. Regulation will be suspended") + return self.target_temp + # Calculate the error factor (P) error = self.target_temp - internal_temp diff --git a/custom_components/versatile_thermostat/thermostat_climate.py b/custom_components/versatile_thermostat/thermostat_climate.py index 38a1f0d..7923b57 100644 --- a/custom_components/versatile_thermostat/thermostat_climate.py +++ b/custom_components/versatile_thermostat/thermostat_climate.py @@ -3,14 +3,29 @@ import logging from datetime import timedelta -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.event import async_track_state_change_event, async_track_time_interval from homeassistant.components.climate import HVACAction, HVACMode from .base_thermostat import BaseThermostat +from .pi_algorithm import PITemperatureRegulator -from .const import CONF_CLIMATE, CONF_CLIMATE_2, CONF_CLIMATE_3, CONF_CLIMATE_4, overrides +from .const import ( + overrides, + CONF_CLIMATE, + CONF_CLIMATE_2, + CONF_CLIMATE_3, + CONF_CLIMATE_4, + CONF_AUTO_REGULATION_MODE, + CONF_AUTO_REGULATION_NONE, + CONF_AUTO_REGULATION_LIGHT, + CONF_AUTO_REGULATION_MEDIUM, + CONF_AUTO_REGULATION_STRONG, + RegulationParamLight, + RegulationParamMedium, + RegulationParamStrong +) from .underlyings import UnderlyingClimate @@ -18,6 +33,9 @@ _LOGGER = logging.getLogger(__name__) class ThermostatOverClimate(BaseThermostat): """Representation of a base class for a Versatile Thermostat over a climate""" + _regulation_mode:str = None + _regulation_algo = None + _regulated_target_temp: float = None _entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union(frozenset( { @@ -25,10 +43,11 @@ class ThermostatOverClimate(BaseThermostat): "underlying_climate_2", "underlying_climate_3" })) - # Useless for now - # def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: - # """Initialize the thermostat over switch.""" - # super().__init__(hass, unique_id, name, entry_infos) + def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None: + """Initialize the thermostat over switch.""" + # super.__init__ calls post_init at the end. So it must be called after regulation initialization + super().__init__(hass, unique_id, name, entry_infos) + self._regulated_target_temp = self.target_temperature @property def is_over_climate(self) -> bool: @@ -60,10 +79,22 @@ class ThermostatOverClimate(BaseThermostat): """Set the target temperature and the target temperature of underlying climate if any""" await super()._async_internal_set_temperature(temperature) - for under in self._underlyings: - await under.set_temperature( - temperature, self._attr_max_temp, self._attr_min_temp - ) + self._regulation_algo.set_target_temp(self.target_temperature) + await self._send_regulated_temperature() + + async def _send_regulated_temperature(self): + """ Sends the regulated temperature to all underlying """ + new_regulated_temp = self._regulation_algo.calculate_regulated_temperature(self.current_temperature, self._cur_ext_temp) + if new_regulated_temp != self._regulated_target_temp: + _LOGGER.info("%s - Regulated temp have changed to %.1f. Resend it to underlyings", self, new_regulated_temp) + self._regulated_target_temp = new_regulated_temp + + for under in self._underlyings: + await under.set_temperature( + self.regulated_target_temp, self._attr_max_temp, self._attr_min_temp + ) + else: + _LOGGER.debug("%s - No change on regulated temperature (%.1f)", self, self._regulated_target_temp) @overrides def post_init(self, entry_infos): @@ -85,7 +116,39 @@ class ThermostatOverClimate(BaseThermostat): ) ) + self._regulation_mode = entry_infos.get(CONF_AUTO_REGULATION_MODE) if entry_infos.get(CONF_AUTO_REGULATION_MODE) is not None else CONF_AUTO_REGULATION_NONE + if self._regulation_mode == CONF_AUTO_REGULATION_LIGHT: + self._regulation_algo = PITemperatureRegulator( + self.target_temperature, + RegulationParamLight.kp, + RegulationParamLight.ki, + RegulationParamLight.k_ext, + RegulationParamLight.offset_max, + RegulationParamLight.stabilization_threshold, + RegulationParamLight.accumulated_error_threshold) + elif self._regulation_mode == CONF_AUTO_REGULATION_MEDIUM: + self._regulation_algo = PITemperatureRegulator( + self.target_temperature, + RegulationParamMedium.kp, + RegulationParamMedium.ki, + RegulationParamMedium.k_ext, + RegulationParamMedium.offset_max, + RegulationParamMedium.stabilization_threshold, + RegulationParamMedium.accumulated_error_threshold) + elif self._regulation_mode == CONF_AUTO_REGULATION_STRONG: + self._regulation_algo = PITemperatureRegulator( + self.target_temperature, + RegulationParamStrong.kp, + RegulationParamStrong.ki, + RegulationParamStrong.k_ext, + RegulationParamStrong.offset_max, + RegulationParamStrong.stabilization_threshold, + RegulationParamStrong.accumulated_error_threshold) + else: + # A default empty algo (which does nothing) + self._regulation_algo = PITemperatureRegulator( + self.target_temperature, 0, 0, 0, 0, 0.1, 0) @overrides async def async_added_to_hass(self): @@ -132,6 +195,9 @@ class ThermostatOverClimate(BaseThermostat): self._underlyings[3].entity_id if len(self._underlyings) > 3 else None ) + if self.is_regulated: + self._attr_extra_state_attributes["regulated_target_temp"] = self._regulated_target_temp + self.async_write_ha_state() _LOGGER.debug( "%s - Calling update_custom_attributes: %s", @@ -352,6 +418,30 @@ class ThermostatOverClimate(BaseThermostat): await end_climate_changed(changes) + @overrides + async def async_control_heating(self, force=False, _=None): + """The main function used to run the calculation at each cycle""" + ret = await super().async_control_heating(force, _) + + await self._send_regulated_temperature() + + return ret + + @property + def regulation_mode(self): + """ Get the regulation mode """ + return self._regulation_mode + + @property + def regulated_target_temp(self): + """ Get the regulated target temperature """ + return self._regulated_target_temp + + @property + def is_regulated(self): + """ Check if the ThermostatOverClimate is regulated """ + return self.regulation_mode != CONF_AUTO_REGULATION_NONE + @property def hvac_modes(self): """List of available operation modes.""" diff --git a/tests/const.py b/tests/const.py index c935bb0..b1762a7 100644 --- a/tests/const.py +++ b/tests/const.py @@ -50,6 +50,8 @@ from custom_components.versatile_thermostat.const import ( CONF_PRESENCE_SENSOR, PRESET_AWAY_SUFFIX, CONF_CLIMATE, + CONF_AUTO_REGULATION_MODE, + CONF_AUTO_REGULATION_MEDIUM ) MOCK_TH_OVER_SWITCH_USER_CONFIG = { CONF_NAME: "TheOverSwitchMockName", @@ -89,14 +91,14 @@ MOCK_TH_OVER_CLIMATE_USER_CONFIG = { CONF_CYCLE_MIN: 5, CONF_TEMP_MIN: 15, CONF_TEMP_MAX: 30, - CONF_DEVICE_POWER: 1, + CONF_DEVICE_POWER: 1 # Keep default values which are False } MOCK_TH_OVER_SWITCH_TYPE_CONFIG = { CONF_HEATER: "switch.mock_switch", CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI, - CONF_AC_MODE: False, + CONF_AC_MODE: False } MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG = { @@ -122,6 +124,7 @@ MOCK_TH_OVER_SWITCH_TPI_CONFIG = { MOCK_TH_OVER_CLIMATE_TYPE_CONFIG = { CONF_CLIMATE: "climate.mock_climate", CONF_AC_MODE: False, + CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_MEDIUM } MOCK_PRESETS_CONFIG = { diff --git a/tests/test_pi.py b/tests/test_pi.py index a3d1e61..a4afcaf 100644 --- a/tests/test_pi.py +++ b/tests/test_pi.py @@ -43,7 +43,7 @@ def test_pi_algorithm_basics(): assert the_algo.calculate_regulated_temperature(20, 20) == 20.0 # = -def test_pi_algorithm_very_light(): +def test_pi_algorithm_light(): """ Test the PI algorithm """ the_algo = PITemperatureRegulator(target_temp=20, kp=0.2, ki=0.05, k_ext=0.1, offset_max=2, stabilization_threshold=0.1, accumulated_error_threshold=20)