Compare commits

...

6 Commits

Author SHA1 Message Date
Jean-Marc Collin
49377de248 Change regulation parameters 2023-10-30 16:43:21 +00:00
Jean-Marc Collin
6886dd6fb5 Implementation of regulation 2023-10-30 14:51:24 +00:00
Jean-Marc Collin
dde622e632 Implements regulation (tests ko) 2023-10-30 09:20:19 +00:00
Jean-Marc Collin
076d9eae24 Fix translations 2023-10-30 09:20:18 +00:00
Jean-Marc Collin
3356489f9d Add translations 2023-10-30 09:20:18 +00:00
Jean-Marc Collin
b323d676dc Algo implementation and tests 2023-10-30 09:20:18 +00:00
18 changed files with 963 additions and 229 deletions

View File

@@ -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

View File

@@ -66,8 +66,6 @@ async def async_setup_entry(
entity = ThermostatOverValve(hass, unique_id, name, entry.data)
async_add_entities([entity], True)
# No more needed
# VersatileThermostat.add_entity(entry.entry_id, entity)
# Add services
platform = entity_platform.async_get_current_platform()

View File

@@ -1,6 +1,4 @@
# pylint: disable=line-too-long
# pylint: disable=too-many-lines
# pylint: disable=invalid-name
# pylint: disable=line-too-long, too-many-lines, invalid-name
"""Config flow for Versatile Thermostat integration."""
from __future__ import annotations
@@ -101,6 +99,9 @@ from .const import (
CONF_VALVE_2,
CONF_VALVE_3,
CONF_VALVE_4,
CONF_AUTO_REGULATION_MODES,
CONF_AUTO_REGULATION_MODE,
CONF_AUTO_REGULATION_NONE,
UnknownEntity,
WindowOpenDetectionMethod,
)
@@ -256,6 +257,13 @@ class VersatileThermostatBaseConfigFlow(FlowHandler):
selector.EntitySelectorConfig(domain=CLIMATE_DOMAIN),
),
vol.Optional(CONF_AC_MODE, default=False): cv.boolean,
vol.Optional(
CONF_AUTO_REGULATION_MODE, default=CONF_AUTO_REGULATION_NONE
): selector.SelectSelector(
selector.SelectSelectorConfig(
options=CONF_AUTO_REGULATION_MODES, translation_key="auto_regulation_mode"
)
),
}
)

View File

