Skip to content

Commit

Permalink
Add temporal filter for calculate_shedding
Browse files Browse the repository at this point in the history
Add restore overpowering state at startup
  • Loading branch information
Jean-Marc Collin committed Jan 5, 2025
1 parent 6bdcece commit 81231f9
Show file tree
Hide file tree
Showing 18 changed files with 173 additions and 154 deletions.
2 changes: 1 addition & 1 deletion custom_components/versatile_thermostat/base_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ def post_init(self, entry_infos: ConfigData):
"""Initialize the attributes of the FeatureManager"""
raise NotImplementedError()

def start_listening(self):
async def start_listening(self):
"""Start listening the underlying entity"""
raise NotImplementedError()

Expand Down
10 changes: 5 additions & 5 deletions custom_components/versatile_thermostat/base_thermostat.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
)

from .const import * # pylint: disable=wildcard-import, unused-wildcard-import
from .commons import ConfigData, T, deprecated
from .commons import ConfigData, T

from .config_schema import * # pylint: disable=wildcard-import, unused-wildcard-import

Expand Down Expand Up @@ -478,7 +478,7 @@ async def async_startup(self, central_configuration):

# start listening for all managers
for manager in self._managers:
manager.start_listening()
await manager.start_listening()

await self.get_my_previous_state()

Expand Down Expand Up @@ -1957,7 +1957,7 @@ def is_preset_configured(self, preset) -> bool:
def _set_now(self, now: datetime):
"""Set the now timestamp. This is only for tests purpose
This method should be replaced by the vthermAPI equivalent"""
VersatileThermostatAPI.get_vtherm_api(self._hass)._set_now(now)
VersatileThermostatAPI.get_vtherm_api(self._hass)._set_now(now) # pylint: disable=protected-access

# @deprecated
@property
Expand All @@ -1968,8 +1968,8 @@ def now(self) -> datetime:

@property
def power_percent(self) -> float | None:
"""Get the current on_percent as a percentage value. valid only for Vtherm with a TPI algo"""
"""Get the current on_percent value"""
"""Get the current on_percent as a percentage value. valid only for Vtherm with a TPI algo
Get the current on_percent value"""
if self._prop_algorithm and self._prop_algorithm.on_percent is not None:
return round(self._prop_algorithm.on_percent * 100, 0)
else:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
from typing import Any
from functools import cmp_to_key

from datetime import timedelta

from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant, Event, callback
from homeassistant.helpers.event import (
async_track_state_change_event,
EventStateChangedData,
async_call_later,
)
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.components.climate import (
Expand Down Expand Up @@ -43,6 +46,8 @@ def __init__(self, hass: HomeAssistant, vtherm_api: Any):
self._current_power: float = None
self._current_max_power: float = None
self._power_temp: float = None
self._cancel_calculate_shedding_call = None
# Not used now
self._last_shedding_date = None

def post_init(self, entry_infos: ConfigData):
Expand All @@ -69,7 +74,7 @@ def post_init(self, entry_infos: ConfigData):
else:
_LOGGER.info("Power management is not fully configured and will be deactivated")

def start_listening(self):
async def start_listening(self):
"""Start listening the power sensor"""
if not self._is_configured:
return
Expand Down Expand Up @@ -110,39 +115,47 @@ async def _max_power_sensor_changed(self, event: Event[EventStateChangedData]):
async def refresh_state(self) -> bool:
"""Tries to get the last state from sensor
Returns True if a change has been made"""
ret = False
if self._is_configured:
# try to acquire current power and power max
if (
new_state := get_safe_float(self._hass, self._power_sensor_entity_id)
) is not None:
self._current_power = new_state
_LOGGER.debug("Current power have been retrieved: %.3f", self._current_power)
ret = True

# Try to acquire power max
if (
new_state := get_safe_float(
self._hass, self._max_power_sensor_entity_id
)
) is not None:
self._current_max_power = new_state
_LOGGER.debug("Current power max have been retrieved: %.3f", self._current_max_power)
ret = True

# check if we need to re-calculate shedding
if ret:
now = self._vtherm_api.now
dtimestamp = (
(now - self._last_shedding_date).seconds
if self._last_shedding_date
else 999
)
if dtimestamp >= MIN_DTEMP_SECS:
await self.calculate_shedding()
self._last_shedding_date = now

return ret

async def _calculate_shedding_internal(_):
_LOGGER.debug("Do the shedding calculation")
await self.calculate_shedding()
if self._cancel_calculate_shedding_call:
self._cancel_calculate_shedding_call()
self._cancel_calculate_shedding_call = None

if not self._is_configured:
return False

# Retrieve current power
new_power = get_safe_float(self._hass, self._power_sensor_entity_id)
power_changed = new_power is not None and self._current_power != new_power
if power_changed:
self._current_power = new_power
_LOGGER.debug("New current power has been retrieved: %.3f", self._current_power)

# Retrieve max power
new_max_power = get_safe_float(self._hass, self._max_power_sensor_entity_id)
max_power_changed = new_max_power is not None and self._current_max_power != new_max_power
if max_power_changed:
self._current_max_power = new_max_power
_LOGGER.debug("New current max power has been retrieved: %.3f", self._current_max_power)

# Schedule shedding calculation if there's any change
if power_changed or max_power_changed:
if not self._cancel_calculate_shedding_call:
self._cancel_calculate_shedding_call = async_call_later(self.hass, timedelta(seconds=MIN_DTEMP_SECS), _calculate_shedding_internal)
return True

return False

# For testing purpose only, do an immediate shedding calculation
async def _do_immediate_shedding(self):
"""Do an immmediate shedding calculation if a timer was programmed.
Else, do nothing"""
if self._cancel_calculate_shedding_call:
self._cancel_calculate_shedding_call()
self._cancel_calculate_shedding_call = None
await self.calculate_shedding()

async def calculate_shedding(self):
"""Do the shedding calculation and set/unset VTherm into overpowering state"""
Expand Down Expand Up @@ -197,21 +210,23 @@ async def calculate_shedding(self):
)

