Skip to content

Commit

Permalink
[Feature Request] - Pwoer shedding should prevent all VTherm to start…
Browse files Browse the repository at this point in the history
… at the same time (#845)

Fixes #844

Co-authored-by: Jean-Marc Collin <[email protected]>
  • Loading branch information
jmcollin78 and Jean-Marc Collin authored Jan 19, 2025
1 parent 8743872 commit 05fe205
Show file tree
Hide file tree
Showing 9 changed files with 261 additions and 145 deletions.
4 changes: 2 additions & 2 deletions custom_components/versatile_thermostat/base_thermostat.py
Original file line number Diff line number Diff line change
Expand Up @@ -1528,8 +1528,8 @@ def save_all():
is_window_detected = self._window_manager.is_window_detected
if new_central_mode == CENTRAL_MODE_AUTO:
if not is_window_detected and not first_init:
await self.restore_hvac_mode()
await self.restore_preset_mode()
await self.restore_preset_mode(force=False)
await self.restore_hvac_mode(need_control_heating=True)
elif is_window_detected and self.hvac_mode == HVACMode.OFF:
# do not restore but mark the reason of off with window detection
self.set_hvac_off_reason(HVAC_OFF_REASON_WINDOW_DETECTION)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def __init__(self, hass: HomeAssistant, vtherm_api: Any):
self._current_max_power: float = None
self._power_temp: float = None
self._cancel_calculate_shedding_call = None
self._started_vtherm_total_power: float = None
# Not used now
self._last_shedding_date = None

Expand All @@ -71,6 +72,7 @@ def post_init(self, entry_infos: ConfigData):
and self._power_temp
):
self._is_configured = True
self._started_vtherm_total_power = 0
else:
_LOGGER.info("Power management is not fully configured and will be deactivated")

Expand Down Expand Up @@ -102,6 +104,8 @@ async def _power_sensor_changed(self, event: Event[EventStateChangedData]):
"""Handle power changes."""
_LOGGER.debug("Receive new Power event")
_LOGGER.debug(event)

self._started_vtherm_total_power = 0
await self.refresh_state()

@callback
Expand Down Expand Up @@ -275,6 +279,12 @@ def cmp_temps(a, b) -> int:
vtherms.sort(key=cmp_to_key(cmp_temps))
return vtherms

def add_started_vtherm_total_power(self, started_power: float):
"""Add the power into the _started_vtherm_total_power which holds all VTherm started after
the last power measurement"""
self._started_vtherm_total_power += started_power
_LOGGER.debug("%s - started_vtherm_total_power is now %s", self, self._started_vtherm_total_power)

@property
def is_configured(self) -> bool:
"""True if the FeatureManager is fully configured"""
Expand Down Expand Up @@ -305,5 +315,10 @@ def max_power_sensor_entity_id(self) -> float | None:
"""Return the max power sensor entity id"""
return self._max_power_sensor_entity_id

@property
def started_vtherm_total_power(self) -> float | None:
"""Return the started_vtherm_total_power"""
return self._started_vtherm_total_power

def __str__(self):
return "CentralPowerManager"
10 changes: 8 additions & 2 deletions custom_components/versatile_thermostat/feature_power_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ def add_custom_attributes(self, extra_state_attributes: dict[str, Any]):

async def check_power_available(self) -> bool:
"""Check if the Vtherm can be started considering overpowering.
Returns True if no overpowering conditions are found
Returns True if no overpowering conditions are found.
If True the vtherm power is written into the temporay vtherm started
"""

vtherm_api = VersatileThermostatAPI.get_vtherm_api()
Expand All @@ -116,6 +117,7 @@ async def check_power_available(self) -> bool:

current_power = vtherm_api.central_power_manager.current_power
current_max_power = vtherm_api.central_power_manager.current_max_power
started_vtherm_total_power = vtherm_api.central_power_manager.started_vtherm_total_power
if (
current_power is None
or current_max_power is None
Expand Down Expand Up @@ -146,7 +148,7 @@ async def check_power_available(self) -> bool:
self._device_power * self._vtherm.proportional_algorithm.on_percent,
)