@@ -1,3 +1,4 @@
# pylint: disable=line-too-long
"""Constants for the Versatile Thermostat integration."""
from enum import Enum
@@ -82,6 +83,11 @@ CONF_VALVE = "valve_entity_id"
CONF_VALVE_2 = "valve_entity2_id"
CONF_VALVE_3 = "valve_entity3_id"
CONF_VALVE_4 = "valve_entity4_id"
CONF_AUTO_REGULATION_MODE= "auto_regulation_mode"
CONF_AUTO_REGULATION_NONE= "auto_regulation_none"
CONF_AUTO_REGULATION_LIGHT= "auto_regulation_light"
CONF_AUTO_REGULATION_MEDIUM= "auto_regulation_medium"
CONF_AUTO_REGULATION_STRONG= "auto_regulation_strong"
CONF_PRESETS = {
p: f"{p}_temp"
@@ -183,7 +189,7 @@ ALL_CONF = (
CONF_VALVE_2,
CONF_VALVE_3,
CONF_VALVE_4,
CONF_AUTO_REGULATION_MODE
]
+ CONF_PRESETS_VALUES
+ CONF_PRESETS_AWAY_VALUES
@@ -195,6 +201,8 @@ CONF_FUNCTIONS = [
PROPORTIONAL_FUNCTION_TPI,
]
CONF_AUTO_REGULATION_MODES = [CONF_AUTO_REGULATION_NONE, CONF_AUTO_REGULATION_LIGHT, CONF_AUTO_REGULATION_MEDIUM, CONF_AUTO_REGULATION_STRONG]
CONF_THERMOSTAT_TYPES = [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE, CONF_THERMOSTAT_VALVE]
SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE
@@ -211,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.4
ki:float = 0.08
k_ext:float = 0.1
offset_max:float = 3
stabilization_threshold:float = 0.1
accumulated_error_threshold:float = 25
class RegulationParamStrong:
""" Strong parameters for regulation"""
kp:float = 0.6
ki:float = 0.1
k_ext:float = 0.2
offset_max:float = 4
stabilization_threshold:float = 0.1
accumulated_error_threshold:float = 30
class EventType(Enum):
"""The event type that can be sent"""

View File

@@ -0,0 +1,70 @@
# pylint: disable=line-too-long
""" The PI algorithm implementation """
import logging
_LOGGER = logging.getLogger(__name__)
class PITemperatureRegulator:
""" A class implementing a PI Algorithm
PI algorithms calculate a target temperature by adding an offset which is calculating as follow:
- offset = kp * error + ki * accumulated_error
To use it you must:
- instanciate the class and gives the algorithm parameters: kp, ki, offset_max, stabilization_threshold, accumulated_error_threshold
- call calculate_regulated_temperature with the internal and external temperature
- call set_target_temp when the target temperature change.
"""
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: 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
# Calculate the sum of error (I)
self.accumulated_error += error
# Capping of the error
self.accumulated_error = min(self.accumulated_error_threshold, max(-self.accumulated_error_threshold, self.accumulated_error))
# Calculate the offset (proportionnel + intégral)
offset = self.kp * error + self.ki * self.accumulated_error
# Calculate the exterior offset
offset_ext = self.k_ext * (self.target_temp - external_temp)
# Capping of offset_ext
total_offset = offset + offset_ext
total_offset = min(self.offset_max, max(-self.offset_max, total_offset))
# If temperature is near the target_temp, reset the accumulated_error
if abs(error) < self.stabilization_threshold:
_LOGGER.debug("Stabilisation")
self.accumulated_error = 0
result = round(self.target_temp + total_offset, 1)
_LOGGER.debug("PITemperatureRegulator - Error: %.2f accumulated_error: %.2f offset: %.2f offset_ext: %.2f target_tem: %.1f regulatedTemp: %.1f",
error, self.accumulated_error, offset, offset_ext, self.target_temp, result)
return result

View File

@@ -11,6 +11,7 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorDeviceClass,
SensorStateClass,
UnitOfTemperature
)
from homeassistant.config_entries import ConfigEntry
@@ -24,6 +25,7 @@ from .const import (
PROPORTIONAL_FUNCTION_TPI,
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_VALVE,
CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_TYPE,
)
@@ -63,6 +65,9 @@ async def async_setup_entry(
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_VALVE:
entities.append(ValveOpenPercentSensor(hass, unique_id, name, entry.data))
if entry.data.get(CONF_THERMOSTAT_TYPE) == CONF_THERMOSTAT_CLIMATE:
entities.append(RegulatedTemperatureSensor(hass, unique_id, name, entry.data))
async_add_entities(entities, True)
@@ -470,3 +475,53 @@ class TemperatureSlopeSensor(VersatileThermostatBaseEntity, SensorEntity):
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 2
class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a Energy sensor which exposes the energy"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos) -> None:
"""Initialize the regulated temperature sensor"""
super().__init__(hass, unique_id, entry_infos.get(CONF_NAME))
self._attr_name = "Regulated temperature"
self._attr_unique_id = f"{self._device_name}_regulated_temperature"
@callback
async def async_my_climate_changed(self, event: Event = None):
"""Called when my climate have change"""
_LOGGER.debug("%s - climate state change", self._attr_unique_id)
if math.isnan(self.my_climate.regulated_target_temp) or math.isinf(
self.my_climate.regulated_target_temp
):
raise ValueError(f"Sensor has illegal state {self.my_climate.regulated_target_temp}")
old_state = self._attr_native_value
self._attr_native_value = round(
self.my_climate.regulated_target_temp, self.suggested_display_precision
)
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:thermometer-auto"
@property
def device_class(self) -> SensorDeviceClass | None:
return SensorDeviceClass.TEMPERATURE
@property
def state_class(self) -> SensorStateClass | None:
return SensorStateClass.MEASUREMENT
@property
def native_unit_of_measurement(self) -> str | None:
if not self.my_climate:
return UnitOfTemperature.CELSIUS
return self.my_climate.temperature_unit
@property
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 1

View File

@@ -38,7 +38,8 @@
"valve_entity_id": "1rst valve number",
"valve_entity2_id": "2nd valve number",
"valve_entity3_id": "3rd valve number",
"valve_entity4_id": "4th valve number"
"valve_entity4_id": "4th valve number",
"auto_regulation_mode": "Self-regulation"
},
"data_description": {
"heater_entity_id": "Mandatory heater entity id",
@@ -54,7 +55,8 @@
"valve_entity_id": "1rst valve number entity id",
"valve_entity2_id": "2nd valve number entity id",
"valve_entity3_id": "3rd valve number entity id",
"valve_entity4_id": "4th valve number entity id"
"valve_entity4_id": "4th valve number entity id",
"auto_regulation_mode": "Auto adjustment of the target temperature"
}
},
"tpi": {
@@ -199,7 +201,8 @@
"valve_entity_id": "1rst valve number",
"valve_entity2_id": "2nd valve number",
"valve_entity3_id": "3rd valve number",
"valve_entity4_id": "4th valve number"
"valve_entity4_id": "4th valve number",
"auto_regulation_mode": "Self-regulation"
},
"data_description": {
"heater_entity_id": "Mandatory heater entity id",
@@ -215,7 +218,8 @@
"valve_entity_id": "1rst valve number entity id",
"valve_entity2_id": "2nd valve number entity id",
"valve_entity3_id": "3rd valve number entity id",
"valve_entity4_id": "4th valve number entity id"
"valve_entity4_id": "4th valve number entity id",
"auto_regulation_mode": "Auto adjustment of the target temperature"
}
},
"tpi": {
@@ -329,6 +333,14 @@
"thermostat_over_climate": "Thermostat over a climate",
"thermostat_over_valve": "Thermostat over a valve"
}
},
"auto_regulation_mode": {
"options": {
"auto_regulation_strong": "Strong",
"auto_regulation_medium": "Medium",
"auto_regulation_light": "Light",
"auto_regulation_none": "No auto-regulation"
}
}
},
"entity": {

View File

@@ -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,17 +33,21 @@ _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(
{
"is_over_climate", "start_hvac_action_date", "underlying_climate_0", "underlying_climate_1",
"underlying_climate_2", "underlying_climate_3"
"underlying_climate_2", "underlying_climate_3", "regulation_accumulated_error"
}))
# 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:
@@ -55,189 +74,27 @@ class ThermostatOverClimate(BaseThermostat):
return HVACAction.IDLE
return HVACAction.OFF
@property
def hvac_modes(self):
"""List of available operation modes."""
if self.underlying_entity(0):
return self.underlying_entity(0).hvac_modes
else:
return super.hvac_modes
@property
def mean_cycle_power(self) -> float | None:
"""Returns the mean power consumption during the cycle"""
return None
@property
def fan_mode(self) -> str | None:
"""Return the fan setting.
Requires ClimateEntityFeature.FAN_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).fan_mode
return None
@property
def fan_modes(self) -> list[str] | None:
"""Return the list of available fan modes.
Requires ClimateEntityFeature.FAN_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).fan_modes
return []
@property
def swing_mode(self) -> str | None:
"""Return the swing setting.
Requires ClimateEntityFeature.SWING_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).swing_mode
return None
@property
def swing_modes(self) -> list[str] | None:
"""Return the list of available swing modes.
Requires ClimateEntityFeature.SWING_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).swing_modes
return None
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
if self.underlying_entity(0):
return self.underlying_entity(0).temperature_unit
return self._unit
@property
def supported_features(self):
"""Return the list of supported features."""
if self.underlying_entity(0):
return self.underlying_entity(0).supported_features | self._support_flags
return self._support_flags
@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.
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).target_temperature_high
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach.
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).target_temperature_low
return None
@property
def is_aux_heat(self) -> bool | None:
"""Return true if aux heater.
Requires ClimateEntityFeature.AUX_HEAT.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).is_aux_heat
return None
@overrides
def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
if self.underlying_entity(0):
return self.underlying_entity(0).turn_aux_heat_on()
raise NotImplementedError()
@overrides
async def async_turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
for under in self._underlyings:
await under.async_turn_aux_heat_on()
@overrides
def turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
for under in self._underlyings:
return under.turn_aux_heat_off()
@overrides
async def async_turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
for under in self._underlyings:
await under.async_turn_aux_heat_off()
@overrides
async def async_set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
_LOGGER.info("%s - Set fan mode: %s", self, fan_mode)
if fan_mode is None:
return
for under in self._underlyings:
await under.set_fan_mode(fan_mode)
self._fan_mode = fan_mode
self.async_write_ha_state()
@overrides
async def async_set_humidity(self, humidity: int):
"""Set new target humidity."""
_LOGGER.info("%s - Set fan mode: %s", self, humidity)
if humidity is None:
return
for under in self._underlyings:
await under.set_humidity(humidity)
self._humidity = humidity
self.async_write_ha_state()
@overrides
async def async_set_swing_mode(self, swing_mode):
"""Set new target swing operation."""
_LOGGER.info("%s - Set fan mode: %s", self, swing_mode)
if swing_mode is None:
return
for under in self._underlyings:
await under.set_swing_mode(swing_mode)
self._swing_mode = swing_mode
self.async_write_ha_state()
@overrides
async def _async_internal_set_temperature(self, temperature):
"""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):
@@ -259,6 +116,40 @@ 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):
"""Run when entity about to be added."""
@@ -304,6 +195,10 @@ 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._attr_extra_state_attributes["regulation_accumulated_error"] = self._regulation_algo.accumulated_error
self.async_write_ha_state()
_LOGGER.debug(
"%s - Calling update_custom_attributes: %s",
@@ -509,7 +404,8 @@ class ThermostatOverClimate(BaseThermostat):
new_state.attributes,
)
if (
self.is_over_climate
# we do not change target temperature on regulated VTherm
not self.is_regulated
and new_state.attributes
and (new_target_temp := new_state.attributes.get("temperature"))
and new_target_temp != self.target_temperature
@@ -523,3 +419,201 @@ class ThermostatOverClimate(BaseThermostat):
changes = True
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."""
if self.underlying_entity(0):
return self.underlying_entity(0).hvac_modes
else:
return super.hvac_modes
@property
def mean_cycle_power(self) -> float | None:
"""Returns the mean power consumption during the cycle"""
return None
@property
def fan_mode(self) -> str | None:
"""Return the fan setting.
Requires ClimateEntityFeature.FAN_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).fan_mode
return None
@property
def fan_modes(self) -> list[str] | None:
"""Return the list of available fan modes.
Requires ClimateEntityFeature.FAN_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).fan_modes
return []
@property
def swing_mode(self) -> str | None:
"""Return the swing setting.
Requires ClimateEntityFeature.SWING_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).swing_mode
return None
@property
def swing_modes(self) -> list[str] | None:
"""Return the list of available swing modes.
Requires ClimateEntityFeature.SWING_MODE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).swing_modes
return None
@property
def temperature_unit(self) -> str:
"""Return the unit of measurement."""
if self.underlying_entity(0):
return self.underlying_entity(0).temperature_unit
return self._unit
@property
def supported_features(self):
"""Return the list of supported features."""
if self.underlying_entity(0):
return self.underlying_entity(0).supported_features | self._support_flags
return self._support_flags
@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.
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).target_temperature_high
return None
@property
def target_temperature_low(self) -> float | None:
"""Return the lowbound target temperature we try to reach.
Requires ClimateEntityFeature.TARGET_TEMPERATURE_RANGE.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).target_temperature_low
return None
@property
def is_aux_heat(self) -> bool | None:
"""Return true if aux heater.
Requires ClimateEntityFeature.AUX_HEAT.
"""
if self.underlying_entity(0):
return self.underlying_entity(0).is_aux_heat
return None
@overrides
def turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
if self.underlying_entity(0):
return self.underlying_entity(0).turn_aux_heat_on()
raise NotImplementedError()
@overrides
async def async_turn_aux_heat_on(self) -> None:
"""Turn auxiliary heater on."""
for under in self._underlyings:
await under.async_turn_aux_heat_on()
@overrides
def turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
for under in self._underlyings:
return under.turn_aux_heat_off()
@overrides
async def async_turn_aux_heat_off(self) -> None:
"""Turn auxiliary heater off."""
for under in self._underlyings:
await under.async_turn_aux_heat_off()
@overrides
async def async_set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
_LOGGER.info("%s - Set fan mode: %s", self, fan_mode)
if fan_mode is None:
return
for under in self._underlyings:
await under.set_fan_mode(fan_mode)
self._fan_mode = fan_mode
self.async_write_ha_state()
@overrides
async def async_set_humidity(self, humidity: int):
"""Set new target humidity."""
_LOGGER.info("%s - Set fan mode: %s", self, humidity)
if humidity is None:
return
for under in self._underlyings:
await under.set_humidity(humidity)
self._humidity = humidity
self.async_write_ha_state()
@overrides
async def async_set_swing_mode(self, swing_mode):
"""Set new target swing operation."""
_LOGGER.info("%s - Set fan mode: %s", self, swing_mode)
if swing_mode is None:
return
for under in self._underlyings:
await under.set_swing_mode(swing_mode)
self._swing_mode = swing_mode
self.async_write_ha_state()