_LOGGER.debug("vtherm %s power_consumption_max is %s (device_power=%s, overclimate=%s)", vtherm.name, power_consumption_max, device_power, vtherm.is_over_climate)
# if total_power_added + power_consumption_max < available_power or not vtherm.power_manager.is_overpowering_detected:
_LOGGER.info("vtherm %s should not be in overpowering state (power_consumption_max=%.2f)", vtherm.name, power_consumption_max)

# we count the unshedding only if the VTherm was in shedding
if vtherm.power_manager.is_overpowering_detected:
total_power_added += power_consumption_max
# or not ... is for initializing the overpowering state if not already done
if total_power_added + power_consumption_max < available_power or not vtherm.power_manager.is_overpowering_detected:
# we count the unshedding only if the VTherm was in shedding
if vtherm.power_manager.is_overpowering_detected:
_LOGGER.info("vtherm %s should not be in overpowering state (power_consumption_max=%.2f)", vtherm.name, power_consumption_max)
total_power_added += power_consumption_max

await vtherm.power_manager.set_overpowering(False)
await vtherm.power_manager.set_overpowering(False)

if total_power_added >= available_power:
_LOGGER.debug("We have found enough vtherm to set to non-overpowering")
break

_LOGGER.debug("after vtherm %s total_power_added=%s, available_power=%s", vtherm.name, total_power_added, available_power)

self._last_shedding_date = self._vtherm_api.now
_LOGGER.debug("-------- End of calculate_shedding")

def get_climate_components_entities(self) -> list:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ def post_init(self, entry_infos: ConfigData):
)

@overrides
def start_listening(self):
async def start_listening(self):
"""Start listening the underlying entity"""

@overrides
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ def post_init(self, entry_infos: ConfigData):
self._motion_state = STATE_UNKNOWN

@overrides
def start_listening(self):
async def start_listening(self):
"""Start listening the underlying entity"""
if self._is_configured:
self.stop_listening()
Expand Down
16 changes: 9 additions & 7 deletions custom_components/versatile_thermostat/feature_power_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,19 +61,21 @@ def post_init(self, entry_infos: ConfigData):
self._is_configured = False

@overrides
def start_listening(self):
async def start_listening(self):
"""Start listening the underlying entity. There is nothing to listen"""
central_power_configuration = (
VersatileThermostatAPI.get_vtherm_api().central_power_manager.is_configured
)