ret = (current_power + power_consumption_max) < current_max_power
ret = (current_power + started_vtherm_total_power + power_consumption_max) < current_max_power
if not ret:
_LOGGER.info(
"%s - there is not enough power available power=%.3f, max_power=%.3f heater power=%.3f",
Expand All @@ -155,6 +157,10 @@ async def check_power_available(self) -> bool:
current_max_power,
self._device_power,
)
else:
# Adds the current_power_max to the started vtherm total power
vtherm_api.central_power_manager.add_started_vtherm_total_power(power_consumption_max)

return ret

async def set_overpowering(self, overpowering: bool, power_consumption_max=0):
Expand Down
4 changes: 2 additions & 2 deletions tests/test_binary_sensors.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# pylint: disable=wildcard-import, unused-wildcard-import, unused-argument, line-too-long, protected-access

""" Test the normal start of a Thermostat """
from unittest.mock import patch
from unittest.mock import patch, PropertyMock
from datetime import timedelta, datetime

from homeassistant.core import HomeAssistant
Expand Down Expand Up @@ -179,7 +179,7 @@ async def test_overpowering_binary_sensors(
)
# fmt:off
with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()), \
patch("custom_components.versatile_thermostat.thermostat_switch.ThermostatOverSwitch.is_device_active", return_value="True"):
patch("custom_components.versatile_thermostat.thermostat_switch.ThermostatOverSwitch.is_device_active", new_callable=PropertyMock, return_value=True):
# fmt: on
await send_power_change_event(entity, 150, now)
await send_max_power_change_event(entity, 100, now)
Expand Down
156 changes: 156 additions & 0 deletions tests/test_central_power_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# pylint: disable=protected-access, unused-argument, line-too-long
""" Test the Central Power management """
import asyncio
from unittest.mock import patch, AsyncMock, MagicMock, PropertyMock
from datetime import datetime, timedelta
import logging
Expand All @@ -10,6 +11,14 @@
from custom_components.versatile_thermostat.central_feature_power_manager import (
CentralFeaturePowerManager,
)

from custom_components.versatile_thermostat.thermostat_switch import (
ThermostatOverSwitch,
)
from custom_components.versatile_thermostat.thermostat_climate import (
ThermostatOverClimate,
)

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

logging.getLogger().setLevel(logging.DEBUG)
Expand Down Expand Up @@ -700,3 +709,150 @@ async def test_central_power_manager_max_power_event(

assert central_power_manager.current_max_power == expected_power
assert mock_calculate_shedding.call_count == nb_call


async def test_central_power_manager_start_vtherm_power(hass: HomeAssistant, skip_hass_states_is_state, init_central_power_manager):
"""Tests the central power start VTherm power. The objective is to starts VTherm until the power max is exceeded"""

temps = {
"eco": 17,
"comfort": 18,
"boost": 19,
}

entry = MockConfigEntry(
domain=DOMAIN,
title="TheOverSwitchMockName",
unique_id="uniqueId",
data={
CONF_NAME: "TheOverSwitchMockName",
CONF_THERMOSTAT_TYPE: CONF_THERMOSTAT_SWITCH,
CONF_TEMP_SENSOR: "sensor.mock_temp_sensor",
CONF_EXTERNAL_TEMP_SENSOR: "sensor.mock_ext_temp_sensor",
CONF_CYCLE_MIN: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: True,
CONF_USE_PRESENCE_FEATURE: False,
CONF_UNDERLYING_LIST: ["switch.mock_switch"],
CONF_PROP_FUNCTION: PROPORTIONAL_FUNCTION_TPI,
CONF_TPI_COEF_INT: 0.3,
CONF_TPI_COEF_EXT: 0.01,
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SAFETY_DELAY_MIN: 5,
CONF_SAFETY_MIN_ON_PERCENT: 0.3,
CONF_DEVICE_POWER: 1000,
CONF_PRESET_POWER: 12,
},
)

entity: ThermostatOverSwitch = await create_thermostat(hass, entry, "climate.theoverswitchmockname", temps)
assert entity

now: datetime = NowClass.get_now(hass)
VersatileThermostatAPI.get_vtherm_api()._set_now(now)

central_power_manager = VersatileThermostatAPI.get_vtherm_api().central_power_manager
assert central_power_manager

side_effects = SideEffects(
{
"sensor.the_power_sensor": State("sensor.the_power_sensor", 1000),
"sensor.the_max_power_sensor": State("sensor.the_max_power_sensor", 2100),
},
State("unknown.entity_id", "unknown"),
)

# 1. Make the heater heats
# fmt: off
with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()), \
patch("custom_components.versatile_thermostat.thermostat_switch.ThermostatOverSwitch.is_device_active", new_callable=PropertyMock, return_value=False):
# fmt: on
# make the heater heats
await send_power_change_event(entity, 1000, now)
await send_max_power_change_event(entity, 2100, now)