View File

@@ -38,7 +38,8 @@
"valve_entity_id": "1rst valve number",
"valve_entity2_id": "2nd valve number",
"valve_entity3_id": "3rd valve number",
"valve_entity4_id": "4th valve number"
"valve_entity4_id": "4th valve number",
"auto_regulation_mode": "Self-regulation"
},
"data_description": {
"heater_entity_id": "Mandatory heater entity id",
@@ -54,7 +55,8 @@
"valve_entity_id": "1rst valve number entity id",
"valve_entity2_id": "2nd valve number entity id",
"valve_entity3_id": "3rd valve number entity id",
"valve_entity4_id": "4th valve number entity id"
"valve_entity4_id": "4th valve number entity id",
"auto_regulation_mode": "Auto adjustment of the target temperature"
}
},
"tpi": {
@@ -199,7 +201,8 @@
"valve_entity_id": "1rst valve number",
"valve_entity2_id": "2nd valve number",
"valve_entity3_id": "3rd valve number",
"valve_entity4_id": "4th valve number"
"valve_entity4_id": "4th valve number",
"auto_regulation_mode": "Self-regulation"
},
"data_description": {
"heater_entity_id": "Mandatory heater entity id",
@@ -215,7 +218,8 @@
"valve_entity_id": "1rst valve number entity id",
"valve_entity2_id": "2nd valve number entity id",
"valve_entity3_id": "3rd valve number entity id",
"valve_entity4_id": "4th valve number entity id"
"valve_entity4_id": "4th valve number entity id",
"auto_regulation_mode": "Auto adjustment of the target temperature"
}
},
"tpi": {
@@ -329,6 +333,14 @@
"thermostat_over_climate": "Thermostat over a climate",
"thermostat_over_valve": "Thermostat over a valve"
}
},
"auto_regulation_mode": {
"options": {
"auto_regulation_strong": "Strong",
"auto_regulation_medium": "Medium",
"auto_regulation_light": "Light",
"auto_regulation_none": "No auto-regulation"
}
}
},
"entity": {

View File

@@ -38,7 +38,8 @@
"valve_entity_id": "1ère valve number",
"valve_entity2_id": "2ème valve number",
"valve_entity3_id": "3ème valve number",
"valve_entity4_id": "4ème valve number"
"valve_entity4_id": "4ème valve number",
"auto_regulation_mode": "Auto-régulation"
},
"data_description": {
"heater_entity_id": "Entity id du 1er radiateur obligatoire",
@@ -54,7 +55,8 @@
"valve_entity_id": "Entity id de la 1ère valve",
"valve_entity2_id": "Entity id de la 2ème valve",
"valve_entity3_id": "Entity id de la 3ème valve",
"valve_entity4_id": "Entity id de la 4ème valve"
"valve_entity4_id": "Entity id de la 4ème valve",
"auto_regulation_mode": "Ajustement automatique de la température cible"
}
},
"tpi": {
@@ -197,10 +199,11 @@
"climate_entity3_id": "3ème thermostat sous-jacent",
"climate_entity4_id": "4ème thermostat sous-jacent",
"ac_mode": "AC mode ?",
"valve_entity_id": "1ère valve number",
"valve_entity2_id": "2ème valve number",
"valve_entity3_id": "3ème valve number",
"valve_entity4_id": "4ème valve number"
"valve_entity_id": "1ère valve",
"valve_entity2_id": "2ème valve",
"valve_entity3_id": "3ème valve",
"valve_entity4_id": "4ème valve",
"auto_regulation_mode": "Auto-regulation"
},
"data_description": {
"heater_entity_id": "Entity id du 1er radiateur obligatoire",
@@ -216,7 +219,8 @@
"valve_entity_id": "Entity id de la 1ère valve",
"valve_entity2_id": "Entity id de la 2ème valve",
"valve_entity3_id": "Entity id de la 3ème valve",
"valve_entity4_id": "Entity id de la 4ème valve"
"valve_entity4_id": "Entity id de la 4ème valve",
"auto_regulation_mode": "Ajustement automatique de la consigne"
}
},
"tpi": {
@@ -330,6 +334,14 @@
"thermostat_over_climate": "Thermostat sur un autre thermostat",
"thermostat_over_valve": "Thermostat sur une valve"
}
},
"auto_regulation_mode": {
"options": {
"auto_regulation_strong": "Forte",
"auto_regulation_medium": "Moyenne",
"auto_regulation_light": "Légère",
"auto_regulation_none": "Aucune"
}
}
},
"entity": {

View File

@@ -38,7 +38,8 @@
"valve_entity_id": "Primo valvola numero",
"valve_entity2_id": "Secondo valvola numero",
"valve_entity3_id": "Terzo valvola numero",
"valve_entity4_id": "Quarto valvola numero"
"valve_entity4_id": "Quarto valvola numero",
"auto_regulation_mode": "Autoregolamentazione"
},
"data_description": {
"heater_entity_id": "Entity id obbligatoria del primo riscaldatore",
@@ -54,7 +55,8 @@
"valve_entity_id": "Entity id del primo valvola numero",
"valve_entity2_id": "Entity id del secondo valvola numero",
"valve_entity3_id": "Entity id del terzo valvola numero",
"valve_entity4_id": "Entity id del quarto valvola numero"
"valve_entity4_id": "Entity id del quarto valvola numero",
"auto_regulation_mode": "Regolazione automatica della temperatura target"
}
},
"tpi": {
@@ -192,7 +194,8 @@
"valve_entity_id": "Primo valvola numero",
"valve_entity2_id": "Secondo valvola numero",
"valve_entity3_id": "Terzo valvola numero",
"valve_entity4_id": "Quarto valvola numero"
"valve_entity4_id": "Quarto valvola numero",
"auto_regulation_mode": "Autoregolamentazione"
},
"data_description": {
"heater_entity_id": "Entity id obbligatoria del primo riscaldatore",
@@ -208,7 +211,8 @@
"valve_entity_id": "Entity id del primo valvola numero",
"valve_entity2_id": "Entity id del secondo valvola numero",
"valve_entity3_id": "Entity id del terzo valvola numero",
"valve_entity4_id": "Entity id del quarto valvola numero"
"valve_entity4_id": "Entity id del quarto valvola numero",
"auto_regulation_mode": "Autoregolamentazione"
}
},
"tpi": {
@@ -315,6 +319,14 @@
"thermostat_over_climate": "Termostato sopra un altro termostato",
"thermostat_over_valve": "Thermostato su una valvola"
}
},
"auto_regulation_mode": {
"options": {
"auto_regulation_strong": "Forte",
"auto_regulation_medium": "Media",
"auto_regulation_light": "Leggera",
"auto_regulation_none": "Nessuna autoregolamentazione"
}
}
},
"entity": {

View File

@@ -34,6 +34,8 @@ from .const import ( # pylint: disable=unused-import
MOCK_TH_OVER_SWITCH_AC_TYPE_CONFIG,
MOCK_TH_OVER_4SWITCH_TYPE_CONFIG,
MOCK_TH_OVER_CLIMATE_TYPE_CONFIG,
MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG,
MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG,
MOCK_TH_OVER_SWITCH_TPI_CONFIG,
MOCK_PRESETS_CONFIG,
MOCK_PRESETS_AC_CONFIG,
@@ -83,6 +85,20 @@ PARTIAL_CLIMATE_CONFIG = (
| MOCK_ADVANCED_CONFIG
)
PARTIAL_CLIMATE_NOT_REGULATED_CONFIG = (
MOCK_TH_OVER_CLIMATE_USER_CONFIG
| MOCK_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_ADVANCED_CONFIG
)
PARTIAL_CLIMATE_AC_CONFIG = (
MOCK_TH_OVER_CLIMATE_USER_CONFIG
| MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_ADVANCED_CONFIG
)
FULL_4SWITCH_CONFIG = (
MOCK_TH_OVER_4SWITCH_USER_CONFIG
| MOCK_TH_OVER_4SWITCH_TYPE_CONFIG
@@ -101,7 +117,7 @@ _LOGGER = logging.getLogger(__name__)
class MockClimate(ClimateEntity):
"""A Mock Climate class used for Underlying climate mode"""
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF) -> None: # pylint: disable=unused-argument
def __init__(self, hass: HomeAssistant, unique_id, name, entry_infos, hvac_mode:HVACMode = HVACMode.OFF, hvac_action:HVACAction = HVACAction.OFF) -> None: # pylint: disable=unused-argument
"""Initialize the thermostat."""
super().__init__()
@@ -118,17 +134,25 @@ class MockClimate(ClimateEntity):
self._attr_temperature_unit = UnitOfTemperature.CELSIUS
self._attr_target_temperature = 20
self._attr_current_temperature = 15
self._attr_hvac_action = hvac_action
def set_temperature(self, **kwargs):
""" Set the target temperature"""
temperature = kwargs.get(ATTR_TEMPERATURE)
self._attr_target_temperature = temperature
self.async_write_ha_state()
async def async_set_hvac_mode(self, hvac_mode):
""" The hvac mode"""
self._attr_hvac_mode = hvac_mode
self.async_write_ha_state()
@property
def hvac_action(self):
""" The hvac action of the mock climate"""
return self._attr_hvac_action
def set_hvac_action(self, hvac_action: HVACAction):
""" Set the HVACaction """
self._attr_hvac_action = hvac_action
class MockUnavailableClimate(ClimateEntity):
"""A Mock Climate class used for Underlying climate mode"""

View File

@@ -50,6 +50,9 @@ from custom_components.versatile_thermostat.const import (
CONF_PRESENCE_SENSOR,
PRESET_AWAY_SUFFIX,
CONF_CLIMATE,
CONF_AUTO_REGULATION_MODE,
CONF_AUTO_REGULATION_MEDIUM,
CONF_AUTO_REGULATION_NONE,
)
MOCK_TH_OVER_SWITCH_USER_CONFIG = {
CONF_NAME: "TheOverSwitchMockName",
@@ -89,14 +92,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 +125,19 @@ 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_TH_OVER_CLIMATE_TYPE_NOT_REGULATED_CONFIG = {
CONF_CLIMATE: "climate.mock_climate",
CONF_AC_MODE: False,
CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_NONE
}
MOCK_TH_OVER_CLIMATE_TYPE_AC_CONFIG = {
CONF_CLIMATE: "climate.mock_climate",
CONF_AC_MODE: True,
CONF_AUTO_REGULATION_MODE: CONF_AUTO_REGULATION_MEDIUM
}
MOCK_PRESETS_CONFIG = {

View File

@@ -0,0 +1,210 @@
# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long
""" Test the normal start of a Thermostat """
from unittest.mock import patch #, call
from datetime import datetime, timedelta
from homeassistant.core import HomeAssistant
from homeassistant.components.climate import HVACAction, HVACMode
from homeassistant.config_entries import ConfigEntryState
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DOMAIN
from pytest_homeassistant_custom_component.common import MockConfigEntry
# from custom_components.versatile_thermostat.base_thermostat import BaseThermostat
from custom_components.versatile_thermostat.thermostat_climate import ThermostatOverClimate
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_over_climate_regulation(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the regulation of an over climate thermostat"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
# This is include a medium regulation
data=PARTIAL_CLIMATE_CONFIG,
)
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {})
# Creates the regulated VTherm over climate
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
def find_my_entity(entity_id) -> ClimateEntity:
"""Find my new entity"""
component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if entity.entity_id == entity_id:
return entity
entity:ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity
assert isinstance(entity, ThermostatOverClimate)
assert entity.name == "TheOverClimateMockName"
assert entity.is_over_climate is True
assert entity.is_regulated is True
assert entity.hvac_action is HVACAction.OFF
assert entity.hvac_mode is HVACMode.OFF
assert entity.target_temperature == entity.min_temp
assert entity.preset_modes == [
PRESET_NONE,
PRESET_ECO,
PRESET_COMFORT,
PRESET_BOOST,
]
assert entity.preset_mode is PRESET_NONE
# Activate the heating by changing HVACMode and temperature
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
):
# Select a hvacmode, presence and preset
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.hvac_action == HVACAction.OFF
# change temperature so that the heating will start
event_timestamp = now - timedelta(minutes=10)
await send_temperature_change_event(entity, 15, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
# set manual target temp
await entity.async_set_temperature(temperature=18)
fake_underlying_climate.set_hvac_action(HVACAction.HEATING) # simulate under heating
assert entity.hvac_action == HVACAction.HEATING
assert entity.preset_mode == PRESET_NONE # Manual mode
# the regulated temperature should be greater
assert entity.regulated_target_temp > entity.target_temperature
assert entity.regulated_target_temp == 18+2.9 # In medium we could go up to +3 degre
assert entity.hvac_action == HVACAction.HEATING
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=9)
await send_temperature_change_event(entity, 19, event_timestamp)
await send_ext_temperature_change_event(entity, 18, event_timestamp)
# the regulated temperature should be under
assert entity.regulated_target_temp < entity.target_temperature
assert entity.regulated_target_temp == 18-0.1
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_over_climate_regulation_ac_mode(hass: HomeAssistant, skip_hass_states_is_state):
"""Test the regulation of an over climate thermostat"""
entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
# This is include a medium regulation
data=PARTIAL_CLIMATE_AC_CONFIG,
)
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {})
# Creates the regulated VTherm over climate
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
):
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state is ConfigEntryState.LOADED
def find_my_entity(entity_id) -> ClimateEntity:
"""Find my new entity"""
component: EntityComponent[ClimateEntity] = hass.data[CLIMATE_DOMAIN]
for entity in component.entities:
if entity.entity_id == entity_id:
return entity
entity:ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity
assert isinstance(entity, ThermostatOverClimate)
assert entity.name == "TheOverClimateMockName"
assert entity.is_over_climate is True
assert entity.is_regulated is True
assert entity.hvac_action is HVACAction.OFF
assert entity.hvac_mode is HVACMode.OFF
assert entity.target_temperature == entity.max_temp
assert entity.preset_modes == [
PRESET_NONE,
PRESET_ECO,
PRESET_COMFORT,
PRESET_BOOST,
]
assert entity.preset_mode is PRESET_NONE
# Activate the heating by changing HVACMode and temperature
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
):
# Select a hvacmode, presence and preset
await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode is HVACMode.HEAT
assert entity.hvac_action == HVACAction.OFF
# change temperature so that the heating will start
event_timestamp = now - timedelta(minutes=10)
await send_temperature_change_event(entity, 30, event_timestamp)
await send_ext_temperature_change_event(entity, 35, event_timestamp)
# set manual target temp
await entity.async_set_temperature(temperature=25)
fake_underlying_climate.set_hvac_action(HVACAction.COOLING) # simulate under heating
assert entity.hvac_action == HVACAction.COOLING
assert entity.preset_mode == PRESET_NONE # Manual mode
# the regulated temperature should be lower
assert entity.regulated_target_temp < entity.target_temperature
assert entity.regulated_target_temp == 25-3 # In medium we could go up to -3 degre
assert entity.hvac_action == HVACAction.COOLING
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=9)
await send_temperature_change_event(entity, 26, event_timestamp)
await send_ext_temperature_change_event(entity, 35, event_timestamp)
# the regulated temperature should be under
assert entity.regulated_target_temp < entity.target_temperature
assert entity.regulated_target_temp == 25-2.7
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=9)
await send_temperature_change_event(entity, 20, event_timestamp)
await send_ext_temperature_change_event(entity, 30, event_timestamp)
# the regulated temperature should be greater
assert entity.regulated_target_temp > entity.target_temperature
assert entity.regulated_target_temp == 25+1.8

