diff --git a/custom_components/versatile_thermostat/base_thermostat.py b/custom_components/versatile_thermostat/base_thermostat.py index d063e9f..4f6d3b2 100644 --- a/custom_components/versatile_thermostat/base_thermostat.py +++ b/custom_components/versatile_thermostat/base_thermostat.py @@ -1609,14 +1609,8 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): return False # Check overpowering condition - await VersatileThermostatAPI.get_vtherm_api().central_power_manager.refresh_state() - - # TODO remove this - # overpowering is now centralized - # overpowering = await self._power_manager.check_overpowering() - # if overpowering == STATE_ON: - # _LOGGER.debug("%s - End of cycle (overpowering)", self) - # return True + # Not usefull. Will be done at the next power refresh + # await VersatileThermostatAPI.get_vtherm_api().central_power_manager.refresh_state() safety: bool = await self._safety_manager.refresh_state() if safety and self.is_over_climate: @@ -1974,5 +1968,17 @@ class BaseThermostat(ClimateEntity, RestoreEntity, Generic[T]): @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""" + if self._prop_algorithm and self._prop_algorithm.on_percent is not None: + return round(self._prop_algorithm.on_percent * 100, 0) + else: + return None + + @property + def on_percent(self) -> float | None: """Get the current on_percent value. valid only for Vtherm with a TPI algo""" - return None + if self._prop_algorithm and self._prop_algorithm.on_percent is not None: + return self._prop_algorithm.on_percent + else: + return None diff --git a/custom_components/versatile_thermostat/central_feature_power_manager.py b/custom_components/versatile_thermostat/central_feature_power_manager.py index af1a8c9..a5389f3 100644 --- a/custom_components/versatile_thermostat/central_feature_power_manager.py +++ b/custom_components/versatile_thermostat/central_feature_power_manager.py @@ -4,6 +4,7 @@ import logging from typing import Any from functools import cmp_to_key +from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant, Event, callback from homeassistant.helpers.event import ( async_track_state_change_event, @@ -163,54 +164,52 @@ class CentralFeaturePowerManager(BaseFeatureManager): total_power_gain = 0 for vtherm in vtherms_sorted: - device_power = vtherm.power_manager.device_power if vtherm.is_device_active and not vtherm.power_manager.is_overpowering_detected: + device_power = vtherm.power_manager.device_power total_power_gain += device_power - _LOGGER.debug("vtherm %s should be in overpowering state", vtherm.name) + _LOGGER.info("vtherm %s should be in overpowering state (device_power=%.2f)", vtherm.name, device_power) await vtherm.power_manager.set_overpowering(True, device_power) _LOGGER.debug("after vtherm %s total_power_gain=%s, available_power=%s", vtherm.name, total_power_gain, available_power) if total_power_gain >= -available_power: _LOGGER.debug("We have found enough vtherm to set to overpowering") break + # unshedding only else: - # vtherms_sorted.reverse() + vtherms_sorted.reverse() _LOGGER.debug("The available power is is > 0 (%s). Do a complete shedding/un-shedding calculation for list: %s", available_power, vtherms_sorted) - total_affected_power = 0 - force_overpowering = False + total_power_added = 0 for vtherm in vtherms_sorted: - device_power = vtherm.power_manager.device_power + # We want to do always unshedding in order to initialize the state + # so we cannot use is_overpowering_detected which test also UNKNOWN and UNAVAILABLE + if vtherm.power_manager.overpowering_state == STATE_OFF: + continue + + power_consumption_max = device_power = vtherm.power_manager.device_power # calculate the power_consumption_max - if vtherm.is_device_active: - power_consumption_max = 0 - else: - if vtherm.is_over_climate: - power_consumption_max = device_power - else: - if vtherm.proportional_algorithm.on_percent > 0: - power_consumption_max = max( - device_power / vtherm.nb_underlying_entities, - device_power * vtherm.proportional_algorithm.on_percent, - ) - else: - power_consumption_max = 0 + if vtherm.on_percent is not None: + power_consumption_max = max( + device_power / vtherm.nb_underlying_entities, + device_power * vtherm.on_percent, + ) _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 force_overpowering or (total_affected_power + power_consumption_max >= available_power): - _LOGGER.debug("vtherm %s should be in overpowering state", vtherm.name) - if not vtherm.power_manager.is_overpowering_detected: - # To force all others vtherms to be in overpowering - force_overpowering = True - await vtherm.power_manager.set_overpowering(True, power_consumption_max) - else: - total_affected_power += power_consumption_max - # Always set to false to init the state - _LOGGER.debug("vtherm %s should not be in overpowering state", vtherm.name) - await vtherm.power_manager.set_overpowering(False) + # 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) - _LOGGER.debug("after vtherm %s total_affected_power=%s, available_power=%s", vtherm.name, total_affected_power, available_power) + # we count the unshedding only if the VTherm was in shedding + if vtherm.power_manager.is_overpowering_detected: + total_power_added += power_consumption_max + + 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) def get_climate_components_entities(self) -> list: """Get all VTherms entitites""" diff --git a/custom_components/versatile_thermostat/thermostat_climate_valve.py b/custom_components/versatile_thermostat/thermostat_climate_valve.py index f7aada2..8d7ea3a 100644 --- a/custom_components/versatile_thermostat/thermostat_climate_valve.py +++ b/custom_components/versatile_thermostat/thermostat_climate_valve.py @@ -263,15 +263,6 @@ class ThermostatOverClimateValve(ThermostatOverClimate): """True if the Thermostat is regulated by valve""" return True - @overrides - @property - def power_percent(self) -> float | None: - """Get the current on_percent value""" - if self._prop_algorithm: - return round(self._prop_algorithm.on_percent * 100, 0) - else: - return None - # @property # def hvac_modes(self) -> list[HVACMode]: # """Get the hvac_modes""" diff --git a/custom_components/versatile_thermostat/thermostat_switch.py b/custom_components/versatile_thermostat/thermostat_switch.py index f7eea02..6ae0939 100644 --- a/custom_components/versatile_thermostat/thermostat_switch.py +++ b/custom_components/versatile_thermostat/thermostat_switch.py @@ -61,15 +61,6 @@ class ThermostatOverSwitch(BaseThermostat[UnderlyingSwitch]): """True if the switch is inversed (for pilot wire and diode)""" return self._is_inversed is True - @overrides - @property - def power_percent(self) -> float | None: - """Get the current on_percent value""" - if self._prop_algorithm: - return round(self._prop_algorithm.on_percent * 100, 0) - else: - return None - @overrides def post_init(self, config_entry: ConfigData): """Initialize the Thermostat""" diff --git a/tests/test_binary_sensors.py b/tests/test_binary_sensors.py index 7ef51ee..7c97c5f 100644 --- a/tests/test_binary_sensors.py +++ b/tests/test_binary_sensors.py @@ -172,16 +172,17 @@ async def test_overpowering_binary_sensors( # Send power mesurement side_effects = SideEffects( { - "sensor.the_power_sensor": State("sensor.the_power_sensor", 100), - "sensor.the_max_power_sensor": State("sensor.the_max_power_sensor", 150), + "sensor.the_power_sensor": State("sensor.the_power_sensor", 150), + "sensor.the_max_power_sensor": State("sensor.the_max_power_sensor", 100), }, State("unknown.entity_id", "unknown"), ) # fmt:off - with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()): + 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"): # fmt: on - await send_power_change_event(entity, 100, now) - await send_max_power_change_event(entity, 150, now) + await send_power_change_event(entity, 150, now) + await send_max_power_change_event(entity, 100, now) assert entity.power_manager.is_overpowering_detected is True assert entity.power_manager.overpowering_state is STATE_ON diff --git a/tests/test_bugs.py b/tests/test_bugs.py index 3c049aa..9ad2902 100644 --- a/tests/test_bugs.py +++ b/tests/test_bugs.py @@ -348,7 +348,7 @@ async def test_bug_407( await entity.async_set_preset_mode(PRESET_COMFORT) assert entity.hvac_mode is HVACMode.HEAT assert entity.preset_mode is PRESET_COMFORT - assert entity.power_manager.overpowering_state is STATE_OFF + assert entity.power_manager.overpowering_state is STATE_UNKNOWN assert entity.target_temperature == 18 # waits that the heater starts await hass.async_block_till_done() @@ -398,7 +398,8 @@ async def test_bug_407( assert entity.target_temperature == 19 assert mock_service_call.call_count >= 1 - # 3. if heater is stopped (is_device_active==False), then overpowering should be started + # 3. if heater is stopped (is_device_active==False) and power is over max, then overpowering should be started + side_effects.add_or_update_side_effect("sensor.the_power_sensor", State("sensor.the_power_sensor", 150)) with patch( "homeassistant.core.ServiceRegistry.async_call" ) as mock_service_call, patch( @@ -420,10 +421,10 @@ async def test_bug_407( # simulate a refresh for central power (not necessary) await do_central_power_refresh(hass) - assert entity.power_manager.is_overpowering_detected is True + assert entity.power_manager.is_overpowering_detected is False assert entity.hvac_mode is HVACMode.HEAT - assert entity.preset_mode is PRESET_POWER - assert entity.power_manager.overpowering_state is STATE_ON + assert entity.preset_mode is PRESET_COMFORT + assert entity.power_manager.overpowering_state is STATE_OFF @pytest.mark.parametrize("expected_lingering_tasks", [True]) diff --git a/tests/test_central_power_manager.py b/tests/test_central_power_manager.py index 6932c77..7880775 100644 --- a/tests/test_central_power_manager.py +++ b/tests/test_central_power_manager.py @@ -273,7 +273,7 @@ async def test_central_power_manageer_find_vtherms( @pytest.mark.parametrize( "current_power, current_max_power, vtherm_configs, expected_results", [ - # simple nominal test (no shedding) + # simple nominal test (initialize overpowering state in VTherm) ( 1000, 5000, @@ -286,139 +286,32 @@ async def test_central_power_manageer_find_vtherms( "nb_underlying_entities": 1, "on_percent": 0, "is_overpowering_detected": False, + "overpowering_state": STATE_UNKNOWN, }, - ], - {"vtherm1": False}, - ), - # Simple trivial shedding - ( - 1000, - 2000, - [ - # should be overpowering - { - "name": "vtherm1", - "device_power": 1100, - "is_device_active": False, - "is_over_climate": False, - "nb_underlying_entities": 1, - "on_percent": 1, - "is_overpowering_detected": False, - }, - # should be overpowering with many underlmying entities { "name": "vtherm2", - "device_power": 4000, - "is_device_active": False, + "device_power": 10000, + "is_device_active": True, "is_over_climate": False, "nb_underlying_entities": 4, - "on_percent": 0.1, + "on_percent": 100, "is_overpowering_detected": False, + "overpowering_state": STATE_UNKNOWN, }, - # over_climate should be overpowering { "name": "vtherm3", - "device_power": 1000, - "is_device_active": False, - "is_over_climate": True, - "is_overpowering_detected": False, - }, - # should pass but because will be also overpowering because previous was overpowering - { - "name": "vtherm4", - "device_power": 800, - "is_device_active": False, - "is_over_climate": False, - "nb_underlying_entities": 1, - "on_percent": 1, - "is_overpowering_detected": False, - }, - ], - {"vtherm1": True, "vtherm2": True, "vtherm3": True, "vtherm4": True}, - ), - # More complex shedding - ( - 1000, - 2000, - [ - # already overpowering (non change) - { - "name": "vtherm1", - "device_power": 1100, - "is_device_active": False, - "is_over_climate": False, - "nb_underlying_entities": 1, - "on_percent": 1, - "is_overpowering_detected": True, - }, - # already overpowering and already active (can be un overpowered) - { - "name": "vtherm2", - "device_power": 1100, + "device_power": 5000, "is_device_active": True, "is_over_climate": True, - "is_overpowering_detected": True, - }, - # should terminate the overpowering - { - "name": "vtherm3", - "device_power": 800, - "is_device_active": False, - "is_over_climate": False, - "nb_underlying_entities": 1, - "on_percent": 1, - "is_overpowering_detected": True, - }, - # should terminate the overpowering and active - { - "name": "vtherm4", - "device_power": 3800, - "is_device_active": True, - "is_over_climate": False, - "nb_underlying_entities": 1, - "on_percent": 1, - "is_overpowering_detected": True, - }, - ], - {"vtherm2": False, "vtherm3": False, "vtherm4": False}, - ), - # More complex shedding - ( - 1000, - 2000, - [ - # already overpowering (non change) - { - "name": "vtherm1", - "device_power": 1100, - "is_device_active": True, - "is_over_climate": False, - "nb_underlying_entities": 1, - "on_percent": 1, - "is_overpowering_detected": True, - }, - # should be overpowering - { - "name": "vtherm2", - "device_power": 1800, - "is_device_active": False, - "is_over_climate": True, - "is_overpowering_detected": False, - }, - # should terminate the overpowering and active but just before is overpowering - { - "name": "vtherm3", - "device_power": 100, - "is_device_active": True, - "is_over_climate": False, - "nb_underlying_entities": 1, - "on_percent": 1, "is_overpowering_detected": False, + "overpowering_state": STATE_UNKNOWN, }, + {"name": "vtherm4", "device_power": 1000, "is_device_active": True, "is_over_climate": True, "is_overpowering_detected": False, "overpowering_state": STATE_OFF}, ], - {"vtherm1": False, "vtherm2": True, "vtherm3": True}, + # init vtherm1 to False + {"vtherm3": False, "vtherm2": False, "vtherm1": False}, ), - # Sheeding only current_power > max_power (need to gain 1000 ) + # Shedding ( 2000, 1000, @@ -432,36 +325,31 @@ async def test_central_power_manageer_find_vtherms( "nb_underlying_entities": 1, "on_percent": 1, "is_overpowering_detected": False, + "overpowering_state": STATE_OFF, }, - # should be overpowering but is already + # should be overpowering with many underlmying entities { "name": "vtherm2", - "device_power": 600, + "device_power": 400, "is_device_active": True, "is_over_climate": False, "nb_underlying_entities": 4, "on_percent": 0.1, - "is_overpowering_detected": True, + "is_overpowering_detected": False, + "overpowering_state": STATE_UNKNOWN, }, - # over_climate should be not overpowering (device not active) + # over_climate should be overpowering { "name": "vtherm3", - "device_power": 690, - "is_device_active": False, - "is_over_climate": True, - "is_overpowering_detected": False, - }, - # over_climate should be overpowering (device active and not already overpowering) - { - "name": "vtherm4", - "device_power": 690, + "device_power": 100, "is_device_active": True, "is_over_climate": True, "is_overpowering_detected": False, + "overpowering_state": STATE_OFF, }, - # should not overpower (keep as is) + # should pass cause not active { - "name": "vtherm5", + "name": "vtherm4", "device_power": 800, "is_device_active": False, "is_over_climate": False, @@ -469,8 +357,98 @@ async def test_central_power_manageer_find_vtherms( "on_percent": 1, "is_overpowering_detected": False, }, + # should be not overpowering (already overpowering) + { + "name": "vtherm5", + "device_power": 400, + "is_device_active": True, + "is_over_climate": False, + "nb_underlying_entities": 4, + "on_percent": 0.1, + "is_overpowering_detected": True, + "overpowering_state": STATE_ON, + }, + # should be overpowering with many underluying entities + { + "name": "vtherm6", + "device_power": 400, + "is_device_active": True, + "is_over_climate": False, + "nb_underlying_entities": 4, + "on_percent": 0.1, + "is_overpowering_detected": False, + "overpowering_state": STATE_UNKNOWN, + }, + # should not be overpowering (we have enough) + { + "name": "vtherm7", + "device_power": 1000, + "is_device_active": True, + "is_over_climate": True, + "is_overpowering_detected": False, + "overpowering_state": STATE_UNKNOWN, + }, ], - {"vtherm1": True, "vtherm4": True}, + {"vtherm1": True, "vtherm2": True, "vtherm3": True, "vtherm6": True}, + ), + # Un-shedding only (will be taken in reverse order) + ( + 1000, + 2000, + [ + # should be not unshedded (we have enough) + { + "name": "vtherm0", + "device_power": 800, + "is_device_active": False, + "is_over_climate": False, + "nb_underlying_entities": 1, + "on_percent": 1, + "is_overpowering_detected": True, + "overpowering_state": STATE_ON, + }, + # should be unshedded + { + "name": "vtherm1", + "device_power": 800, + "is_device_active": False, + "is_over_climate": False, + "nb_underlying_entities": 1, + "on_percent": 1, + "is_overpowering_detected": True, + "overpowering_state": STATE_ON, + }, + # already stay unshedded cause already unshedded + { + "name": "vtherm2", + "device_power": 1100, + "is_device_active": True, + "is_over_climate": True, + "is_overpowering_detected": False, + "overpowering_state": STATE_OFF, + }, + # should be unshedded + { + "name": "vtherm3", + "device_power": 200, + "is_device_active": False, + "is_over_climate": True, + "is_overpowering_detected": True, + "overpowering_state": STATE_ON, + }, + # should be unshedded + { + "name": "vtherm4", + "device_power": 300, + "is_device_active": False, + "is_over_climate": False, + "nb_underlying_entities": 1, + "on_percent": 1, + "is_overpowering_detected": True, + "overpowering_state": STATE_ON, + }, + ], + {"vtherm4": False, "vtherm3": False, "vtherm1": False}, ), ], ) @@ -501,7 +479,10 @@ async def test_central_power_manageer_calculate_shedding( vtherm.nb_underlying_entities = vtherm_config.get("nb_underlying_entities") if not vtherm_config.get("is_over_climate"): vtherm.proportional_algorithm = MagicMock() - vtherm.proportional_algorithm.on_percent = vtherm_config.get("on_percent") + vtherm.on_percent = vtherm.proportional_algorithm.on_percent = vtherm_config.get("on_percent") + else: + vtherm.on_percent = None + vtherm.proportional_algorithm = None vtherm.power_manager = MagicMock(spec=FeaturePowerManager) vtherm.power_manager._vtherm = vtherm @@ -510,6 +491,7 @@ async def test_central_power_manageer_calculate_shedding( "is_overpowering_detected" ) vtherm.power_manager.device_power = vtherm_config.get("device_power") + vtherm.power_manager.overpowering_state = vtherm_config.get("overpowering_state") async def mock_set_overpowering( overpowering, power_consumption_max=0, v=vtherm diff --git a/tests/test_multiple_switch.py b/tests/test_multiple_switch.py index f053032..38be50b 100644 --- a/tests/test_multiple_switch.py +++ b/tests/test_multiple_switch.py @@ -812,21 +812,20 @@ async def test_multiple_switch_power_management( assert entity.power_manager.overpowering_state is STATE_OFF # 2. Send power max mesurement too low and HVACMode is on - side_effects.add_or_update_side_effect("sensor.the_max_power_sensor", State("sensor.the_max_power_sensor", 74)) + side_effects.add_or_update_side_effect("sensor.the_max_power_sensor", State("sensor.the_max_power_sensor", 49)) - with patch( - "custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event" - ) as mock_send_event, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on" - ) as mock_heater_on, patch( - "custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off" - ) as mock_heater_off: + #fmt: off + with patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \ + patch("custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on") as mock_heater_on, \ + patch("custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off") as mock_heater_off, \ + patch("custom_components.versatile_thermostat.thermostat_switch.ThermostatOverSwitch.is_device_active", return_value="True"): + #fmt: on now = now + timedelta(seconds=30) VersatileThermostatAPI.get_vtherm_api()._set_now(now) assert entity.power_percent > 0 # 100 of the device / 4 -> 25, current power 50 so max is 75 - await send_max_power_change_event(entity, 74, datetime.now()) + await send_max_power_change_event(entity, 49, datetime.now()) assert entity.power_manager.is_overpowering_detected is True # All configuration is complete and power is > power_max we switch to POWER preset assert entity.preset_mode is PRESET_POWER @@ -843,7 +842,7 @@ async def test_multiple_switch_power_management( "type": "start", "current_power": 50, "device_power": 100, - "current_max_power": 74, + "current_max_power": 49, "current_power_consumption": 100, }, ), diff --git a/tests/test_power.py b/tests/test_power.py index 99e40b8..a311242 100644 --- a/tests/test_power.py +++ b/tests/test_power.py @@ -517,17 +517,18 @@ async def test_power_management_hvac_on( assert entity.power_manager.overpowering_state is STATE_OFF # Send power max mesurement too low and HVACMode is on - side_effects.add_or_update_side_effect("sensor.the_max_power_sensor", State("sensor.the_max_power_sensor", 149)) + side_effects.add_or_update_side_effect("sensor.the_max_power_sensor", State("sensor.the_max_power_sensor", 49)) # fmt:off with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()), \ patch("custom_components.versatile_thermostat.base_thermostat.BaseThermostat.send_event") as mock_send_event, \ patch("custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_on") as mock_heater_on, \ - patch("custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off") as mock_heater_off: + patch("custom_components.versatile_thermostat.underlyings.UnderlyingSwitch.turn_off") as mock_heater_off, \ + patch("custom_components.versatile_thermostat.thermostat_switch.ThermostatOverSwitch.is_device_active", return_value="True"): # fmt: on now = now + timedelta(seconds=30) VersatileThermostatAPI.get_vtherm_api()._set_now(now) - await send_max_power_change_event(entity, 149, datetime.now()) + await send_max_power_change_event(entity, 49, now) assert entity.power_manager.is_overpowering_detected is True # All configuration is complete and power is > power_max we switch to POWER preset assert entity.preset_mode is PRESET_POWER @@ -544,7 +545,7 @@ async def test_power_management_hvac_on( "type": "start", "current_power": 50, "device_power": 100, - "current_max_power": 149, + "current_max_power": 49, "current_power_consumption": 100.0, }, ), @@ -554,7 +555,7 @@ async def test_power_management_hvac_on( assert mock_heater_on.call_count == 0 assert mock_heater_off.call_count == 1 - # Send power mesurement low to unseet power preset + # Send power mesurement low to unset power preset side_effects.add_or_update_side_effect("sensor.the_power_sensor", State("sensor.the_power_sensor", 48)) # fmt:off with patch("homeassistant.core.StateMachine.get", side_effect=side_effects.get_side_effects()), \ @@ -565,7 +566,7 @@ async def test_power_management_hvac_on( now = now + timedelta(seconds=30) VersatileThermostatAPI.get_vtherm_api()._set_now(now) - await send_power_change_event(entity, 48, datetime.now()) + await send_power_change_event(entity, 48, now) assert entity.power_manager.is_overpowering_detected is False # All configuration is complete and power is < power_max, we restore previous preset assert entity.preset_mode is PRESET_BOOST @@ -582,7 +583,7 @@ async def test_power_management_hvac_on( "type": "end", "current_power": 48, "device_power": 100, - "current_max_power": 149, + "current_max_power": 49, }, ), ],