Compare commits

..

13 Commits

Author SHA1 Message Date
Jean-Marc Collin
6a44d0dc4a With EMA entity and slope calculation optimisations 2023-11-22 10:31:14 +00:00
Jean-Marc Collin
9e15aa48b9 Maia comments: change MAX_ALPHA to 0.5, add slope calculation at each cycle. 2023-11-21 18:56:26 +00:00
Jean-Marc Collin
ae568c8be2 Take Maia feedbacks on the algo. 2023-11-18 23:33:40 +00:00
Jean-Marc Collin
8fe4eb7ac0 15 sec between two slope calculation 2023-11-18 18:10:51 +00:00
Jean-Marc Collin
328f5f7cb0 Fix ema_temp unknown and remove slope smoothing 2023-11-18 18:08:33 +00:00
Jean-Marc Collin
41ae572875 Removes circular dependency error 2023-11-18 17:10:06 +00:00
Jean-Marc Collin
cf64109232 Add ema calculation class, calculate an emo temperature, use the ema_temperature in auto_window dectection 2023-11-18 16:32:24 +00:00
Jean-Marc Collin
7eac10ab3c Test with reset of error when changing the target temperature. For @pbranly configuration 2023-11-18 11:50:51 +00:00
Jean-Marc Collin
856f47ce03 Use HA 2023.11.2, fix regulation unit tests 2023-11-17 23:53:48 +00:00
Jean-Marc Collin
f1595f93da Issue #199 - persist and don't reset the accumulation error 2023-11-17 18:11:55 +00:00
Jean-Marc Collin
a5c548bbee Fix issue #195 - Presence management don't work for person 2023-11-17 17:24:11 +00:00
Jean-Marc Collin
1375b3c53a Feature 194 - add auto-regulation Export mode (#197)
* Add config, read and create algo
* Tests ok

---------

Co-authored-by: Jean-Marc Collin <jean-marc.collin-extern@renault.com>
2023-11-17 07:40:07 +01:00
Jean-Marc Collin
72ede4a03f Test parameters for gunmalmg AC heatpump conditions 2023-11-14 06:41:20 +00:00
27 changed files with 942 additions and 235 deletions

View File

@@ -13,6 +13,15 @@ debugpy:
wait: false
port: 5678
versatile_thermostat:
auto_regulation_expert:
kp: 0.4
ki: 0.08
k_ext: 0.0
offset_max: 5
stabilization_threshold: 0.1
accumulated_error_threshold: 50
input_number:
fake_temperature_sensor1:
name: Temperature

View File

@@ -23,7 +23,9 @@
"ryanluker.vscode-coverage-gutters",
"ms-python.black-formatter",
"ms-python.pylint",
"ferrierbenjamin.fold-unfold-all-icone"
"ferrierbenjamin.fold-unfold-all-icone",
"ms-python.isort",
"LittleFoxTeam.vscode-python-test-adapter"
],
// "mounts": [
// "source=${localWorkspaceFolder}/.devcontainer/configuration.yaml,target=${localWorkspaceFolder}/config/www/community/,type=bind,consistency=cached",

View File