if (
self._use_power_feature
and self._device_power
and central_power_configuration
):
if self._use_power_feature and self._device_power and central_power_configuration:
self._is_configured = True
self._overpowering_state = STATE_UNKNOWN
# Try to restore _overpowering_state from previous state
old_state = await self._vtherm.async_get_last_state()
self._overpowering_state = (
old_state.attributes.get("overpowering_state", STATE_UNKNOWN)
if old_state and old_state.attributes and old_state.attributes in (STATE_OFF, STATE_ON)
else STATE_UNKNOWN
)
else:
if self._use_power_feature:
if not central_power_configuration:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def post_init(self, entry_infos: ConfigData):
self._presence_state = STATE_UNKNOWN

@overrides
def start_listening(self):
async def start_listening(self):
"""Start listening the underlying entity"""
if self._is_configured:
self.stop_listening()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def post_init(self, entry_infos: ConfigData):
self._is_configured = True

@overrides
def start_listening(self):
async def start_listening(self):
"""Start listening the underlying entity"""

@overrides
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ def post_init(self, entry_infos: ConfigData):
self._window_state = STATE_UNKNOWN

@overrides
def start_listening(self):
async def start_listening(self):
"""Start listening the underlying entity"""
if self._is_configured:
self.stop_listening()
Expand Down
32 changes: 15 additions & 17 deletions custom_components/versatile_thermostat/thermostat_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,21 @@
class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]):
"""Representation of a base class for a Versatile Thermostat over a switch."""

_entity_component_unrecorded_attributes = (
BaseThermostat._entity_component_unrecorded_attributes.union(
frozenset(
{
"is_over_switch",
"is_inversed",
"underlying_entities",
"on_time_sec",
"off_time_sec",
"cycle_min",
"function",
"tpi_coef_int",
"tpi_coef_ext",
"power_percent",
"calculated_on_percent",
}
)
_entity_component_unrecorded_attributes = BaseThermostat._entity_component_unrecorded_attributes.union( # pylint: disable=protected-access
frozenset(
{
"is_over_switch",
"is_inversed",
"underlying_entities",
"on_time_sec",
"off_time_sec",
"cycle_min",
"function",
"tpi_coef_int",
"tpi_coef_ext",
"power_percent",
"calculated_on_percent",
}
)
)

Expand Down
2 changes: 1 addition & 1 deletion custom_components/versatile_thermostat/vtherm_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ async def init_vtherm_links(self, entry_id=None):

# start listening for the central power manager if not only one vtherm reload
if not entry_id:
self.central_power_manager.start_listening()
await self.central_power_manager.start_listening()

async def init_vtherm_preset_with_central(self):
"""Init all VTherm presets when the VTherm uses central temperature"""
Expand Down
2 changes: 2 additions & 0 deletions tests/commons.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,7 @@ async def send_power_change_event(entity: BaseThermostat, new_power, date, sleep
)
vtherm_api = VersatileThermostatAPI.get_vtherm_api()
await vtherm_api.central_power_manager._power_sensor_changed(power_event)
await vtherm_api.central_power_manager._do_immediate_shedding()
if sleep:
await entity.hass.async_block_till_done()

Expand Down Expand Up @@ -778,6 +779,7 @@ async def send_max_power_change_event(
)
vtherm_api = VersatileThermostatAPI.get_vtherm_api()
await vtherm_api.central_power_manager._max_power_sensor_changed(power_event)
await vtherm_api.central_power_manager._do_immediate_shedding()
if sleep:
await entity.hass.async_block_till_done()

Expand Down
4 changes: 2 additions & 2 deletions tests/test_binary_sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,13 @@ async def test_overpowering_binary_sensors(
assert overpowering_binary_sensor.state == STATE_ON

# set max power to a low value
side_effects.add_or_update_side_effect("sensor.the_max_power_sensor", State("sensor.the_max_power_sensor", 201))
side_effects.add_or_update_side_effect("sensor.the_max_power_sensor", State("sensor.the_max_power_sensor", 251))
# fmt:off
with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()):
# fmt: on
now = now + timedelta(seconds=30)
VersatileThermostatAPI.get_vtherm_api()._set_now(now)
await send_max_power_change_event(entity, 201, now)
await send_max_power_change_event(entity, 251, now)
assert entity.power_manager.is_overpowering_detected is False
assert entity.power_manager.overpowering_state is STATE_OFF
# Simulate the event reception
Expand Down
Loading

0 comments on commit 81231f9

Please sign in to comment.