diff --git a/custom_components/versatile_thermostat/climate.py b/custom_components/versatile_thermostat/climate.py index 4f4a4e3..2b70c58 100644 --- a/custom_components/versatile_thermostat/climate.py +++ b/custom_components/versatile_thermostat/climate.py @@ -1,4 +1,5 @@ import math +from datetime import timedelta from homeassistant.core import HomeAssistant, callback, CoreState from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity @@ -62,9 +63,14 @@ from .const import ( CONF_WINDOW_SENSOR, CONF_DEVICE_POWER, CONF_PRESETS, + CONF_CYCLE_MIN, + CONF_PROP_FUNCTION, + CONF_PROP_BIAS, SUPPORT_FLAGS, ) +from .prop_algorithm import PropAlgorithm + async def async_setup_entry( hass: HomeAssistant, @@ -79,6 +85,9 @@ async def async_setup_entry( unique_id = entry.entry_id name = entry.data.get(CONF_NAME) heater_entity_id = entry.data.get(CONF_HEATER) + cycle_min = entry.data.get(CONF_CYCLE_MIN) + proportional_function = entry.data.get(CONF_PROP_FUNCTION) + proportional_bias = entry.data.get(CONF_PROP_BIAS) temp_sensor_entity_id = entry.data.get(CONF_TEMP_SENSOR) power_sensor_entity_id = entry.data.get(CONF_POWER_SENSOR) max_power_sensor_entity_id = entry.data.get(CONF_MAX_POWER_SENSOR) @@ -100,6 +109,9 @@ async def async_setup_entry( unique_id, name, heater_entity_id, + cycle_min, + proportional_function, + proportional_bias, temp_sensor_entity_id, power_sensor_entity_id, max_power_sensor_entity_id, @@ -118,12 +130,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): _name: str _heater_entity_id: str + _prop_algorithm: PropAlgorithm def __init__( self, unique_id, name, heater_entity_id, + cycle_min, + proportional_function, + proportional_bias, temp_sensor_entity_id, power_sensor_entity_id, max_power_sensor_entity_id, @@ -139,6 +155,9 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._unique_id = unique_id self._name = name self._heater_entity_id = heater_entity_id + self._cycle_min = cycle_min + self._proportional_function = proportional_function + self._proportional_bias = proportional_bias self._temp_sensor_entity_id = temp_sensor_entity_id self._power_sensor_entity_id = power_sensor_entity_id self._max_power_sensor_entity_id = max_power_sensor_entity_id @@ -189,6 +208,11 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._swing_mode = None self._cur_temp = None + # Initiate the ProportionalAlgorithm + self._prop_algorithm = PropAlgorithm( + self._proportional_function, self._proportional_bias, self._cycle_min + ) + _LOGGER.debug( "%s - Creation of a new VersatileThermostat entity: unique_id=%s heater_entity_id=%s", self, @@ -268,10 +292,10 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): _LOGGER.info("%s - Set hvac mode: %s", self, hvac_mode) if hvac_mode == HVAC_MODE_HEAT: self._hvac_mode = HVAC_MODE_HEAT - # TODO await self._async_control_heating(force=True) + await self._async_control_heating() elif hvac_mode == HVAC_MODE_COOL: self._hvac_mode = HVAC_MODE_COOL - # TODO await self._async_control_heating(force=True) + await self._async_control_heating() elif hvac_mode == HVAC_MODE_OFF: self._hvac_mode = HVAC_MODE_OFF # TODO self.prop_current_phase = PROP_PHASE_NONE @@ -296,19 +320,20 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): if preset_mode == PRESET_NONE: self._attr_preset_mode = PRESET_NONE self._target_temp = self._saved_target_temp - # TODO await self._async_control_heating(force=True) + await self._async_control_heating() elif preset_mode == PRESET_ACTIVITY: self._attr_preset_mode = PRESET_ACTIVITY # TODO self._target_temp = self._presets[self.no_motion_mode] - # await self._async_control_heating(force=True) + await self._async_control_heating() else: if self._attr_preset_mode == PRESET_NONE: self._saved_target_temp = self._target_temp self._attr_preset_mode = preset_mode self._target_temp = self._presets[preset_mode] - # TODO await self._async_control_heating(force=True) + await self._async_control_heating() self.async_write_ha_state() + self._prop_algorithm.calculate(self._target_temp, self._cur_temp) async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" @@ -341,7 +366,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): return self._target_temp = temperature self._attr_preset_mode = PRESET_NONE - # TODO await self._async_control_heating(force=True) + self._prop_algorithm.calculate(self._target_temp, self._cur_temp) self.async_write_ha_state() @callback @@ -387,12 +412,16 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self._async_motion_changed, ) ) - # if self._keep_alive: - # self.async_on_remove( - # async_track_time_interval( - # self.hass, self._async_control_heating, self._keep_alive - # ) - # ) + + if self._cycle_min: + self.async_on_remove( + async_track_time_interval( + self.hass, + self._async_control_heating, + interval=timedelta(minutes=self._cycle_min), + ) + ) + if self._power_sensor_entity_id: self.async_on_remove( async_track_state_change_event( @@ -431,7 +460,7 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): self, float(temperature_state.state), ) - # TODO self._async_update_temp(temperature_state) + self._async_update_temp(temperature_state) need_write_state = True switch_state = self.hass.states.get(self._heater_entity_id) @@ -474,7 +503,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): if need_write_state: self.async_write_ha_state() - # TODO self.hass.create_task(self._async_control_heating()) + self._prop_algorithm.calculate(self._target_temp, self._cur_temp) + self.hass.create_task(self._async_control_heating()) if self.hass.state == CoreState.running: _async_startup_internal() @@ -551,7 +581,8 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): return self._async_update_temp(new_state) - # TODO await self._async_control_heating() + self._prop_algorithm.calculate(self._target_temp, self._cur_temp) + # TODO Not sure we need this - await self._async_control_heating() self.async_write_ha_state() @callback @@ -689,3 +720,15 @@ class VersatileThermostat(ClimateEntity, RestoreEntity): except ValueError as ex: _LOGGER.error("Unable to update current_power from sensor: %s", ex) + + async def _async_control_heating(self, time=None): + """The main function used to run the calculation at each cycle""" + on_time_sec = self._prop_algorithm.on_time_sec + off_time_sec = self._prop_algorithm.off_time_sec + _LOGGER.info( + "%s - Running new cycle at %s. on_time_sec=%f, off_time_sec=%f", + self, + time, + on_time_sec, + off_time_sec, + ) diff --git a/custom_components/versatile_thermostat/config_flow.py b/custom_components/versatile_thermostat/config_flow.py index ba864e4..5ae7589 100644 --- a/custom_components/versatile_thermostat/config_flow.py +++ b/custom_components/versatile_thermostat/config_flow.py @@ -28,8 +28,14 @@ from .const import ( CONF_WINDOW_SENSOR, CONF_MOTION_SENSOR, CONF_DEVICE_POWER, + CONF_CYCLE_MIN, ALL_CONF, CONF_PRESETS, + CONF_FUNCTIONS, + CONF_PROP_FUNCTION, + CONF_PROP_BIAS, + PROPORTIONAL_FUNCTION_ATAN, + PROPORTIONAL_FUNCTION_LINEAR, ) # from .climate import VersatileThermostat @@ -40,6 +46,11 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, vol.Required(CONF_HEATER): cv.string, + vol.Required(CONF_CYCLE_MIN, default=5): cv.positive_int, + vol.Required(CONF_PROP_FUNCTION, default=PROPORTIONAL_FUNCTION_LINEAR): vol.In( + [PROPORTIONAL_FUNCTION_LINEAR, PROPORTIONAL_FUNCTION_ATAN] + ), + vol.Required(CONF_PROP_BIAS, default=0.25): vol.Coerce(float), vol.Required(CONF_TEMP_SENSOR): cv.string, vol.Optional(CONF_POWER_SENSOR): cv.string, vol.Optional(CONF_MAX_POWER_SENSOR): cv.string, diff --git a/custom_components/versatile_thermostat/const.py b/custom_components/versatile_thermostat/const.py index 1b73094..9b50b3d 100644 --- a/custom_components/versatile_thermostat/const.py +++ b/custom_components/versatile_thermostat/const.py @@ -10,6 +10,8 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, ) +from .prop_algorithm import PROPORTIONAL_FUNCTION_ATAN, PROPORTIONAL_FUNCTION_LINEAR + PRESET_POWER = "power" DOMAIN = "versatile_thermostat" @@ -21,6 +23,9 @@ CONF_MAX_POWER_SENSOR = "max_power_sensor_entity_id" CONF_WINDOW_SENSOR = "window_sensor_entity_id" CONF_MOTION_SENSOR = "motion_sensor_entity_id" CONF_DEVICE_POWER = "device_power" +CONF_CYCLE_MIN = "cycle_min" +CONF_PROP_FUNCTION = "proportional_function" +CONF_PROP_BIAS = "proportional_bias" ALL_CONF = [ CONF_NAME, @@ -31,6 +36,9 @@ ALL_CONF = [ CONF_WINDOW_SENSOR, CONF_MOTION_SENSOR, CONF_DEVICE_POWER, + CONF_CYCLE_MIN, + CONF_PROP_FUNCTION, + CONF_PROP_BIAS, ] CONF_PRESETS = { @@ -44,4 +52,6 @@ CONF_PRESETS = { ) } +CONF_FUNCTIONS = [PROPORTIONAL_FUNCTION_LINEAR, PROPORTIONAL_FUNCTION_ATAN] + SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE diff --git a/custom_components/versatile_thermostat/prop_algorithm.py b/custom_components/versatile_thermostat/prop_algorithm.py new file mode 100644 index 0000000..9fbd0af --- /dev/null +++ b/custom_components/versatile_thermostat/prop_algorithm.py @@ -0,0 +1,83 @@ +import logging +import math + +_LOGGER = logging.getLogger(__name__) + +PROPORTIONAL_FUNCTION_ATAN = "atan" +PROPORTIONAL_FUNCTION_LINEAR = "linear" + +PROPORTIONAL_MIN_DURATION_SEC = 10 + +FUNCTION_TYPE = [PROPORTIONAL_FUNCTION_ATAN, PROPORTIONAL_FUNCTION_LINEAR] + + +class PropAlgorithm: + """This class aims to do all calculation of the Proportional alogorithm""" + + def __init__(self, function_type: str, bias: float, cycle_min: int): + """Initialisation of the Proportional Algorithm""" + _LOGGER.debug( + "Creation new PropAlgorithm function_type: %s, bias: %f, cycle_min:%d", + function_type, + bias, + cycle_min, + ) + # TODO test function_type, bias, cycle_min + self._function = function_type + self._bias = bias + self._cycle_min = cycle_min + self._on_time_sec = 0 + self._off_time_sec = self._cycle_min * 60 + + def calculate(self, target_temp: float, current_temp: float): + """Do the calculation of the duration""" + if target_temp is None or current_temp is None: + _LOGGER.warning( + "Proportional algorithm: calculation is not possible cause target_temp or current_temp is null. Heating will be disabled" + ) + on_percent = 0 + else: + delta_temp = target_temp - current_temp + if self._function == PROPORTIONAL_FUNCTION_LINEAR: + on_percent = 0.25 * delta_temp + self._bias + elif self._function == PROPORTIONAL_FUNCTION_ATAN: + on_percent = math.atan(delta_temp + self._bias) / 1.4 + else: + _LOGGER.warning( + "Proportional algorithm: unknown %s function. Heating will be disabled", + self._function, + ) + on_percent = 0 + + # calculated on_time duration in seconds + if on_percent > 1: + on_percent = 1 + self._on_time_sec = on_percent * self._cycle_min * 60 + + # Do not heat for less than xx sec + if self._on_time_sec < PROPORTIONAL_MIN_DURATION_SEC: + _LOGGER.debug( + "No heating period due to heating period too small (%f < %f)", + self._on_time_sec, + PROPORTIONAL_MIN_DURATION_SEC, + ) + self._on_time_sec = 0 + + self._off_time_sec = (1.0 - on_percent) * self._cycle_min * 60 + + _LOGGER.debug( + "heating percent calculated is %f, on_time is %f (sec), off_time is %s (sec)", + on_percent, + self._on_time_sec, + self._off_time_sec, + ) + + @property + def on_time_sec(self): + """Returns the calculated time in sec the heater must be ON""" + return self._on_time_sec + + @property + def off_time_sec(self): + """Returns the calculated time in sec the heater must be OFF""" + return self._off_time_sec diff --git a/custom_components/versatile_thermostat/strings.json b/custom_components/versatile_thermostat/strings.json index d55836b..7ac36e8 100644 --- a/custom_components/versatile_thermostat/strings.json +++ b/custom_components/versatile_thermostat/strings.json @@ -13,6 +13,9 @@ "window_sensor_entity_id": "[%key:config::data::window_sensor_entity_id%]", "motion_sensor_entity_id": "[%key:config::data::motion_sensor_entity_id%]", "device_power": "[%key:config::data::device_power%]", + "cycle_min": "[%key:config::data::cycle_min%]", + "proportional_function": "[%key:config::data::proportional_function%]", + "proportional_bias": "[%key:config::data::proportional_bias%]" } } }, diff --git a/custom_components/versatile_thermostat/translations/en.json b/custom_components/versatile_thermostat/translations/en.json index 5611131..de70794 100644 --- a/custom_components/versatile_thermostat/translations/en.json +++ b/custom_components/versatile_thermostat/translations/en.json @@ -18,7 +18,10 @@ "max_power_sensor_entity_id": "Max power sensor entity id", "window_sensor_entity_id": "Window sensor entity id", "motion_sensor_entity_id": "Motion sensor entity id", - "device_power": "Device power (kW)" + "device_power": "Device power (kW)", + "cycle_min": "Cycle duration (min)", + "proportional_function": "Function to use (atan is more aggressive)", + "proportional_bias": "A bias to use in proportional algorithm" } } }