@@ -22,6 +22,7 @@
- [Pour un thermostat de type ```thermostat_over_switch```](#pour-un-thermostat-de-type-thermostat_over_switch)
- [Pour un thermostat de type ```thermostat_over_climate```:](#pour-un-thermostat-de-type-thermostat_over_climate)
- [L'auto-régulation](#lauto-régulation)
- [L'auto-régulation en mode Expert](#lauto-régulation-en-mode-expert)
- [Pour un thermostat de type ```thermostat_over_valve```:](#pour-un-thermostat-de-type-thermostat_over_valve)
- [Configurez les coefficients de l'algorithme TPI](#configurez-les-coefficients-de-lalgorithme-tpi)
- [Configurer la température préréglée](#configurer-la-température-préréglée)
@@ -63,13 +64,14 @@ Ce composant personnalisé pour Home Assistant est une mise à niveau et est une
> ![Nouveau](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/new-icon.png?raw=true) _*Nouveautés*_
> * **Release 4.1** : Ajout d'un mode de régulation **Expert** dans lequel l'utilisateur peut spécifier ses propres paramètres d'auto-régulation au lieu d'utiliser les pre-programmés [#194](https://github.com/jmcollin78/versatile_thermostat/issues/194).
> * **Release 4.0** : Ajout de la prise en charge de la **Versatile Thermostat UI Card**. Voir [Versatile Thermostat UI Card](https://github.com/jmcollin78/versatile-thermostat-ui-card). Ajout d'un mode de régulation **Slow** pour les appareils de chauffage à latence lente [#168](https://github.com/jmcollin78/versatile_thermostat/issues/168). Changement de la façon dont **la puissance est calculée** dans le cas de VTherm avec des équipements multi-sous-jacents [#146](https://github.com/jmcollin78/versatile_thermostat/issues/146). Ajout de la prise en charge de AC et Heat pour VTherm via un interrupteur également [#144](https://github.com/jmcollin78/versatile_thermostat/pull/144)
> * **Release 3.8**: Ajout d'une **fonction d'auto-régulation** pour les thermostats `over climate` dont la régulation est faite par le climate sous-jacent. Cf. [L'auto-régulation](#lauto-régulation) et [#129](https://github.com/jmcollin78/versatile_thermostat/issues/129). Ajout de la **possibilité d'inverser la commande** pour un thermostat `over switch` pour adresser les installations avec fil pilote et diode [#124](https://github.com/jmcollin78/versatile_thermostat/issues/124).
> * **Release 3.7**: Ajout du type de **Versatile Thermostat `over valve`** pour piloter une vanne TRV directement ou tout autre équipement type gradateur pour le chauffage. La régulation se fait alors directement en agissant sur le pourcentage d'ouverture de l'entité sous-jacente : 0 la vanne est coupée, 100 : la vanne est ouverte à fond. Cf. [#131](https://github.com/jmcollin78/versatile_thermostat/issues/131). Ajout d'une fonction permettant le bypass de la détection d'ouverture [#138](https://github.com/jmcollin78/versatile_thermostat/issues/138). Ajout de la langue Slovaque
> * **Release 3.6**: Ajout du paramètre `motion_off_delay` pour améliorer la gestion de des mouvements [#116](https://github.com/jmcollin78/versatile_thermostat/issues/116), [#128](https://github.com/jmcollin78/versatile_thermostat/issues/128). Ajout du mode AC (air conditionné) pour un VTherm over switch. Préparation du projet Github pour faciliter les contributions [#127](https://github.com/jmcollin78/versatile_thermostat/issues/127)
<details>
<summary>Autres versions</summary>
> * **Release 3.6**: Ajout du paramètre `motion_off_delay` pour améliorer la gestion de des mouvements [#116](https://github.com/jmcollin78/versatile_thermostat/issues/116), [#128](https://github.com/jmcollin78/versatile_thermostat/issues/128). Ajout du mode AC (air conditionné) pour un VTherm over switch. Préparation du projet Github pour faciliter les contributions [#127](https://github.com/jmcollin78/versatile_thermostat/issues/127)
> * **Release 3.5**: Plusieurs thermostats sont possibles en "thermostat over climate" mode [#113](https://github.com/jmcollin78/versatile_thermostat/issues/113)
> * **Release 3.4**: bug fix et exposition des preset temperatures pour le mode AC [#103](https://github.com/jmcollin78/versatile_thermostat/issues/103)
> * **Release 3.3**: ajout du mode Air Conditionné (AC). Cette fonction vous permet d'utiliser le mode AC de votre thermostat sous-jacent. Pour l'utiliser, vous devez cocher l'option "Uitliser le mode AC" et définir les valeurs de température pour les presets et pour les presets en cas d'absence
@@ -204,8 +206,8 @@ Il est possible de choisir un thermostat over climate qui commande une climatisa
#### L'auto-régulation
Depuis la release 3.8, vous avez la possibilité d'activer la fonction d'auto-régulation. Cette fonction autorise VersatileThermostat à adapter la consigne de température donnée au climate sous-jacent afin que la température de la pièce atteigne réellement la consigne.
Pour faire ça, le VersatileThermostat calcule un décalage basé sur les informations suivantes :
1. la différence actuelle entre la température réelle et la température de consigne,
2. l'accumulation des différences passées,
1. la différence actuelle entre la température réelle et la température de consigne, appelé erreur brute,
2. l'accumulation des erreurs passées,
3. la différence entre la température extérieure et la consigne
Ces trois informations sont combinées pour calculer le décalage qui sera ajouté à la consigne courante et envoyé au climate sous-jacent.
@@ -224,10 +226,89 @@ Ces trois paramètres permettent de moduler la régulation et éviter de multipl
> 1. Ne démarrez pas tout de suite l'auto-régulation. Regardez comment se passe la régulation naturelle de votre équipement. Si vous constatez que la température de consigne n'est pas atteinte ou qu'elle met trop de temps à être atteinte, démarrez la régulation,
> 2. D'abord commencez par une légère auto-régulation et gardez les deux paramètres avec leur valeurs par défaut. Attendez quelques jours et vérifiez si la situation s'est améliorée,
> 3. Si ce n'est pas suffisant, passez en auto-régulation Medium, attendez une stabilisation,
> 4. Si ce n'est toujours pas suffisant, passez en auto-régulation Forte.
> 4. Si ce n'est toujours pas suffisant, passez en auto-régulation Forte,
> 5. Si ce n'est toujours pas bon, il faudra passer en mode expert pour pouvoir régler les paramètres de régulation de façon fine. Voir en-dessous
L'auto-régulation consiste à forcer l'équipement a aller plus loin en lui forçant sa température de consigne régulièrement. Sa consommation peut donc être augmentée, ainsi que son usure.
#### L'auto-régulation en mode Expert
En mode **Expert** pouvez régler finement les paramètres de l'auto-régulation pour atteindre vos objeetifs et optimiser au mieux. L'algorithme calcule l'écart entre la consigne et la température réelle de la pièce. Cet écard est appelé erreur.
Les paramètres réglables sont les suivants :
1. `kp` : le facteur appliqué à l'erreur brute,
2. `ki` : le facteur appliqué à l'accumulation des erreurs,
3. `k_ext` : le facteur appliqué à la différence entre la température intérieure et la température externe,
4. `offset_max` : le maximum de correction (offset) que la régulation peut appliquer,
5. `stabilization_threshold` : un seuil de stabilisation qui lorsqu'il est atteint par l'erreur remet à 0, l'accumulation des erreurs,
6. `accumulated_error_threshold` : le maximum pour l'accumulation d'erreur.
Pour le tuning il faut tenir compte de ces observations :
1. `kp * erreur` va donner l'offset lié à l'erreur brute. Cet offset est directement proportionnel à l'erreur et sera à 0 lorsque la target sera atteinte,
2. l'accumulation de l'erreur permet de corriger le stabilisation de la courbe alors qu'il reste une erreur. L'erreur s'accumule et l'offset augmente donc progressivement ce qui devrait finir par stabiliser sur la température cible. Pour que ce paramètre fondamental est un effet il faut qu'il soit pas trop petit. Une valeur moyenne est 30
3. `ki * accumulated_error_threshold` va donner l'offset maximal lié à l'accumulation de l'erreur,
4. `k_ext` permet d'appliquer tout de suite (sans attendre une accumulation des erreurs) une correction lorsque la température extérieure est très différente de la température cible. Si la stabilisation se fait trop haut lorsqu'il les écarts de température sont importants, c'est que ce paramètre est trop fort. Il devrait pouvoir être annulé totalement pour laisser faire les 2 premiers offset
Les valeurs préprogrammées sont les suivantes :
Slow régulation :
kp: 0.2 # 20% of the current internal regulation offset are caused by the current difference of target temperature and room temperature
ki: 0.8 / 288.0 # 80% of the current internal regulation offset are caused by the average offset of the past 24 hours
k_ext: 1.0 / 25.0 # this will add 1°C to the offset when it's 25°C colder outdoor than indoor
offset_max: 2.0 # limit to a final offset of -2°C to +2°C
stabilization_threshold: 0.0 # this needs to be disabled as otherwise the long term accumulated error will always be reset when the temp briefly crosses from/to below/above the target
accumulated_error_threshold: 2.0 * 288 # this allows up to 2°C long term offset in both directions
Light régulation :
kp: 0.2
ki: 0.05
k_ext: 0.05
offset_max: 1.5
stabilization_threshold: 0.1
accumulated_error_threshold: 10
Medium régulation :
kp: 0.3
ki: 0.05
k_ext: 0.1
offset_max: 2
stabilization_threshold: 0.1
accumulated_error_threshold: 20
Strong régulation :
"""Strong parameters for regulation
A set of parameters which doesn't take into account the external temp
and concentrate to internal temp error + accumulated error.
This should work for cold external conditions which else generates
high external_offset"""
kp: 0.4
ki: 0.08
k_ext: 0.0
offset_max: 5
stabilization_threshold: 0.1
accumulated_error_threshold: 50
Pour utiliser le mode Expert il vous faut déclarer les valeurs que vous souhaitez utiliser pour chacun de ces paramètres dans votre `configuration.yaml` sous la forme suivante :
```
versatile_thermostat:
auto_regulation_expert:
kp: 0.4
ki: 0.08
k_ext: 0.0
offset_max: 5
stabilization_threshold: 0.1
accumulated_error_threshold: 50
```
et bien sur, configurer le mode auto-régulation du VTherm en mode Expert. Tous les VTherm en mode **Expert** utiliseront ces mêmes paramètres.
Pour que les modifications soient prises en compte, il faut soit **relancer totalement Home Assistant** soit juste l'intégration Versatile Thermostat (Outils de dev / Yaml / rechargement de la configuration / Versatile Thermostat).
### Pour un thermostat de type ```thermostat_over_valve```:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity3.png?raw=true)
Vous pouvez choisir jusqu'à entité du domaine ```number``` ou ```ìnput_number``` qui vont commander les vannes.

View File

@@ -22,6 +22,7 @@
- [For a ```thermostat_over_switch``` type thermostat](#for-a-thermostat_over_switch-type-thermostat)
- [For a thermostat of type ```thermostat_over_climate```:](#for-a-thermostat-of-type-thermostat_over_climate)
- [Self-regulation](#self-regulation)
- [Self-regulation in Expert mode](#self-regulation-in-expert-mode)
- [For a thermostat of type ```thermostat_over_valve```:](#for-a-thermostat-of-type-thermostat_over_valve)
- [Configure the TPI algorithm coefficients](#configure-the-tpi-algorithm-coefficients)
- [Configure the preset temperature](#configure-the-preset-temperature)
@@ -61,13 +62,14 @@
This custom component for Home Assistant is an upgrade and is a complete rewrite of the component "Awesome thermostat" (see [Github](https://github.com/dadge/awesome_thermostat)) with addition of features.
>![New](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/new-icon.png?raw=true) _*News*_
> * **Release 4.1**: Added an **Expert** regulation mode in which the user can specify their own auto-regulation parameters instead of using the pre-programmed ones [#194]( https://github.com/jmcollin78/versatile_thermostat/issues/194).
> * **Release 4.0**: Added the support of **Versatile Thermostat UI Card**. See [Versatile Thermostat UI Card](https://github.com/jmcollin78/versatile-thermostat-ui-card). Added a **Slow** regulation mode for slow latency heating devices [#168](https://github.com/jmcollin78/versatile_thermostat/issues/168). Change the way **the power is calculated** in case of VTherm with multi-underlying equipements [#146](https://github.com/jmcollin78/versatile_thermostat/issues/146). Added the support of AC and Heat for VTherm over switch alse [#144](https://github.com/jmcollin78/versatile_thermostat/pull/144)
> * **Release 3.8**: Added a **self-regulation function** for `over climate` thermostats whose regulation is done by the underlying climate. See [Self-regulation](#self-regulation) and [#129](https://github.com/jmcollin78/versatile_thermostat/issues/129). Added the possibility of **inverting the command** for an `over switch` thermostat to address installations with pilot wire and diode [#124](https://github.com/jmcollin78/versatile_thermostat/issues/124).
> * **Release 3.7**: Addition of the **Versatile Thermostat type `over valve`** to control a TRV valve directly or any other dimmer type equipment for heating. Regulation is then done directly by acting on the opening percentage of the underlying entity: 0 the valve is cut off, 100: the valve is fully opened. See [#131](https://github.com/jmcollin78/versatile_thermostat/issues/131). Added a function allowing the bypass of opening detection [#138](https://github.com/jmcollin78/versatile_thermostat/issues/138). Added Slovak language
> * **Release 3.6**: Added the `motion_off_delay` parameter to improve motion management [#116](https://github.com/jmcollin78/versatile_thermostat/issues/116), [#128](https://github.com/jmcollin78/versatile_thermostat/issues/128). Added AC (air conditioning) mode for a VTherm over switch. Preparing the Github project to facilitate contributions [#127](https://github.com/jmcollin78/versatile_thermostat/issues/127)
<details>
<summary>Others releases</summary>
> * **Release 3.6**: Added the `motion_off_delay` parameter to improve motion management [#116](https://github.com/jmcollin78/versatile_thermostat/issues/116), [#128](https://github.com/jmcollin78/versatile_thermostat/issues/128). Added AC (air conditioning) mode for a VTherm over switch. Preparing the Github project to facilitate contributions [#127](https://github.com/jmcollin78/versatile_thermostat/issues/127)
> * **Release 3.5**: Multiple thermostats when using "thermostat over another thermostat" mode [#113](https://github.com/jmcollin78/versatile_thermostat/issues/113)
> * **Release 3.4**: bug fixes and expose preset temperatures for AC mode [#103](https://github.com/jmcollin78/versatile_thermostat/issues/103)
> * **Release 3.3**: add the Air Conditionned mode (AC). This feature allow to use the eventual AC mode of your underlying climate entity. You have to check the "Use AC mode" checkbox in configuration and give preset temperature value for AC mode and AC mode when absent if absence is configured
@@ -221,10 +223,87 @@ These three parameters make it possible to modulate the regulation and avoid mul
> 1. Do not start self-regulation straight away. Watch how the natural regulation of your equipment works. If you notice that the set temperature is not reached or that it is taking too long to be reached, start the regulation,
> 2. First start with a slight self-regulation and keep both parameters at their default values. Wait a few days and check if the situation has improved,
> 3. If this is not sufficient, switch to Medium self-regulation, wait for stabilization,
> 4. If this is still not sufficient, switch to Strong self-regulation.
> 4. If this is still not sufficient, switch to Strong self-regulation,
> 5. If it is still not good, you will have to switch to expert mode to be able to finely adjust the regulation parameters. See below.
Self-regulation consists of forcing the equipment to go further by forcing its set temperature regularly. Its consumption can therefore be increased, as well as its wear.
#### Self-regulation in Expert mode
In **Expert** mode you can finely adjust the auto-regulation parameters to achieve your objectives and optimize as best as possible. The algorithm calculates the difference between the setpoint and the actual temperature of the room. This discrepancy is called error.
The adjustable parameters are as follows:
1. `kp`: the factor applied to the raw error,
2. `ki`: the factor applied to the accumulation of errors,
3. `k_ext`: the factor applied to the difference between the interior temperature and the exterior temperature,
4. `offset_max`: the maximum correction (offset) that the regulation can apply,
5. `stabilization_threshold`: a stabilization threshold which, when reached by the error, resets the accumulation of errors to 0,
6. `accumulated_error_threshold`: the maximum for error accumulation.
For tuning, these observations must be taken into account:
1. `kp * error` will give the offset linked to the raw error. This offset is directly proportional to the error and will be 0 when the target is reached,
2. the accumulation of the error makes it possible to correct the stabilization of the curve while there remains an error. The error accumulates and the offset therefore gradually increases which should eventually stabilize at the target temperature. For this fundamental parameter to have an effect it must not be too small. An average value is 30
3. `ki * accumulated_error_threshold` will give the maximum offset linked to the accumulation of the error,
4. `k_ext` allows a correction to be applied immediately (without waiting for errors to accumulate) when the outside temperature is very different from the target temperature. If the stabilization is done too high when the temperature differences are significant, it is because this parameter is too high. It should be possible to cancel completely to let the first 2 offsets take place
The pre-programmed values are as follows:
Slow régulation :
kp: 0.2 # 20% of the current internal regulation offset are caused by the current difference of target temperature and room temperature
ki: 0.8 / 288.0 # 80% of the current internal regulation offset are caused by the average offset of the past 24 hours
k_ext: 1.0 / 25.0 # this will add 1°C to the offset when it's 25°C colder outdoor than indoor
offset_max: 2.0 # limit to a final offset of -2°C to +2°C
stabilization_threshold: 0.0 # this needs to be disabled as otherwise the long term accumulated error will always be reset when the temp briefly crosses from/to below/above the target
accumulated_error_threshold: 2.0 * 288 # this allows up to 2°C long term offset in both directions
Light régulation :
kp: 0.2
ki: 0.05
k_ext: 0.05
offset_max: 1.5
stabilization_threshold: 0.1
accumulated_error_threshold: 10
Medium régulation :
kp: 0.3
ki: 0.05
k_ext: 0.1
offset_max: 2
stabilization_threshold: 0.1
accumulated_error_threshold: 20
Strong régulation :
"""Strong parameters for regulation
A set of parameters which doesn't take into account the external temp
and concentrate to internal temp error + accumulated error.
This should work for cold external conditions which else generates
high external_offset"""
kp: 0.4
ki: 0.08
k_ext: 0.0
offset_max: 5
stabilization_threshold: 0.1
accumulated_error_threshold: 50
To use Expert mode you must declare the values you want to use for each of these parameters in your `configuration.yaml` in the following form:
```
versatile_thermostat:
auto_regulation_expert:
kp: 0.4
ki: 0.08
k_ext: 0.0
offset_max: 5
stabilization_threshold: 0.1
accumulated_error_threshold: 50
```
and of course, configure the VTherm's self-regulation mode in **Expert** mode. All VTherms in Expert mode will use these same settings.
For the changes to be taken into account, you must either **completely restart Home Assistant** or just the **Versatile Thermostat integration** (Dev tools / Yaml / reloading the configuration / Versatile Thermostat).
### For a thermostat of type ```thermostat_over_valve```:
![image](https://github.com/jmcollin78/versatile_thermostat/blob/main/images/config-linked-entity3.png?raw=true)
You can choose up to domain entity ```number``` or ```ìnput_number``` which will control the valves.

View File

@@ -4,16 +4,84 @@ from __future__ import annotations
from typing import Dict
import logging
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigType
from homeassistant.core import HomeAssistant
from .base_thermostat import BaseThermostat
from .const import DOMAIN, PLATFORMS
from .const import (
DOMAIN,
PLATFORMS,
CONF_AUTO_REGULATION_LIGHT,
CONF_AUTO_REGULATION_MEDIUM,
CONF_AUTO_REGULATION_STRONG,
CONF_AUTO_REGULATION_SLOW,
CONF_AUTO_REGULATION_EXPERT,
)
from .vtherm_api import VersatileThermostatAPI
_LOGGER = logging.getLogger(__name__)
SELF_REGULATION_PARAM_SCHEMA = (
vol.Schema(
{
vol.Required("kp"): vol.Coerce(float),
vol.Required("ki"): vol.Coerce(float),
vol.Required("k_ext"): vol.Coerce(float),
vol.Required("offset_max"): vol.Coerce(float),
vol.Required("stabilization_threshold"): vol.Coerce(float),
vol.Required("accumulated_error_threshold"): vol.Coerce(float),
}
),
)
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
{
CONF_AUTO_REGULATION_EXPERT: vol.Schema(
{
vol.Required("kp"): vol.Coerce(float),
vol.Required("ki"): vol.Coerce(float),
vol.Required("k_ext"): vol.Coerce(float),
vol.Required("offset_max"): vol.Coerce(float),
vol.Required("stabilization_threshold"): vol.Coerce(float),
vol.Required("accumulated_error_threshold"): vol.Coerce(float),
}
),
}
),
},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(
hass: HomeAssistant, config: ConfigType
): # pylint: disable=unused-argument
"""Initialisation de l'intégration"""
_LOGGER.info(
"Initializing %s integration with config: %s",
DOMAIN,
config.get(DOMAIN),
)
hass.data.setdefault(DOMAIN, {})
# L'argument config contient votre fichier configuration.yaml
vtherm_config = config.get(DOMAIN)
if vtherm_config is not None:
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
api.set_global_config(vtherm_config)
else:
_LOGGER.info("No global config from configuration.yaml available")
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Versatile Thermostat from a config entry."""
@@ -24,11 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry.data,
)
# hass.data.setdefault(DOMAIN, {})
api: VersatileThermostatAPI = hass.data.get(DOMAIN)
if api is None:
api = VersatileThermostatAPI(hass)
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
api.add_entry(entry)
@@ -46,7 +110,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
api: VersatileThermostatAPI = hass.data.get(DOMAIN)
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(hass)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
if api:
@@ -55,43 +119,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return unload_ok
class VersatileThermostatAPI(dict):
"""The VersatileThermostatAPI"""
_hass: HomeAssistant
# _entries: Dict(str, ConfigEntry)
def __init__(self, hass: HomeAssistant) -> None:
_LOGGER.debug("building a VersatileThermostatAPI")
super().__init__()
self._hass = hass
# self._entries = dict()
# Add the API in hass.data
self._hass.data[DOMAIN] = self
def add_entry(self, entry: ConfigEntry):
"""Add a new entry"""
_LOGGER.debug("Add the entry %s", entry.entry_id)
# self._entries[entry.entry_id] = entry
# Add the entry in hass.data
self._hass.data[DOMAIN][entry.entry_id] = entry
def remove_entry(self, entry: ConfigEntry):
"""Remove an entry"""
_LOGGER.debug("Remove the entry %s", entry.entry_id)
# self._entries.pop(entry.entry_id)
self._hass.data[DOMAIN].pop(entry.entry_id)
# If not more entries are preset, remove the API
if len(self) == 0:
_LOGGER.debug("No more entries-> Remove the API from DOMAIN")
self._hass.data.pop(DOMAIN)
@property
def hass(self):
"""Get the HomeAssistant object"""
return self._hass
# Example migration function
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Migrate old entry."""

View File

@@ -113,10 +113,17 @@ from .underlyings import UnderlyingEntity
from .prop_algorithm import PropAlgorithm
from .open_window_algorithm import WindowOpenDetectionAlgorithm
from .ema import ExponentialMovingAverage
_LOGGER = logging.getLogger(__name__)
def get_tz(hass: HomeAssistant):
"""Get the current timezone"""
return dt_util.get_time_zone(hass.config.time_zone)
class BaseThermostat(ClimateEntity, RestoreEntity):
"""Representation of a base class for all Versatile Thermostat device."""
@@ -246,6 +253,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._underlyings = []
self._ema_temp = None
self._ema_algo = None
self.post_init(entry_infos)
def post_init(self, entry_infos):
@@ -450,6 +459,15 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._total_energy = 0
self._ema_algo = ExponentialMovingAverage(
self.name,
self._cycle_min * 60,
# Needed for time calculation
get_tz(self._hass),
# two digits after the coma for temperature slope calculation
2,
)
_LOGGER.debug(
"%s - Creation of a new VersatileThermostat entity: unique_id=%s",
self,
@@ -693,6 +711,12 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
EVENT_HOMEASSISTANT_START, _async_startup_internal
)
def restore_specific_previous_state(self, old_state):
"""Should be overriden in each specific thermostat
if a specific previous state or attribute should be
restored
"""
async def get_my_previous_state(self):
"""Try to get my previou state"""
# Check If we have an old state
@@ -738,6 +762,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
old_total_energy = old_state.attributes.get(ATTR_TOTAL_ENERGY)
if old_total_energy:
self._total_energy = old_total_energy
self.restore_specific_previous_state(old_state)
else:
# No previous state, try and restore defaults
if self._target_temp is None:
@@ -854,6 +880,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"""Return the unit of measurement."""
return self._unit
@property
def ema_temperature(self) -> str:
"""Return the EMA temperature."""
return self._ema_temp
@property
def hvac_mode(self) -> HVACMode | None:
"""Return current operation."""
@@ -1468,6 +1499,11 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._last_temperature_mesure = self.get_state_date_or_now(state)
# calculate the smooth_temperature with EMA calculation
self._ema_temp = self._ema_algo.calculate_ema(
self._cur_temp, self._last_temperature_mesure
)
_LOGGER.debug(
"%s - After setting _last_temperature_mesure %s , state.last_changed.replace=%s",
self,
@@ -1578,7 +1614,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
async def _async_update_presence(self, new_state):
_LOGGER.info("%s - Updating presence. New state is %s", self, new_state)
self._presence_state = new_state
self._presence_state = (
STATE_ON if new_state in (STATE_ON, STATE_HOME) else STATE_OFF
)
if self._attr_preset_mode in HIDDEN_PRESETS or self._presence_on is False:
_LOGGER.info(
"%s - Ignoring presence change cause in Power or Security preset or presence not configured",
@@ -1638,7 +1676,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
for under in self._underlyings:
await under.turn_off()
async def _async_manage_window_auto(self):
async def _async_manage_window_auto(self, in_cycle=False):
"""The management of the window auto feature"""
async def dearm_window_auto(_):
@@ -1668,9 +1706,17 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
if not self._window_auto_algo:
return
slope = self._window_auto_algo.add_temp_measurement(
temperature=self._cur_temp, datetime_measure=self._last_temperature_mesure
)
if in_cycle:
slope = self._window_auto_algo.check_age_last_measurement(
temperature=self._ema_temp,
datetime_now=datetime.now(get_tz(self._hass)),
)
else:
slope = self._window_auto_algo.add_temp_measurement(
temperature=self._ema_temp,
datetime_measure=self._last_temperature_mesure,
)
_LOGGER.debug(
"%s - Window auto is on, check the alert. last slope is %.3f",
self,
@@ -2019,6 +2065,9 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
self._attr_preset_mode,
)
# check auto_window conditions
await self._async_manage_window_auto(in_cycle=True)
# Issue 56 in over_climate mode, if the underlying climate is not initialized, try to initialize it
for under in self._underlyings:
if not under.is_initialized:
@@ -2145,6 +2194,7 @@ class BaseThermostat(ClimateEntity, RestoreEntity):
"max_power_sensor_entity_id": self._max_power_sensor_entity_id,
"temperature_unit": self.temperature_unit,
"is_device_active": self.is_device_active,
"ema_temp": self._ema_temp,
}
@callback

View File

@@ -17,6 +17,7 @@ from homeassistant.exceptions import HomeAssistantError
from .prop_algorithm import (
PROPORTIONAL_FUNCTION_TPI,
)
PRESET_AC_SUFFIX = "_ac"
PRESET_ECO_AC = PRESET_ECO + PRESET_AC_SUFFIX
PRESET_COMFORT_AC = PRESET_COMFORT + PRESET_AC_SUFFIX
@@ -83,15 +84,16 @@ 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_SLOW= "auto_regulation_slow"
CONF_AUTO_REGULATION_LIGHT= "auto_regulation_light"
CONF_AUTO_REGULATION_MEDIUM= "auto_regulation_medium"
CONF_AUTO_REGULATION_STRONG= "auto_regulation_strong"
CONF_AUTO_REGULATION_DTEMP="auto_regulation_dtemp"
CONF_AUTO_REGULATION_PERIOD_MIN="auto_regulation_periode_min"
CONF_INVERSE_SWITCH="inverse_switch_command"
CONF_AUTO_REGULATION_MODE = "auto_regulation_mode"
CONF_AUTO_REGULATION_NONE = "auto_regulation_none"
CONF_AUTO_REGULATION_SLOW = "auto_regulation_slow"
CONF_AUTO_REGULATION_LIGHT = "auto_regulation_light"
CONF_AUTO_REGULATION_MEDIUM = "auto_regulation_medium"
CONF_AUTO_REGULATION_STRONG = "auto_regulation_strong"
CONF_AUTO_REGULATION_EXPERT = "auto_regulation_expert"
CONF_AUTO_REGULATION_DTEMP = "auto_regulation_dtemp"
CONF_AUTO_REGULATION_PERIOD_MIN = "auto_regulation_periode_min"
CONF_INVERSE_SWITCH = "inverse_switch_command"
CONF_PRESETS = {
p: f"{p}_temp"
@@ -196,7 +198,7 @@ ALL_CONF = (
CONF_AUTO_REGULATION_MODE,
CONF_AUTO_REGULATION_DTEMP,
CONF_AUTO_REGULATION_PERIOD_MIN,
CONF_INVERSE_SWITCH
CONF_INVERSE_SWITCH,
]
+ CONF_PRESETS_VALUES
+ CONF_PRESETS_AWAY_VALUES
@@ -208,9 +210,20 @@ 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_AUTO_REGULATION_SLOW]
CONF_AUTO_REGULATION_MODES = [
CONF_AUTO_REGULATION_NONE,
CONF_AUTO_REGULATION_LIGHT,
CONF_AUTO_REGULATION_MEDIUM,
CONF_AUTO_REGULATION_STRONG,
CONF_AUTO_REGULATION_SLOW,
CONF_AUTO_REGULATION_EXPERT,
]
CONF_THERMOSTAT_TYPES = [CONF_THERMOSTAT_SWITCH, CONF_THERMOSTAT_CLIMATE, CONF_THERMOSTAT_VALVE]
CONF_THERMOSTAT_TYPES = [
CONF_THERMOSTAT_SWITCH,
CONF_THERMOSTAT_CLIMATE,
CONF_THERMOSTAT_VALVE,
]
SUPPORT_FLAGS = ClimateEntityFeature.TARGET_TEMPERATURE
@@ -226,54 +239,73 @@ DEFAULT_SECURITY_DEFAULT_ON_PERCENT = 0.1
ATTR_TOTAL_ENERGY = "total_energy"
ATTR_MEAN_POWER_CYCLE = "mean_cycle_power"
# A special regulation parameter suggested by @Maia here: https://github.com/jmcollin78/versatile_thermostat/discussions/154
class RegulationParamSlow:
""" Light parameters for slow latency regulation"""
kp:float = 0.2 # 20% of the current internal regulation offset are caused by the current difference of target temperature and room temperature
ki:float = 0.8 / 288.0 # 80% of the current internal regulation offset are caused by the average offset of the past 24 hours
k_ext:float = 1.0 / 25.0 # this will add 1°C to the offset when it's 25°C colder outdoor than indoor
offset_max:float = 2.0 # limit to a final offset of -2°C to +2°C
stabilization_threshold:float = 0.0 # this needs to be disabled as otherwise the long term accumulated error will always be reset when the temp briefly crosses from/to below/above the target
accumulated_error_threshold:float = 2.0 * 288 # this allows up to 2°C long term offset in both directions
"""Light parameters for slow latency regulation"""
kp: float = 0.2 # 20% of the current internal regulation offset are caused by the current difference of target temperature and room temperature
ki: float = (
0.8 / 288.0
) # 80% of the current internal regulation offset are caused by the average offset of the past 24 hours
k_ext: float = (
1.0 / 25.0
) # this will add 1°C to the offset when it's 25°C colder outdoor than indoor
offset_max: float = 2.0 # limit to a final offset of -2°C to +2°C
stabilization_threshold: float = 0.0 # this needs to be disabled as otherwise the long term accumulated error will always be reset when the temp briefly crosses from/to below/above the target
accumulated_error_threshold: float = (
2.0 * 288
) # this allows up to 2°C long term offset in both directions
class RegulationParamLight:
""" Light parameters for regulation"""
kp:float = 0.2
ki:float = 0.05
k_ext:float = 0.05
offset_max:float = 1.5
stabilization_threshold:float = 0.1
accumulated_error_threshold:float = 10
"""Light parameters for regulation"""
kp: float = 0.2
ki: float = 0.05
k_ext: float = 0.05
offset_max: float = 1.5
stabilization_threshold: float = 0.1
accumulated_error_threshold: float = 10
class RegulationParamMedium:
""" Light parameters for regulation"""
kp:float = 0.3
ki:float = 0.05
k_ext:float = 0.1
offset_max:float = 2
stabilization_threshold:float = 0.1
accumulated_error_threshold:float = 20
"""Light parameters for regulation"""
kp: float = 0.3
ki: float = 0.05
k_ext: float = 0.1
offset_max: float = 2
stabilization_threshold: float = 0.1
accumulated_error_threshold: float = 20
class RegulationParamStrong:
""" 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
"""Strong parameters for regulation
A set of parameters which doesn't take into account the external temp
and concentrate to internal temp error + accumulated error.
This should work for cold external conditions which else generates
high external_offset"""
kp: float = 0.4
ki: float = 0.08
k_ext: float = 0.0
offset_max: float = 5
stabilization_threshold: float = 0.1
accumulated_error_threshold: float = 50
# Not used now
class RegulationParamVeryStrong:
""" 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
"""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"""
@@ -293,8 +325,10 @@ class UnknownEntity(HomeAssistantError):
class WindowOpenDetectionMethod(HomeAssistantError):
"""Error to indicate there is an error in the window open detection method given."""
class overrides: # pylint: disable=invalid-name
""" An annotation to inform overrides """
class overrides: # pylint: disable=invalid-name
"""An annotation to inform overrides"""
def __init__(self, func):
self.func = func

View File

@@ -0,0 +1,85 @@
# pylint: disable=line-too-long
"""The Estimated Mobile Average calculation used for temperature slope
and maybe some others feature"""
import logging
import math
from datetime import datetime, tzinfo
_LOGGER = logging.getLogger(__name__)
MIN_TIME_DECAY_SEC = 0
# As for the EMA calculation of irregular time series, I've seen that it might be useful to
# have an upper limit for alpha in case the last measurement was too long ago.
# For example when using a half life of 10 minutes a measurement that is 60 minutes ago
# (if there's nothing inbetween) would contribute to the smoothed value with 1,5%,
# giving the current measurement 98,5% relevance. It could be wise to limit the alpha to e.g. 4x the half life (=0.9375).
MAX_ALPHA = 0.5
class ExponentialMovingAverage:
"""A class that will do the Estimated Mobile Average calculation"""
def __init__(
self, vterm_name: str, halflife: float, timezone: tzinfo, precision: int = 3
):
"""The halflife is the duration in secondes of a normal cycle"""
self._halflife: float = halflife
self._timezone = timezone
self._current_ema: float = None
self._last_timestamp: datetime = datetime.now(self._timezone)
self._name = vterm_name
self._precision = precision
def __str__(self) -> str:
return f"EMA-{self._name}"
def calculate_ema(self, measurement: float, timestamp: datetime) -> float | None:
"""Calculate the new EMA from a new measurement measured at timestamp
Return the EMA or None if all parameters are not initialized now
"""
if measurement is None or timestamp is None:
_LOGGER.warning(
"%s - Cannot calculate EMA: measurement and timestamp are mandatory. This message can be normal at startup but should not persist",
self,
)
return measurement
if self._current_ema is None:
_LOGGER.debug(
"%s - First init of the EMA",
self,
)
self._current_ema = measurement
self._last_timestamp = timestamp
return self._current_ema
time_decay = (timestamp - self._last_timestamp).total_seconds()
if time_decay < MIN_TIME_DECAY_SEC:
_LOGGER.debug(
"%s - time_decay %s is too small (< %s). Forget the measurement",
self,
time_decay,
MIN_TIME_DECAY_SEC,
)
return self._current_ema
alpha = 1 - math.exp(math.log(0.5) * time_decay / self._halflife)
# capping alpha to avoid gap if last measurement was long time ago
alpha = min(alpha, MAX_ALPHA)
new_ema = alpha * measurement + (1 - alpha) * self._current_ema
self._last_timestamp = timestamp
self._current_ema = new_ema
_LOGGER.debug(
"%s - timestamp=%s alpha=%.2f measurement=%.2f current_ema=%.2f new_ema=%.2f",
self,
timestamp,
alpha,
measurement,
self._current_ema,
new_ema,
)
return round(self._current_ema, self._precision)

View File

@@ -14,6 +14,6 @@
"quality_scale": "silver",
"requirements": [],
"ssdp": [],
"version": "4.0.0",
"version": "4.2.0",
"zeroconf": []
}

View File

@@ -1,3 +1,4 @@
# pylint: disable=line-too-long
""" This file implements the Open Window by temperature algorithm
This algo works the following way:
- each time a new temperature is measured
@@ -12,9 +13,13 @@ from datetime import datetime
_LOGGER = logging.getLogger(__name__)
# To filter bad values
MIN_DELTA_T_SEC = 30 # two temp mesure should be > 10 sec
MIN_DELTA_T_SEC = 0 # two temp mesure should be > 0 sec
MAX_SLOPE_VALUE = 2 # slope cannot be > 2 or < -2 -> else this is an aberrant point
MAX_DURATION_SEC = 600 # a fake data point is added in the cycle if last measurement was older than 600 sec (10 min)
MIN_NB_POINT = 4 # do not calculate slope until we have enough point
class WindowOpenDetectionAlgorithm:
"""The class that implements the algorithm listed above"""
@@ -24,6 +29,7 @@ class WindowOpenDetectionAlgorithm:
_last_slope: float
_last_datetime: datetime
_last_temperature: float
_nb_point: int
def __init__(self, alert_threshold, end_alert_threshold) -> None:
"""Initalize a new algorithm with the both threshold"""
@@ -31,6 +37,21 @@ class WindowOpenDetectionAlgorithm:
self._end_alert_threshold = end_alert_threshold
self._last_slope = None
self._last_datetime = None
self._nb_point = 0
def check_age_last_measurement(self, temperature, datetime_now) -> float:
""" " Check if last measurement is old and add
a fake measurement point if this is the case
"""
if self._last_datetime is None:
return self.add_temp_measurement(temperature, datetime_now)
delta_t_sec = float((datetime_now - self._last_datetime).total_seconds())
if delta_t_sec >= MAX_DURATION_SEC:
return self.add_temp_measurement(temperature, datetime_now)
else:
# do nothing
return self._last_slope
def add_temp_measurement(
self, temperature: float, datetime_measure: datetime
@@ -42,6 +63,7 @@ class WindowOpenDetectionAlgorithm:
_LOGGER.debug("First initialisation")
self._last_datetime = datetime_measure
self._last_temperature = temperature
self._nb_point = self._nb_point + 1
return None
_LOGGER.debug(
@@ -72,21 +94,24 @@ class WindowOpenDetectionAlgorithm:
return lspe
if self._last_slope is None:
self._last_slope = new_slope
self._last_slope = round(new_slope, 4)
else:
self._last_slope = (0.5 * self._last_slope) + (0.5 * new_slope)
self._last_slope = round((0.2 * self._last_slope) + (0.8 * new_slope), 4)
self._last_datetime = datetime_measure
self._last_temperature = temperature
self._nb_point = self._nb_point + 1
_LOGGER.debug(
"delta_t=%.3f delta_temp=%.3f new_slope=%.3f last_slope=%s slope=%.3f",
"delta_t=%.3f delta_temp=%.3f new_slope=%.3f last_slope=%s slope=%.3f nb_point=%s",
delta_t,
delta_temp,
new_slope,
lspe,
self._last_slope,
self._nb_point,
)
return self._last_slope
def is_window_open_detected(self) -> bool:
@@ -94,22 +119,20 @@ class WindowOpenDetectionAlgorithm:
if self._alert_threshold is None:
return False
return (
self._last_slope < -self._alert_threshold
if self._last_slope is not None
else False
)
if self._nb_point < MIN_NB_POINT or self._last_slope is None:
return False
return self._last_slope < -self._alert_threshold
def is_window_close_detected(self) -> bool:
"""True if the last calculated slope is above (cause negative) the _end_alert_threshold"""
if self._end_alert_threshold is None:
return False
return (
self._last_slope >= self._end_alert_threshold
if self._last_slope is not None
else False
)
if self._nb_point < MIN_NB_POINT or self._last_slope is None:
return False
return self._last_slope >= self._end_alert_threshold
@property
def last_slope(self) -> float:

View File

@@ -40,11 +40,17 @@ class PITemperatureRegulator:
"""Reset the accumulated error"""
self.accumulated_error = 0
def set_accumulated_error(self, accumulated_error):
"""Allow to persist and restore the accumulated_error"""
self.accumulated_error = accumulated_error
def set_target_temp(self, target_temp):
"""Set the new target_temp"""
self.target_temp = target_temp
# Do not reset the accumulated error
# self.accumulated_error = 0
# Discussion #191. After a target change we should reset the accumulated error which is certainly wrong now.
if self.accumulated_error < 0:
self.accumulated_error = 0
def calculate_regulated_temperature(
self, internal_temp: float, external_temp: float
@@ -85,9 +91,10 @@ class PITemperatureRegulator:
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
# Issue #199 - don't reset the accumulation error
# if abs(error) < self.stabilization_threshold:
# _LOGGER.debug("Stabilisation")
# self.accumulated_error = 0
result = round(self.target_temp + total_offset, 1)

View File

@@ -51,6 +51,7 @@ async def async_setup_entry(
LastTemperatureSensor(hass, unique_id, name, entry.data),
LastExtTemperatureSensor(hass, unique_id, name, entry.data),
TemperatureSlopeSensor(hass, unique_id, name, entry.data),
EMATemperatureSensor(hass, unique_id, name, entry.data),
]
if entry.data.get(CONF_DEVICE_POWER):
entities.append(EnergySensor(hass, unique_id, name, entry.data))
@@ -542,3 +543,54 @@ class RegulatedTemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
def suggested_display_precision(self) -> int | None:
"""Return the suggested number of decimal digits for display."""
return 1
class EMATemperatureSensor(VersatileThermostatBaseEntity, SensorEntity):
"""Representation of a Exponential Moving Average temp"""
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 = "EMA temperature"
self._attr_unique_id = f"{self._device_name}_ema_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.ema_temperature) or math.isinf(
self.my_climate.ema_temperature
):
raise ValueError(
f"Sensor has illegal state {self.my_climate.ema_temperature}"
)
old_state = self._attr_native_value
self._attr_native_value = self.my_climate.ema_temperature
if old_state != self._attr_native_value:
self.async_write_ha_state()
return
@property
def icon(self) -> str | None:
return "mdi:thermometer-lines"
@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 2

View File

@@ -160,3 +160,4 @@ set_auto_regulation_mode:
- "Medium"
- "Strong"
- "Slow"
- "Expert"

View File

@@ -352,6 +352,7 @@
"auto_regulation_strong": "Strong",
"auto_regulation_medium": "Medium",
"auto_regulation_light": "Light",
"auto_regulation_expert": "Expert",
"auto_regulation_none": "No auto-regulation"
}
}

View File

@@ -17,6 +17,7 @@ from .pi_algorithm import PITemperatureRegulator
from .const import (
overrides,
DOMAIN,
CONF_CLIMATE,
CONF_CLIMATE_2,
CONF_CLIMATE_3,
@@ -27,6 +28,7 @@ from .const import (
CONF_AUTO_REGULATION_LIGHT,
CONF_AUTO_REGULATION_MEDIUM,
CONF_AUTO_REGULATION_STRONG,
CONF_AUTO_REGULATION_EXPERT,
CONF_AUTO_REGULATION_DTEMP,
CONF_AUTO_REGULATION_PERIOD_MIN,
RegulationParamSlow,
@@ -35,6 +37,7 @@ from .const import (
RegulationParamStrong,
)
from .vtherm_api import VersatileThermostatAPI
from .underlyings import UnderlyingClimate
_LOGGER = logging.getLogger(__name__)
@@ -241,7 +244,34 @@ class ThermostatOverClimate(BaseThermostat):
RegulationParamSlow.stabilization_threshold,
RegulationParamSlow.accumulated_error_threshold,
)
else:
elif self._auto_regulation_mode == CONF_AUTO_REGULATION_EXPERT:
api: VersatileThermostatAPI = VersatileThermostatAPI.get_vtherm_api(
self._hass
)
if api is not None:
if (expert_param := api.self_regulation_expert) is not None:
self._regulation_algo = PITemperatureRegulator(
self.target_temperature,
expert_param.get("kp"),
expert_param.get("ki"),
expert_param.get("k_ext"),
expert_param.get("offset_max"),
expert_param.get("stabilization_threshold"),
expert_param.get("accumulated_error_threshold"),
)
else:
_LOGGER.error(
"%s - Cannot initialize Expert self-regulation mode due to VTherm API doesn't exists. Please contact the publisher of the integration",
self,
)
else:
_LOGGER.error(
"%s - Cannot initialize Expert self-regulation mode cause the configuration in configuration.yaml have not been found. Please see readme documentation for %s",
self,
DOMAIN,
)
if not self._regulation_algo:
# A default empty algo (which does nothing)
self._regulation_algo = PITemperatureRegulator(
self.target_temperature, 0, 0, 0, 0, 0.1, 0
@@ -272,6 +302,18 @@ class ThermostatOverClimate(BaseThermostat):
)
)
@overrides
def restore_specific_previous_state(self, old_state):
"""Restore my specific attributes from previous state"""
old_error = old_state.attributes.get("regulation_accumulated_error")
if old_error:
self._regulation_algo.set_accumulated_error(old_error)
_LOGGER.debug(
"%s - Old regulation accumulated_error have been restored to %f",
self,
old_error,
)
@overrides
def update_custom_attributes(self):
"""Custom attributes"""
@@ -748,6 +790,8 @@ class ThermostatOverClimate(BaseThermostat):
self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_STRONG)
elif auto_regulation_mode == "Slow":
self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_SLOW)
elif auto_regulation_mode == "Expert":
self.choose_auto_regulation_mode(CONF_AUTO_REGULATION_EXPERT)
await self._send_regulated_temperature()
self.update_custom_attributes()

View File

@@ -352,6 +352,7 @@
"auto_regulation_strong": "Strong",
"auto_regulation_medium": "Medium",
"auto_regulation_light": "Light",
"auto_regulation_expert": "Expert",
"auto_regulation_none": "No auto-regulation"
}
}

View File

@@ -353,6 +353,7 @@
"auto_regulation_strong": "Forte",
"auto_regulation_medium": "Moyenne",
"auto_regulation_light": "Légère",
"auto_regulation_expert": "Expert",
"auto_regulation_none": "Aucune"
}
}

View File

@@ -330,6 +330,7 @@
"auto_regulation_strong": "Forte",
"auto_regulation_medium": "Media",
"auto_regulation_light": "Leggera",
"auto_regulation_expert": "Esperto",
"auto_regulation_none": "Nessuna autoregolamentazione"
}
}

View File

@@ -352,6 +352,7 @@
"auto_regulation_strong": "Strong",
"auto_regulation_medium": "Medium",
"auto_regulation_light": "Light",
"auto_regulation_expert": "Expert",
"auto_regulation_none": "No auto-regulation"
}
}

View File

@@ -0,0 +1,70 @@
""" The API of Versatile Thermostat"""
import logging
from homeassistant.core import HomeAssistant
from homeassistant.config_entries import ConfigEntry
from .const import (
DOMAIN,
CONF_AUTO_REGULATION_EXPERT,
)
VTHERM_API_NAME = "vtherm_api"
_LOGGER = logging.getLogger(__name__)
class VersatileThermostatAPI(dict):
"""The VersatileThermostatAPI"""
_hass: HomeAssistant
# _entries: Dict(str, ConfigEntry)
@classmethod
def get_vtherm_api(cls, hass: HomeAssistant):
"""Get the eventual VTherm API class instance"""
ret = hass.data.get(DOMAIN).get(VTHERM_API_NAME)
if ret is None:
ret = VersatileThermostatAPI(hass)
hass.data[DOMAIN][VTHERM_API_NAME] = ret
return ret
def __init__(self, hass: HomeAssistant) -> None:
_LOGGER.debug("building a VersatileThermostatAPI")
super().__init__()
self._hass = hass
self._expert_params = None
def add_entry(self, entry: ConfigEntry):
"""Add a new entry"""
_LOGGER.debug("Add the entry %s", entry.entry_id)
# self._entries[entry.entry_id] = entry
# Add the entry in hass.data
self._hass.data[DOMAIN][entry.entry_id] = entry
def remove_entry(self, entry: ConfigEntry):
"""Remove an entry"""
_LOGGER.debug("Remove the entry %s", entry.entry_id)
# self._entries.pop(entry.entry_id)
self._hass.data[DOMAIN].pop(entry.entry_id)
# If not more entries are preset, remove the API
if len(self) == 0:
_LOGGER.debug("No more entries-> Remove the API from DOMAIN")
self._hass.data.pop(DOMAIN)
def set_global_config(self, config):
"""Read the global configuration from configuration.yaml file"""
_LOGGER.info("Read global config from configuration.yaml")
self._expert_params = config.get(CONF_AUTO_REGULATION_EXPERT)
if self._expert_params:
_LOGGER.debug("We have found expert params %s", self._expert_params)
@property
def self_regulation_expert(self):
"""Get the self regulation params"""
return self._expert_params
@property
def hass(self):
"""Get the HomeAssistant object"""
return self._hass

View File

@@ -3,5 +3,5 @@
"content_in_root": false,
"render_readme": true,
"hide_default_branch": false,
"homeassistant": "2023.11.0"
"homeassistant": "2023.11.2"
}

View File

@@ -1,2 +1,2 @@
homeassistant==2023.10.3
homeassistant==2023.11.2
ffmpeg

View File

@@ -1,7 +1,7 @@
# 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 unittest.mock import patch # , call
from datetime import datetime, timedelta
from homeassistant.core import HomeAssistant
@@ -14,13 +14,18 @@ from homeassistant.components.climate import ClimateEntity, DOMAIN as CLIMATE_DO
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 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, skip_send_event):
async def test_over_climate_regulation(
hass: HomeAssistant, skip_hass_states_is_state, skip_send_event
):
"""Test the regulation of an over climate thermostat"""
entry = MockConfigEntry(
@@ -41,7 +46,8 @@ async def test_over_climate_regulation(hass: HomeAssistant, skip_hass_states_is_
event_timestamp = now - timedelta(minutes=10)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
@@ -57,7 +63,7 @@ async def test_over_climate_regulation(hass: HomeAssistant, skip_hass_states_is_
if entity.entity_id == entity_id:
return entity
entity:ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
entity: ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity
assert isinstance(entity, ThermostatOverClimate)
@@ -90,36 +96,45 @@ async def test_over_climate_regulation(hass: HomeAssistant, skip_hass_states_is_
# set manual target temp (at now - 7) -> the regulation should occurs
event_timestamp = now - timedelta(minutes=7)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await entity.async_set_temperature(temperature=18)
fake_underlying_climate.set_hvac_action(HVACAction.HEATING) # simulate under heating
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
assert entity.preset_mode == PRESET_NONE # Manual mode
# the regulated temperature should be greater
assert entity.regulated_target_temp > entity.target_temperature
# In medium we could go up to +3 degre
# normally the calcul gives 18 + 2.2 but we round the result to the nearest 0.5 which is 2.0
assert entity.regulated_target_temp == 18+2.0
assert entity.regulated_target_temp == 18 + 1.5
assert entity.hvac_action == HVACAction.HEATING
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=5)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 23, event_timestamp)
await send_ext_temperature_change_event(entity, 19, event_timestamp)
# the regulated temperature should be under
assert entity.regulated_target_temp < entity.target_temperature
assert entity.regulated_target_temp == 18-0.5 # normally 0.6 but round_to_nearest gives 0.5
assert (
entity.regulated_target_temp == 18 - 2
) # normally 0.6 but round_to_nearest gives 0.5
@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, skip_send_event):
async def test_over_climate_regulation_ac_mode(
hass: HomeAssistant, skip_hass_states_is_state, skip_send_event
):
"""Test the regulation of an over climate thermostat"""
entry = MockConfigEntry(
@@ -140,7 +155,8 @@ async def test_over_climate_regulation_ac_mode(hass: HomeAssistant, skip_hass_st
event_timestamp = now - timedelta(minutes=10)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
@@ -156,7 +172,7 @@ async def test_over_climate_regulation_ac_mode(hass: HomeAssistant, skip_hass_st
if entity.entity_id == entity_id:
return entity
entity:ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
entity: ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity
assert isinstance(entity, ThermostatOverClimate)
@@ -185,53 +201,66 @@ async def test_over_climate_regulation_ac_mode(hass: HomeAssistant, skip_hass_st
await send_temperature_change_event(entity, 30, event_timestamp)
await send_ext_temperature_change_event(entity, 35, event_timestamp)
# set manual target temp
event_timestamp = now - timedelta(minutes=7)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await entity.async_set_temperature(temperature=25)
fake_underlying_climate.set_hvac_action(HVACAction.COOLING) # simulate under heating
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
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.regulated_target_temp == 25 - 2.5
) # 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=5)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
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 # +2.3 without round_to_nearest
assert (
entity.regulated_target_temp == 25 - 1
) # +2.3 without round_to_nearest
# change temperature so that the regulated temperature should slow down
event_timestamp = now - timedelta(minutes=3)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 18, event_timestamp)
await send_ext_temperature_change_event(entity, 25, event_timestamp)
# the regulated temperature should be greater
assert entity.regulated_target_temp > entity.target_temperature
assert entity.regulated_target_temp == 25+0.5 # +0.4 without round_to_nearest
assert (
entity.regulated_target_temp == 25 + 3
) # +0.4 without round_to_nearest
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_over_climate_regulation_limitations(hass: HomeAssistant, skip_hass_states_is_state, skip_send_event):
async def test_over_climate_regulation_limitations(
hass: HomeAssistant, skip_hass_states_is_state, skip_send_event
):
"""Test the limitations of the regulation of an over climate thermostat:
1. test the period_min parameter: do not send regulation event too frequently
2. test the dtemp parameter: do not send regulation event if offset temp is lower than dtemp
1. test the period_min parameter: do not send regulation event too frequently
2. test the dtemp parameter: do not send regulation event if offset temp is lower than dtemp
"""
entry = MockConfigEntry(
@@ -252,7 +281,8 @@ async def test_over_climate_regulation_limitations(hass: HomeAssistant, skip_has
event_timestamp = now - timedelta(minutes=20)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
), patch(
"custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",
return_value=fake_underlying_climate,
@@ -268,7 +298,7 @@ async def test_over_climate_regulation_limitations(hass: HomeAssistant, skip_has
if entity.entity_id == entity_id:
return entity
entity:ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
entity: ThermostatOverClimate = find_my_entity("climate.theoverclimatemockname")
assert entity
assert isinstance(entity, ThermostatOverClimate)
@@ -289,46 +319,56 @@ async def test_over_climate_regulation_limitations(hass: HomeAssistant, skip_has
# set manual target temp (at now - 19) -> the regulation should be ignored because too early
event_timestamp = now - timedelta(minutes=19)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await entity.async_set_temperature(temperature=18)
fake_underlying_climate.set_hvac_action(HVACAction.HEATING) # simulate under heating
fake_underlying_climate.set_hvac_action(
HVACAction.HEATING
) # simulate under heating
assert entity.hvac_action == HVACAction.HEATING
# the regulated temperature will change because when we set temp manually it is forced
assert entity.regulated_target_temp == 20.
assert entity.regulated_target_temp == 19.5
# set manual target temp (at now - 18) -> the regulation should be taken into account
event_timestamp = now - timedelta(minutes=18)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await entity.async_set_temperature(temperature=17)
assert entity.regulated_target_temp > entity.target_temperature
assert entity.regulated_target_temp == 18+1 # In strong we could go up to +3 degre. 0.7 without round_to_nearest
assert (
entity.regulated_target_temp == 18 + 0
) # In strong we could go up to +3 degre. 0.7 without round_to_nearest
old_regulated_temp = entity.regulated_target_temp
# change temperature so that dtemp < 0.5 and time is > period_min (+ 3min)
event_timestamp = now - timedelta(minutes=15)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 16, event_timestamp)
await send_ext_temperature_change_event(entity, 10, event_timestamp)
# the regulated temperature should be under
assert entity.regulated_target_temp == old_regulated_temp
assert entity.regulated_target_temp <= old_regulated_temp
# change temperature so that dtemp > 0.5 and time is > period_min (+ 3min)
event_timestamp = now - timedelta(minutes=12)
with patch(
"custom_components.versatile_thermostat.commons.NowClass.get_now", return_value=event_timestamp
"custom_components.versatile_thermostat.commons.NowClass.get_now",
return_value=event_timestamp,
):
await send_temperature_change_event(entity, 18, event_timestamp)
await send_temperature_change_event(entity, 16, event_timestamp)
await send_ext_temperature_change_event(entity, 12, event_timestamp)
# the regulated should have been done
assert entity.regulated_target_temp != old_regulated_temp
assert entity.regulated_target_temp > entity.target_temperature
assert entity.regulated_target_temp == 17 + 1.5 # 0.7 without round_to_nearest
assert entity.regulated_target_temp >= entity.target_temperature
assert (
entity.regulated_target_temp == 17 + 0.5
) # 0.7 without round_to_nearest

View File

@@ -1,3 +1,4 @@
# pylint: disable=unused-argument, line-too-long
""" Test the Versatile Thermostat config flow """
from homeassistant import data_entry_flow
@@ -29,7 +30,11 @@ async def test_show_form(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_user_config_flow_over_switch(hass: HomeAssistant, skip_hass_states_get): # pylint: disable=unused-argument
# Disable this test which don't work anymore (kill the pytest !)
@pytest.mark.skip
async def test_user_config_flow_over_switch(
hass: HomeAssistant, skip_hass_states_get
): # pylint: disable=unused-argument
"""Test the config flow with all thermostat_over_switch features"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
@@ -128,7 +133,9 @@ async def test_user_config_flow_over_switch(hass: HomeAssistant, skip_hass_state
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_user_config_flow_over_climate(hass: HomeAssistant, skip_hass_states_get): # pylint: disable=unused-argument
async def test_user_config_flow_over_climate(
hass: HomeAssistant, skip_hass_states_get
): # pylint: disable=unused-argument
"""Test the config flow with all thermostat_over_climate features and no additional features"""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
@@ -184,7 +191,9 @@ async def test_user_config_flow_over_climate(hass: HomeAssistant, skip_hass_stat
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_user_config_flow_window_auto_ok(
hass: HomeAssistant, skip_hass_states_get, skip_control_heating # pylint: disable=unused-argument
hass: HomeAssistant,
skip_hass_states_get,
skip_control_heating, # pylint: disable=unused-argument
):
"""Test the config flow with only window auto feature"""
result = await hass.config_entries.flow.async_init(
@@ -353,7 +362,9 @@ async def test_user_config_flow_window_auto_ko(
@pytest.mark.parametrize("expected_lingering_tasks", [True])
@pytest.mark.parametrize("expected_lingering_timers", [True])
async def test_user_config_flow_over_4_switches(
hass: HomeAssistant, skip_hass_states_get, skip_control_heating # pylint: disable=unused-argument
hass: HomeAssistant,
skip_hass_states_get,
skip_control_heating, # pylint: disable=unused-argument
):
"""Test the config flow with 4 switchs thermostat_over_switch features"""
@@ -369,7 +380,7 @@ async def test_user_config_flow_over_4_switches(
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: False,
CONF_USE_PRESENCE_FEATURE: False
CONF_USE_PRESENCE_FEATURE: False,
}
TYPE_CONFIG = { # pylint: disable=wildcard-import, invalid-name
@@ -427,15 +438,11 @@ async def test_user_config_flow_over_4_switches(
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert (
result["data"]
== SOURCE_CONFIG
| TYPE_CONFIG
| MOCK_TH_OVER_SWITCH_TPI_CONFIG
| MOCK_PRESETS_CONFIG
| MOCK_ADVANCED_CONFIG
| { CONF_INVERSE_SWITCH: False }
)
assert result[
"data"
] == SOURCE_CONFIG | TYPE_CONFIG | MOCK_TH_OVER_SWITCH_TPI_CONFIG | MOCK_PRESETS_CONFIG | MOCK_ADVANCED_CONFIG | {
CONF_INVERSE_SWITCH: False
}
assert result["result"]
assert result["result"].domain == DOMAIN
assert result["result"].version == 1

54
tests/test_ema.py Normal file
View File

@@ -0,0 +1,54 @@
# pylint: disable=line-too-long
""" Tests de EMA calculation"""
from datetime import datetime, timedelta
from homeassistant.core import HomeAssistant
from custom_components.versatile_thermostat.ema import ExponentialMovingAverage
from .commons import get_tz
def test_ema_basics(hass: HomeAssistant):
"""Test the EMA calculation with basic features"""
tz = get_tz(hass) # pylint: disable=invalid-name
now: datetime = datetime.now(tz=tz)
the_ema = ExponentialMovingAverage(
"test",
# 5 minutes
300,
# Needed for time calculation
get_tz(hass),
1,
)
assert the_ema
current_timestamp = now
# First initialization
assert the_ema.calculate_ema(20, current_timestamp) == 20
current_timestamp = current_timestamp + timedelta(minutes=1)
# One minute later, same temperature. EMA temperature should not have change
assert the_ema.calculate_ema(20, current_timestamp) == 20
# Too short measurement should be ignored
assert the_ema.calculate_ema(2000, current_timestamp) == 20
current_timestamp = current_timestamp + timedelta(seconds=4)
assert the_ema.calculate_ema(20, current_timestamp) == 20
# a new normal measurement 5 minutes later
current_timestamp = current_timestamp + timedelta(minutes=5)
ema = the_ema.calculate_ema(25, current_timestamp)
assert ema > 20
assert ema == 22.5
# a big change in a short time does have a limited effect
current_timestamp = current_timestamp + timedelta(seconds=5)
ema = the_ema.calculate_ema(30, current_timestamp)
assert ema > 22.5
assert ema < 23
assert ema == 22.6

View File

@@ -386,6 +386,7 @@ async def test_multiple_climates(
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 8,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,
@@ -486,6 +487,7 @@ async def test_multiple_climates_underlying_changes(
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_CLIMATE,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 8,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
"eco_temp": 17,

View File

@@ -3,10 +3,19 @@
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)
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
@@ -19,62 +28,79 @@ def test_pi_algorithm_basics():
the_algo.reset_accumulated_error()
# 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 # +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 error
the_algo.reset_accumulated_error()
assert the_algo.calculate_regulated_temperature(18, 10) == 21.3 # +1.5
assert the_algo.calculate_regulated_temperature(18, 10) == 21.3 # +1.5
assert the_algo.calculate_regulated_temperature(18.1, 10) == 21.4 # +1.6
assert the_algo.calculate_regulated_temperature(18.3, 10) == 21.4 # +1.6
assert the_algo.calculate_regulated_temperature(18.5, 10) == 21.5 # +1.7
assert the_algo.calculate_regulated_temperature(18.7, 10) == 21.6 # +1.7
assert the_algo.calculate_regulated_temperature(19, 10) == 21.6 # +1.7
assert the_algo.calculate_regulated_temperature(20, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(21, 10) == 20.9 # +0.8
assert the_algo.calculate_regulated_temperature(21, 10) == 20.8 # +0.7
assert the_algo.calculate_regulated_temperature(20, 10) == 20.9 # +0.7
assert the_algo.calculate_regulated_temperature(19, 10) == 21.6 # +1.7
assert the_algo.calculate_regulated_temperature(20, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(21, 10) == 21.3 # +0.8
assert the_algo.calculate_regulated_temperature(21, 10) == 21.3 # +0.7
assert the_algo.calculate_regulated_temperature(20, 10) == 21.4 # +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 # =
assert the_algo.calculate_regulated_temperature(20, 12) == 21.2 # +0.8
assert the_algo.calculate_regulated_temperature(20, 15) == 20.9 # +0.5
assert the_algo.calculate_regulated_temperature(20, 18) == 20.6 # +0.2
assert the_algo.calculate_regulated_temperature(20, 20) == 20.4 # =
def test_pi_algorithm_light():
""" Test the PI algorithm """
"""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)
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.3 # +1.5
assert the_algo.calculate_regulated_temperature(18, 10) == 21.3 # +1.5
assert the_algo.calculate_regulated_temperature(18.1, 10) == 21.4 # +1.6
assert the_algo.calculate_regulated_temperature(18.3, 10) == 21.4 # +1.6
assert the_algo.calculate_regulated_temperature(18.5, 10) == 21.5 # +1.7
assert the_algo.calculate_regulated_temperature(18.7, 10) == 21.6 # +1.7
assert the_algo.calculate_regulated_temperature(19, 10) == 21.6 # +1.7
assert the_algo.calculate_regulated_temperature(20, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(21, 10) == 20.9 # +0.8
assert the_algo.calculate_regulated_temperature(21, 10) == 20.8 # +0.7
assert the_algo.calculate_regulated_temperature(20, 10) == 20.9 # +0.7
assert the_algo.calculate_regulated_temperature(19, 10) == 21.6 # +1.7
assert the_algo.calculate_regulated_temperature(20, 10) == 21.5 # +1.5
assert the_algo.calculate_regulated_temperature(21, 10) == 21.3 # +0.8
assert the_algo.calculate_regulated_temperature(21, 10) == 21.3 # +0.7
assert the_algo.calculate_regulated_temperature(20, 10) == 21.4 # +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 # =
assert the_algo.calculate_regulated_temperature(20, 12) == 21.2 # +0.8
assert the_algo.calculate_regulated_temperature(20, 15) == 20.9 # +0.5
assert the_algo.calculate_regulated_temperature(20, 18) == 20.6 # +0.2
assert the_algo.calculate_regulated_temperature(20, 20) == 20.4 # =
def test_pi_algorithm_medium():
""" Test the PI algorithm """
"""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)
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
@@ -88,20 +114,20 @@ def test_pi_algorithm_medium():
assert the_algo.calculate_regulated_temperature(18.7, 10) == 22.4
assert the_algo.calculate_regulated_temperature(19, 10) == 22.3
assert the_algo.calculate_regulated_temperature(20, 10) == 21.9
assert the_algo.calculate_regulated_temperature(21, 10) == 20.5
assert the_algo.calculate_regulated_temperature(21, 10) == 20.4
assert the_algo.calculate_regulated_temperature(20, 10) == 20.8
assert the_algo.calculate_regulated_temperature(21, 10) == 21.4
assert the_algo.calculate_regulated_temperature(21, 10) == 21.3
assert the_algo.calculate_regulated_temperature(20, 10) == 21.7
# 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
assert the_algo.calculate_regulated_temperature(20, 8) == 21.9
assert the_algo.calculate_regulated_temperature(20, 6) == 22.1
assert the_algo.calculate_regulated_temperature(20, 4) == 22.3
assert the_algo.calculate_regulated_temperature(20, 2) == 22.5
assert the_algo.calculate_regulated_temperature(20, 0) == 22.7
assert the_algo.calculate_regulated_temperature(20, -2) == 22.9
assert the_algo.calculate_regulated_temperature(20, -4) == 23.0
assert the_algo.calculate_regulated_temperature(20, -6) == 23.0
assert the_algo.calculate_regulated_temperature(20, -8) == 23.0
# to reset the accumulated erro
the_algo.set_target_temp(20)
@@ -121,10 +147,19 @@ def test_pi_algorithm_medium():
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)
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
@@ -138,19 +173,19 @@ def test_pi_algorithm_strong():
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.4
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) == 23.3
assert the_algo.calculate_regulated_temperature(21, 10) == 23.1
assert the_algo.calculate_regulated_temperature(21, 10) == 22.9
assert the_algo.calculate_regulated_temperature(21, 10) == 22.7
assert the_algo.calculate_regulated_temperature(21, 10) == 22.5
assert the_algo.calculate_regulated_temperature(21, 10) == 22.3
assert the_algo.calculate_regulated_temperature(21, 10) == 22.1
# 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, 8) == 22.9
assert the_algo.calculate_regulated_temperature(20, 6) == 23.3
assert the_algo.calculate_regulated_temperature(20, 4) == 23.7
assert the_algo.calculate_regulated_temperature(20, 2) == 24
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