View File

@@ -463,11 +463,11 @@ async def test_bug_101(
domain=DOMAIN,
title="TheOverClimateMockName",
unique_id="uniqueId",
data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay
data=PARTIAL_CLIMATE_NOT_REGULATED_CONFIG, # 5 minutes security delay
)
# Underlying is in HEAT mode but should be shutdown at startup
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT, HVACAction.HEATING)
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
@@ -495,7 +495,7 @@ async def test_bug_101(
assert entity.name == "TheOverClimateMockName"
assert entity.is_over_climate is True
assert entity.hvac_mode is HVACMode.OFF
# because the underlying is heating. In real life the underlying should be shut-off
# because in MockClimate HVACAction is HEATING if hvac_mode is not set
assert entity.hvac_action is HVACAction.HEATING
# Underlying should have been shutdown
assert mock_underlying_set_hvac_mode.call_count == 1
@@ -539,6 +539,7 @@ async def test_bug_101(
# 2. Change the target temp of underlying thermostat at 11 sec later -> the event will be taken
# Wait 11 sec
event_timestamp = now + timedelta(seconds=11)
assert entity.is_regulated is False
await send_climate_change_event_with_temperature(entity, HVACMode.HEAT, HVACMode.HEAT, HVACAction.OFF, HVACAction.OFF, event_timestamp, 12.75)
assert entity.target_temperature == 12.75
assert entity.preset_mode is PRESET_NONE

171
tests/test_pi.py Normal file
View File

@@ -0,0 +1,171 @@
# pylint: disable=line-too-long
""" Tests de PI algorithm used for auto-regulation """
from custom_components.versatile_thermostat.pi_algorithm import PITemperatureRegulator
def test_pi_algorithm_basics():
""" 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)
assert the_algo
assert the_algo.calculate_regulated_temperature(20, 20) == 20
assert the_algo.calculate_regulated_temperature(20, 10) == 21
# to reset the accumulated erro
the_algo.set_target_temp(20)
# Test the accumulator threshold effect and offset_max
assert the_algo.calculate_regulated_temperature(10, 10) == 22 # +2
assert the_algo.calculate_regulated_temperature(10, 10) == 22
assert the_algo.calculate_regulated_temperature(10, 10) == 22
# Will keep infinitly 22.0
# to reset the accumulated erro
the_algo.set_target_temp(20)
assert the_algo.calculate_regulated_temperature(18, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(18.1, 10) == 21.6 # +1.6
assert the_algo.calculate_regulated_temperature(18.3, 10) == 21.6 # +1.6
assert the_algo.calculate_regulated_temperature(18.5, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(18.7, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(19, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(20, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(21, 10) == 20.8 # +0.8
assert the_algo.calculate_regulated_temperature(21, 10) == 20.7 # +0.7
assert the_algo.calculate_regulated_temperature(20, 10) == 20.9 # +0.7
# Test temperature external
assert the_algo.calculate_regulated_temperature(20, 12) == 20.8 # +0.8
assert the_algo.calculate_regulated_temperature(20, 15) == 20.5 # +0.5
assert the_algo.calculate_regulated_temperature(20, 18) == 20.2 # +0.2
assert the_algo.calculate_regulated_temperature(20, 20) == 20.0 # =
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)
assert the_algo
# to reset the accumulated erro
the_algo.set_target_temp(20)
assert the_algo.calculate_regulated_temperature(18, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(18.1, 10) == 21.6 # +1.6
assert the_algo.calculate_regulated_temperature(18.3, 10) == 21.6 # +1.6
assert the_algo.calculate_regulated_temperature(18.5, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(18.7, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(19, 10) == 21.7 # +1.7
assert the_algo.calculate_regulated_temperature(20, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(21, 10) == 20.8 # +0.8
assert the_algo.calculate_regulated_temperature(21, 10) == 20.7 # +0.7
assert the_algo.calculate_regulated_temperature(20, 10) == 20.9 # +0.7
# Test temperature external
assert the_algo.calculate_regulated_temperature(20, 12) == 20.8 # +0.8
assert the_algo.calculate_regulated_temperature(20, 15) == 20.5 # +0.5
assert the_algo.calculate_regulated_temperature(20, 18) == 20.2 # +0.2
assert the_algo.calculate_regulated_temperature(20, 20) == 20.0 # =
def test_pi_algorithm_medium():
""" Test the PI algorithm """
the_algo = PITemperatureRegulator(target_temp=20, kp=0.5, ki=0.1, k_ext=0.1, offset_max=3, stabilization_threshold=0.1, accumulated_error_threshold=30)
assert the_algo
# to reset the accumulated erro
the_algo.set_target_temp(20)
assert the_algo.calculate_regulated_temperature(18, 10) == 22.2
assert the_algo.calculate_regulated_temperature(18.1, 10) == 22.3
assert the_algo.calculate_regulated_temperature(18.3, 10) == 22.4
assert the_algo.calculate_regulated_temperature(18.5, 10) == 22.5
assert the_algo.calculate_regulated_temperature(18.7, 10) == 22.5
assert the_algo.calculate_regulated_temperature(19, 10) == 22.4
assert the_algo.calculate_regulated_temperature(20, 10) == 21.9
assert the_algo.calculate_regulated_temperature(21, 10) == 20.4
assert the_algo.calculate_regulated_temperature(21, 10) == 20.3
assert the_algo.calculate_regulated_temperature(20, 10) == 20.8
# Test temperature external
assert the_algo.calculate_regulated_temperature(20, 8) == 21.2
assert the_algo.calculate_regulated_temperature(20, 6) == 21.4
assert the_algo.calculate_regulated_temperature(20, 4) == 21.6
assert the_algo.calculate_regulated_temperature(20, 2) == 21.8
assert the_algo.calculate_regulated_temperature(20, 0) == 22.0
assert the_algo.calculate_regulated_temperature(20, -2) == 22.2
assert the_algo.calculate_regulated_temperature(20, -4) == 22.4
assert the_algo.calculate_regulated_temperature(20, -6) == 22.6
assert the_algo.calculate_regulated_temperature(20, -8) == 22.8
# to reset the accumulated erro
the_algo.set_target_temp(20)
# Test the error acculation effect
assert the_algo.calculate_regulated_temperature(19, 5) == 22.1
assert the_algo.calculate_regulated_temperature(19, 5) == 22.2
assert the_algo.calculate_regulated_temperature(19, 5) == 22.3
assert the_algo.calculate_regulated_temperature(19, 5) == 22.4
assert the_algo.calculate_regulated_temperature(19, 5) == 22.5
assert the_algo.calculate_regulated_temperature(19, 5) == 22.6
assert the_algo.calculate_regulated_temperature(19, 5) == 22.7
assert the_algo.calculate_regulated_temperature(19, 5) == 22.8
assert the_algo.calculate_regulated_temperature(19, 5) == 22.9
assert the_algo.calculate_regulated_temperature(19, 5) == 23
assert the_algo.calculate_regulated_temperature(19, 5) == 23
assert the_algo.calculate_regulated_temperature(19, 5) == 23
assert the_algo.calculate_regulated_temperature(19, 5) == 23
def test_pi_algorithm_strong():
""" Test the PI algorithm """
the_algo = PITemperatureRegulator(target_temp=20, kp=0.6, ki=0.2, k_ext=0.2, offset_max=4, stabilization_threshold=0.1, accumulated_error_threshold=40)
assert the_algo
# to reset the accumulated erro
the_algo.set_target_temp(20)
assert the_algo.calculate_regulated_temperature(18, 10) == 23.6
assert the_algo.calculate_regulated_temperature(18.1, 10) == 23.9
assert the_algo.calculate_regulated_temperature(18.3, 10) == 24.0
assert the_algo.calculate_regulated_temperature(18.5, 10) == 24
assert the_algo.calculate_regulated_temperature(18.7, 10) == 24
assert the_algo.calculate_regulated_temperature(19, 10) == 24
assert the_algo.calculate_regulated_temperature(20, 10) == 23.9
assert the_algo.calculate_regulated_temperature(21, 10) == 21.2
assert the_algo.calculate_regulated_temperature(21, 10) == 21
assert the_algo.calculate_regulated_temperature(21, 10) == 20.8
assert the_algo.calculate_regulated_temperature(21, 10) == 20.6
assert the_algo.calculate_regulated_temperature(21, 10) == 20.4
assert the_algo.calculate_regulated_temperature(21, 10) == 20.2
assert the_algo.calculate_regulated_temperature(21, 10) == 20
# Test temperature external
assert the_algo.calculate_regulated_temperature(20, 8) == 21.0
assert the_algo.calculate_regulated_temperature(20, 6) == 22.8
assert the_algo.calculate_regulated_temperature(20, 4) == 23.2
assert the_algo.calculate_regulated_temperature(20, 2) == 23.6
assert the_algo.calculate_regulated_temperature(20, 0) == 24
assert the_algo.calculate_regulated_temperature(20, -2) == 24
assert the_algo.calculate_regulated_temperature(20, -4) == 24
assert the_algo.calculate_regulated_temperature(20, -6) == 24
assert the_algo.calculate_regulated_temperature(20, -8) == 24
# to reset the accumulated erro
the_algo.set_target_temp(20)
# Test the error acculation effect
assert the_algo.calculate_regulated_temperature(19, 10) == 22.8
assert the_algo.calculate_regulated_temperature(19, 10) == 23
assert the_algo.calculate_regulated_temperature(19, 10) == 23.2
assert the_algo.calculate_regulated_temperature(19, 10) == 23.4
assert the_algo.calculate_regulated_temperature(19, 10) == 23.6
assert the_algo.calculate_regulated_temperature(19, 10) == 23.8
assert the_algo.calculate_regulated_temperature(19, 10) == 24
assert the_algo.calculate_regulated_temperature(19, 10) == 24
assert the_algo.calculate_regulated_temperature(19, 10) == 24
assert the_algo.calculate_regulated_temperature(19, 10) == 24
assert the_algo.calculate_regulated_temperature(19, 10) == 24

View File

@@ -1,11 +1,15 @@
# pylint: disable=wildcard-import, unused-wildcard-import, protected-access, unused-argument, line-too-long
""" Test the Security featrure """
from unittest.mock import patch, call
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
from datetime import timedelta, datetime
import logging
from custom_components.versatile_thermostat.thermostat_climate import ThermostatOverClimate
from custom_components.versatile_thermostat.thermostat_switch import ThermostatOverSwitch
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
logging.getLogger().setLevel(logging.DEBUG)
@@ -55,7 +59,7 @@ async def test_security_feature(hass: HomeAssistant, skip_hass_states_is_state):
# 1. creates a thermostat and check that security is off
now: datetime = datetime.now(tz=tz)
entity: VersatileThermostat = await create_thermostat(
entity: ThermostatOverSwitch = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity
@@ -211,7 +215,7 @@ async def test_security_over_climate(
data=PARTIAL_CLIMATE_CONFIG, # 5 minutes security delay
)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT)
fake_underlying_climate = MockClimate(hass, "mockUniqueId", "MockClimateName", {}, HVACMode.HEAT, HVACAction.HEATING)
with patch(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
@@ -230,7 +234,7 @@ async def test_security_over_climate(
if entity.entity_id == entity_id:
return entity
entity = find_my_entity("climate.theoverclimatemockname")
entity: ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity
@@ -295,11 +299,11 @@ async def test_security_over_climate(
"custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event"
) as mock_send_event, patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on"
) as mock_heater_on:
):
event_timestamp = now - timedelta(minutes=6)
await send_temperature_change_event(entity, 15, event_timestamp)
# Should stay False because a climate is never in security mode
assert entity.security_state is False
assert entity.preset_mode == 'none'
assert entity._saved_preset_mode == 'none'
assert entity._saved_preset_mode == 'none'

View File

@@ -1,5 +1,6 @@
""" Test the TPI algorithm """
from custom_components.versatile_thermostat.base_thermostat import BaseThermostat
from .commons import * # pylint: disable=wildcard-import, unused-wildcard-import
@@ -37,7 +38,7 @@ async def test_tpi_calculation(
},
)
entity: VersatileThermostat = await create_thermostat(
entity: BaseThermostat = await create_thermostat(
hass, entry, "climate.theoverswitchmockname"
)
assert entity