await send_temperature_change_event(entity, 15, now)
await send_ext_temperature_change_event(entity, 1, now)

await entity.async_set_preset_mode(PRESET_BOOST)
assert entity.preset_mode is PRESET_BOOST
assert entity.power_manager.overpowering_state is STATE_UNKNOWN
assert entity.target_temperature == 19
await hass.async_block_till_done()

await entity.async_set_hvac_mode(HVACMode.HEAT)
assert entity.hvac_mode is HVACMode.HEAT

await hass.async_block_till_done()
await asyncio.sleep(0.1)

# the power of Vtherm should have been added
assert central_power_manager.started_vtherm_total_power == 1000

# 2. Check that another heater cannot heat
entry2 = MockConfigEntry(
domain=DOMAIN,
title="TheOverClimateMockName2",
unique_id="uniqueId2",
data={
CONF_NAME: "TheOverClimateMockName2",
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: 5,
CONF_TEMP_MIN: 15,
CONF_TEMP_MAX: 30,
CONF_USE_WINDOW_FEATURE: False,
CONF_USE_MOTION_FEATURE: False,
CONF_USE_POWER_FEATURE: True,
CONF_USE_PRESENCE_FEATURE: False,
CONF_UNDERLYING_LIST: ["switch.mock_climate"],
CONF_MINIMAL_ACTIVATION_DELAY: 30,
CONF_SAFETY_DELAY_MIN: 5,
CONF_SAFETY_MIN_ON_PERCENT: 0.3,
CONF_DEVICE_POWER: 150,
CONF_PRESET_POWER: 12,
},
)

entity2: ThermostatOverClimate = await create_thermostat(hass, entry2, "climate.theoverclimatemockname2", temps)
assert entity2

fake_underlying_climate = MockClimate(
hass=hass,
unique_id="mockUniqueId",
name="MockClimateName",
)

# fmt: off
with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()), \
patch("custom_components.versatile_thermostat.thermostat_switch.ThermostatOverSwitch.is_device_active", new_callable=PropertyMock, return_value=False), \
patch("custom_components.versatile_thermostat.underlyings.UnderlyingClimate.find_underlying_climate",return_value=fake_underlying_climate):
# fmt: on
# make the heater heats
await entity2.async_set_preset_mode(PRESET_COMFORT)
assert entity2.preset_mode is PRESET_COMFORT
assert entity2.power_manager.overpowering_state is STATE_UNKNOWN
assert entity2.target_temperature == 18
await entity2.async_set_hvac_mode(HVACMode.HEAT)
assert entity2.hvac_mode is HVACMode.HEAT

await hass.async_block_till_done()
await asyncio.sleep(0.1)

# the power of Vtherm should have not been added (cause it has not started) and the entity2 should be shedding
assert central_power_manager.started_vtherm_total_power == 1000


assert entity2.power_manager.overpowering_state is STATE_ON

# 3. sends a new power sensor event
await send_max_power_change_event(entity, 2150, now)
# No change
assert central_power_manager.started_vtherm_total_power == 1000

await send_power_change_event(entity, 1010, now)
assert central_power_manager.started_vtherm_total_power == 0
Loading

0 comments on commit 05fe205

Please sign in to comment.