diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cb4393d..147f824 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -54,6 +54,7 @@ "python.analysis.autoSearchPaths": true, "pylint.lintOnChange": false, "python.formatting.provider": "black", + "python.formatting.blackArgs": ["--line-length", "180"], "python.formatting.blackPath": "/usr/local/py-utils/bin/black", "editor.formatOnPaste": false, "editor.formatOnSave": true, diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..bf4c766 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +[FORMAT] +max-line-length=180 \ No newline at end of file diff --git a/custom_components/versatile_thermostat/central_feature_power_manager.py b/custom_components/versatile_thermostat/central_feature_power_manager.py index 477b82e..5614faf 100644 --- a/custom_components/versatile_thermostat/central_feature_power_manager.py +++ b/custom_components/versatile_thermostat/central_feature_power_manager.py @@ -48,10 +48,7 @@ class CentralFeaturePowerManager(BaseFeatureManager): """Gets the configuration parameters""" central_config = self._vtherm_api.find_central_configuration() if not central_config: - _LOGGER.info( - "%s - No central configuration is found. Power management will be deactivated.", - self, - ) + _LOGGER.info("No central configuration is found. Power management will be deactivated") return self._power_sensor_entity_id = entry_infos.get(CONF_POWER_SENSOR) @@ -69,10 +66,7 @@ class CentralFeaturePowerManager(BaseFeatureManager): ): self._is_configured = True else: - _LOGGER.info( - "%s - Power management is not fully configured and will be deactivated", - self, - ) + _LOGGER.info("Power management is not fully configured and will be deactivated") def start_listening(self): """Start listening the power sensor""" @@ -100,14 +94,14 @@ class CentralFeaturePowerManager(BaseFeatureManager): @callback async def _power_sensor_changed(self, event: Event[EventStateChangedData]): """Handle power changes.""" - _LOGGER.debug("Thermostat %s - Receive new Power event", self) + _LOGGER.debug("Receive new Power event") _LOGGER.debug(event) await self.refresh_state() @callback async def _max_power_sensor_changed(self, event: Event[EventStateChangedData]): """Handle power max changes.""" - _LOGGER.debug("Thermostat %s - Receive new Power Max event", self.name) + _LOGGER.debug("Receive new Power Max event") _LOGGER.debug(event) await self.refresh_state() @@ -122,11 +116,7 @@ class CentralFeaturePowerManager(BaseFeatureManager): new_state := get_safe_float(self._hass, self._power_sensor_entity_id) ) is not None: self._current_power = new_state - _LOGGER.debug( - "%s - Current power have been retrieved: %.3f", - self, - self._current_power, - ) + _LOGGER.debug("Current power have been retrieved: %.3f", self._current_power) ret = True # Try to acquire power max @@ -136,11 +126,7 @@ class CentralFeaturePowerManager(BaseFeatureManager): ) ) is not None: self._current_max_power = new_state - _LOGGER.debug( - "%s - Current power max have been retrieved: %.3f", - self, - self._current_max_power, - ) + _LOGGER.debug("Current power max have been retrieved: %.3f", self._current_max_power) ret = True # check if we need to re-calculate shedding @@ -159,70 +145,68 @@ class CentralFeaturePowerManager(BaseFeatureManager): async def calculate_shedding(self): """Do the shedding calculation and set/unset VTherm into overpowering state""" - if ( - not self.is_configured - or not self.current_max_power - or not self.current_power - ): + if not self.is_configured or self.current_max_power is None or self.current_power is None: return # Find all VTherms - vtherms_sorted = self.find_all_vtherm_with_power_management_sorted_by_dtemp() available_power = self.current_max_power - self.current_power + vtherms_sorted = self.find_all_vtherm_with_power_management_sorted_by_dtemp() - total_affected_power = 0 - force_overpowering = False - - for vtherm in vtherms_sorted: - device_power = vtherm.power_manager.device_power - if vtherm.is_device_active: - power_consumption_max = 0 - else: - if vtherm.is_over_climate: - power_consumption_max = device_power - else: - power_consumption_max = max( - device_power / vtherm.nb_underlying_entities, - device_power * vtherm.proportional_algorithm.on_percent, - ) - + # shedding only + if available_power < 0: _LOGGER.debug( - "%s - vtherm %s power_consumption_max is %s (device_power=%s, overclimate=%s)", - self, - 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( - "%s - vtherm %s should be in overpowering state", self, 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( - "%s - vtherm %s should not be in overpowering state", - self, - vtherm.name, - ) - await vtherm.power_manager.set_overpowering(False) - - _LOGGER.debug( - "%s - after vtherm %s total_affected_power=%s, available_power=%s", - self, - vtherm.name, - total_affected_power, + "The available power is is < 0 (%s). Set overpowering only for list: %s", available_power, + vtherms_sorted, ) + # we will set overpowering for the nearest target temp first + 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: + total_power_gain += device_power + _LOGGER.debug("vtherm %s should be in overpowering state", vtherm.name) + 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 + else: + # 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 + + for vtherm in vtherms_sorted: + device_power = vtherm.power_manager.device_power + if vtherm.is_device_active: + power_consumption_max = 0 + else: + if vtherm.is_over_climate: + power_consumption_max = device_power + else: + power_consumption_max = max( + device_power / vtherm.nb_underlying_entities, + device_power * vtherm.proportional_algorithm.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) + + _LOGGER.debug("after vtherm %s total_affected_power=%s, available_power=%s", vtherm.name, total_affected_power, available_power) def get_climate_components_entities(self) -> list: """Get all VTherms entitites""" @@ -256,10 +240,12 @@ class CentralFeaturePowerManager(BaseFeatureManager): def cmp_temps(a, b) -> int: diff_a = float("inf") diff_b = float("inf") - if a.current_temperature is not None and a.target_temperature is not None: - diff_a = a.target_temperature - a.current_temperature - if b.current_temperature is not None and b.target_temperature is not None: - diff_b = b.target_temperature - b.current_temperature + a_target = a.target_temperature if not a.power_manager.is_overpowering_detected else a.saved_target_temp + b_target = b.target_temperature if not b.power_manager.is_overpowering_detected else b.saved_target_temp + if a.current_temperature is not None and a_target is not None: + diff_a = a_target - a.current_temperature + if b.current_temperature is not None and b_target is not None: + diff_b = b_target - b.current_temperature if diff_a == diff_b: return 0 diff --git a/pyproject.toml b/pyproject.toml index e69de29..8b0dac4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.black] +# don't work. Options are in the devcontainer.yaml +line-length = 180 \ No newline at end of file diff --git a/tests/test_bugs.py b/tests/test_bugs.py index 6df6bf6..a1cf4df 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_UNKNOWN + assert entity.power_manager.overpowering_state is STATE_OFF assert entity.target_temperature == 18 # waits that the heater starts await hass.async_block_till_done() diff --git a/tests/test_central_power_manager.py b/tests/test_central_power_manager.py index 29b68b9..0d30745 100644 --- a/tests/test_central_power_manager.py +++ b/tests/test_central_power_manager.py @@ -79,6 +79,8 @@ async def test_central_power_manager_init( "is_on": True, "current_temperature": 13, "target_temperature": 12, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, { "name": "vtherm2", @@ -86,6 +88,8 @@ async def test_central_power_manager_init( "is_on": True, "current_temperature": 18, "target_temperature": 12, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, { "name": "vtherm3", @@ -93,6 +97,8 @@ async def test_central_power_manager_init( "is_on": True, "current_temperature": 12, "target_temperature": 18, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, ], ["vtherm2", "vtherm1", "vtherm3"], @@ -106,6 +112,8 @@ async def test_central_power_manager_init( "is_on": True, "current_temperature": 13, "target_temperature": 12, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, { "name": "vtherm2", @@ -113,6 +121,8 @@ async def test_central_power_manager_init( "is_on": False, "current_temperature": 18, "target_temperature": 12, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, { "name": "vtherm3", @@ -120,6 +130,8 @@ async def test_central_power_manager_init( "is_on": True, "current_temperature": 12, "target_temperature": 18, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, ], ["vtherm3"], @@ -133,6 +145,8 @@ async def test_central_power_manager_init( "is_on": True, "current_temperature": 13, "target_temperature": 12, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, { "name": "vtherm2", @@ -140,6 +154,8 @@ async def test_central_power_manager_init( "is_on": True, "current_temperature": None, "target_temperature": 12, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, { "name": "vtherm3", @@ -147,6 +163,8 @@ async def test_central_power_manager_init( "is_on": True, "current_temperature": 12, "target_temperature": 18, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, ], ["vtherm1", "vtherm3", "vtherm2"], @@ -160,6 +178,8 @@ async def test_central_power_manager_init( "is_on": True, "current_temperature": 13, "target_temperature": 12, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, { "name": "vtherm2", @@ -167,6 +187,8 @@ async def test_central_power_manager_init( "is_on": True, "current_temperature": 18, "target_temperature": None, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, { "name": "vtherm3", @@ -174,10 +196,45 @@ async def test_central_power_manager_init( "is_on": True, "current_temperature": 12, "target_temperature": 18, + "saved_target_temp": 18, + "is_overpowering_detected": False, }, ], ["vtherm1", "vtherm3", "vtherm2"], ), + # simple sort with overpowering detected + ( + [ + { + "name": "vtherm1", + "is_configured": True, + "is_on": True, + "current_temperature": 13, + # "target_temperature": 12, + "saved_target_temp": 21, + "is_overpowering_detected": True, + }, + { + "name": "vtherm2", + "is_configured": True, + "is_on": True, + "current_temperature": 18, + # "target_temperature": 12, + "saved_target_temp": 17, + "is_overpowering_detected": True, + }, + { + "name": "vtherm3", + "is_configured": True, + "is_on": True, + "current_temperature": 12, + # "target_temperature": 18, + "saved_target_temp": 16, + "is_overpowering_detected": True, + }, + ], + ["vtherm2", "vtherm3", "vtherm1"], + ), ], ) async def test_central_power_manageer_find_vtherms( @@ -194,7 +251,9 @@ async def test_central_power_manageer_find_vtherms( vtherm.is_on = vtherm_config.get("is_on") vtherm.current_temperature = vtherm_config.get("current_temperature") vtherm.target_temperature = vtherm_config.get("target_temperature") + vtherm.saved_target_temp = vtherm_config.get("saved_target_temp") vtherm.power_manager.is_configured = vtherm_config.get("is_configured") + vtherm.power_manager.is_overpowering_detected = vtherm_config.get("is_overpowering_detected") vtherms.append(vtherm) with patch( @@ -359,6 +418,60 @@ async def test_central_power_manageer_find_vtherms( ], {"vtherm1": False, "vtherm2": True, "vtherm3": True}, ), + # Sheeding only current_power > max_power (need to gain 1000 ) + ( + 2000, + 1000, + [ + # should be overpowering + { + "name": "vtherm1", + "device_power": 300, + "is_device_active": True, + "is_over_climate": False, + "nb_underlying_entities": 1, + "on_percent": 1, + "is_overpowering_detected": False, + }, + # should be overpowering but is already + { + "name": "vtherm2", + "device_power": 600, + "is_device_active": True, + "is_over_climate": False, + "nb_underlying_entities": 4, + "on_percent": 0.1, + "is_overpowering_detected": True, + }, + # over_climate should be not overpowering (device not active) + { + "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, + "is_device_active": True, + "is_over_climate": True, + "is_overpowering_detected": False, + }, + # should not overpower (keep as is) + { + "name": "vtherm5", + "device_power": 800, + "is_device_active": False, + "is_over_climate": False, + "nb_underlying_entities": 1, + "on_percent": 1, + "is_overpowering_detected": False, + }, + ], + {"vtherm1": True, "vtherm4": True}, + ), ], ) # @pytest.mark.skip diff --git a/tests/test_power.py b/tests/test_power.py index 1da2e82..8d68579 100644 --- a/tests/test_power.py +++ b/tests/test_power.py @@ -98,6 +98,8 @@ async def test_power_feature_manager( } ) + power_manager.start_listening() + assert power_manager.is_configured is True assert power_manager.overpowering_state == STATE_UNKNOWN @@ -197,6 +199,8 @@ async def test_power_feature_manager_set_overpowering( } ) + power_manager.start_listening() + assert power_manager.is_configured is True assert power_manager.overpowering_state == STATE_UNKNOWN