Files
HomeAssistantVS/custom_components/versatile_thermostat/prop_algo_tpi.py
T
2026-06-16 10:33:21 -04:00

228 lines
9.2 KiB
Python

""" The TPI calculation module """
# pylint: disable='line-too-long'
import logging
from vtherm_api.log_collector import get_vtherm_logger
from .vtherm_hvac_mode import VThermHvacMode, VThermHvacMode_OFF, VThermHvacMode_COOL, VThermHvacMode_SLEEP
_LOGGER = get_vtherm_logger(__name__)
def is_number(value):
"""check if value is a number"""
return isinstance(value, (int, float))
class TpiAlgorithm:
"""This class aims to do all calculation of the Proportional alogorithm"""
def __init__(
self,
tpi_coef_int,
tpi_coef_ext,
vtherm_entity_id: str = None,
max_on_percent: float = None,
tpi_threshold_low: float = 0.0,
tpi_threshold_high: float = 0.0,
) -> None:
"""Initialisation of the Proportional Algorithm"""
_LOGGER.debug(
"%s - Creation new TpiAlgorithm tpi_coef_int: %s, tpi_coef_ext: %s, tpi_threshold_low=%s, tpi_threshold_high=%s", # pylint: disable=line-too-long
vtherm_entity_id,
tpi_coef_int,
tpi_coef_ext,
tpi_threshold_low,
tpi_threshold_high,
)
# Issue 506 - check parameters
if (
vtherm_entity_id is None
or not is_number(tpi_coef_int)
or not is_number(tpi_coef_ext)
):
_LOGGER.error(
"%s - configuration is wrong. entity_id is %s, tpi_coef_int is %s, tpi_coef_ext is %s",
vtherm_entity_id,
vtherm_entity_id,
tpi_coef_int,
tpi_coef_ext,
)
raise TypeError(
"TPI parameters are not set correctly. VTherm will not work as expected. Please reconfigure it correctly. See previous log for values"
)
self._vtherm_entity_id = vtherm_entity_id
self._tpi_coef_int = tpi_coef_int
self._tpi_coef_ext = tpi_coef_ext
self._on_percent = 0
self._calculated_on_percent = 0
self._total_on_percent = 0
self._max_on_percent = max_on_percent
self._tpi_threshold_low = tpi_threshold_low
self._tpi_threshold_high = tpi_threshold_high
self._apply_threshold = tpi_threshold_low != 0.0 and tpi_threshold_high != 0.0
# True once calculate() has been called with a valid temperature.
# When False, on_percent returns None so the cycle scheduler
# does not touch an already-active switch at startup.
self._temperature_available: bool = False
def calculate(
self,
target_temp: float | None,
current_temp: float | None,
ext_current_temp: float | None,
slope: float | None,
hvac_mode: VThermHvacMode,
**kwargs,
):
"""Do the calculation of the duration"""
if target_temp is None or current_temp is None:
log = _LOGGER.debug if hvac_mode == VThermHvacMode_OFF else _LOGGER.warning
log(
"%s - Proportional algorithm: calculation is not possible cause target_temp (%s) or current_temp (%s) is null. Heating/cooling will be disabled. This could be normal at startup", # pylint: disable=line-too-long
self._vtherm_entity_id,
target_temp,
current_temp,
)
self._calculated_on_percent = 0
self._temperature_available = False
else:
self._temperature_available = True
if hvac_mode == VThermHvacMode_COOL:
delta_temp = current_temp - target_temp
delta_ext_temp = ext_current_temp - target_temp if ext_current_temp is not None else 0
slope = -slope if slope is not None else None
else:
delta_temp = target_temp - current_temp
delta_ext_temp = target_temp - ext_current_temp if ext_current_temp is not None else 0
# Apply thresholds
if (
# fmt: off
self._apply_threshold
and slope is not None
and ((slope > 0.0 and -delta_temp > self._tpi_threshold_high)
or (slope < 0.0 and -delta_temp > self._tpi_threshold_low))
# fmt: on
):
_LOGGER.debug(
"%s - Proportional algorithm: on_percent is forced to 0 cause current_temp (%.1f) is outside the thresholds (slope=%.1f, target_temp=%.1f, tpi_threshold_low=%.1f, tpi_threshold_high=%.1f). Heating/cooling will be disabled.", # pylint: disable=line-too-long
self._vtherm_entity_id,
current_temp,
slope,
target_temp,
self._tpi_threshold_low,
self._tpi_threshold_high,
)
self._calculated_on_percent = 0
else:
if hvac_mode not in [VThermHvacMode_OFF, VThermHvacMode_SLEEP]:
self._calculated_on_percent = self._tpi_coef_int * delta_temp + self._tpi_coef_ext * delta_ext_temp
# calculated on_time duration in seconds
if self._calculated_on_percent > 1:
self._calculated_on_percent = 1
if self._calculated_on_percent < 0:
self._calculated_on_percent = 0
if self._max_on_percent is not None and self._calculated_on_percent > self._max_on_percent:
self._calculated_on_percent = self._max_on_percent
else:
_LOGGER.debug(
"%s - Proportional algorithm: VTherm is off. Heating will be disabled",
self._vtherm_entity_id,
)
self._calculated_on_percent = 0
_LOGGER.debug(
"%s - heating percent calculated for current_temp %.1f, ext_current_temp %.1f and target_temp %.1f is %.2f", # pylint: disable=line-too-long
self._vtherm_entity_id,
current_temp if current_temp else -9999.0,
ext_current_temp if ext_current_temp else -9999.0,
target_temp if target_temp else -9999.0,
self._calculated_on_percent,
)
def update_parameters(
self,
tpi_coef_int=None,
tpi_coef_ext=None,
tpi_threshold_low=None,
tpi_threshold_high=None,
):
"""Update the parameters of the algorithm"""
if tpi_coef_int is not None:
self._tpi_coef_int = tpi_coef_int
if tpi_coef_ext is not None:
self._tpi_coef_ext = tpi_coef_ext
if tpi_threshold_low is not None:
self._tpi_threshold_low = tpi_threshold_low
if tpi_threshold_high is not None:
self._tpi_threshold_high = tpi_threshold_high
self._apply_threshold = self._tpi_threshold_low != 0.0 and self._tpi_threshold_high != 0.0
_LOGGER.debug(
"%s - Parameters updated. tpi_coef_int: %s, tpi_coef_ext: %s, tpi_threshold_low: %s, tpi_threshold_high: %s",
self._vtherm_entity_id,
self._tpi_coef_int,
self._tpi_coef_ext,
self._tpi_threshold_low,
self._tpi_threshold_high,
)
def update_realized_power(self, power_percent: float):
"""Update the realized power_percent.
This method is called by the VTherm when the power is modified by safety or other features
which clamps the value or force it to 0 or 1.
"""
self._total_on_percent = power_percent
if self._total_on_percent != self._calculated_on_percent:
_LOGGER.debug(
"%s - Realized power is different from calculated power. Calculated: %.2f, Realized: %.2f",
self._vtherm_entity_id,
self._calculated_on_percent,
self._total_on_percent,
)
@property
def on_percent(self) -> float | None:
"""Returns the percentage the heater must be ON.
Returns None when no valid temperature was available at the last calculate() call.
In that case callers should preserve the current switch state rather than forcing it off.
Note: This does NOT reflect safety overrides.
Use calculated_on_percent for display or check ThermostatProp.on_percent for the effective value.
"""
if not self._temperature_available:
return None
return round(self._calculated_on_percent, 2)
@property
def calculated_on_percent(self) -> float:
"""Returns the calculated percentage the heater must be ON
Calculated means NOT overriden even in safety mode
(1 means the heater will be always on, 0 never on)""" # pylint: disable=line-too-long
return round(self._calculated_on_percent, 2)
@property
def tpi_coef_int(self) -> float:
"""Returns the TPI coefficient int"""
return self._tpi_coef_int
@property
def tpi_coef_ext(self) -> float:
"""Returns the TPI coefficient ext"""
return self._tpi_coef_ext
@property
def tpi_threshold_low(self) -> float:
"""Returns the TPI threshold low"""
return self._tpi_threshold_low
@property
def tpi_threshold_high(self) -> float:
"""Returns the TPI threshold high"""
return self._tpi_threshold_high