From 874097a74ac9e54b5cf88b8501315e9b10d463dc Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 9 Oct 2026 11:00:29 +0200 Subject: [PATCH 1/3] =?UTF-8?q?Verbraucher:=20nicht=20erfasste=20Verbr?= =?UTF-8?q?=C3=A4uche=20in=20HV?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../public/default-dynamic-security.json | 8 +- packages/control/counter_all/counter_all.py | 31 +- .../control/counter_all/counter_all_data.py | 2 + .../counter_home_consumption_test.py | 306 +++++++++++++++++- packages/helpermodules/setdata.py | 1 + packages/helpermodules/update_config.py | 1 + 6 files changed, 343 insertions(+), 6 deletions(-) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index adae24d06a..d3f23d07c1 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -3000,6 +3000,12 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientReceive", + "topic": "openWB/counter/set/unmeasured_home_consumption", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/counter/set/imported_home_consumption", @@ -3098,4 +3104,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/packages/control/counter_all/counter_all.py b/packages/control/counter_all/counter_all.py index 5ef6cb5972..6673d4a004 100644 --- a/packages/control/counter_all/counter_all.py +++ b/packages/control/counter_all/counter_all.py @@ -50,8 +50,10 @@ def get_id_evu_counter(self) -> int: def set_home_consumption(self) -> None: try: home_consumption, not_in_home_consumption, elements = self._calc_home_consumption() + unmeasured_home_consumption = self._calc_unmeasured_home_consumption() home_consumption = round(home_consumption, 2) not_in_home_consumption = round(not_in_home_consumption, 2) + unmeasured_home_consumption = round(unmeasured_home_consumption, 2) if home_consumption < 0: log.error( f"Ungültiger Hausverbrauch: {home_consumption}W, Berücksichtigte Komponenten neben EVU {elements}") @@ -68,9 +70,11 @@ def set_home_consumption(self) -> None: return else: home_consumption = 0 + unmeasured_home_consumption = 0 else: self.data.set.invalid_home_consumption = 0 self.data.set.not_in_home_consumption = not_in_home_consumption + self.data.set.unmeasured_home_consumption = unmeasured_home_consumption self.data.set.home_consumption = home_consumption imported, _ = self.sim_counter.sim_count(self.data.set.home_consumption) self.data.set.imported_home_consumption = round(imported, 2) @@ -160,6 +164,23 @@ def _calc_home_consumption_from_counter( return home_consumption, not_home_consumption + def _calc_unmeasured_home_consumption_from_counter(self, element: Dict) -> float: + elements = self.get_elements_for_downstream_calculation(element["id"]) + element_for_calculation = {"id": element["id"], "type": ComponentType.COUNTER.value, "children": elements} + unmeasured_home_consumption = self._get_local_power_from_counter(element_for_calculation) + + for child in elements: + comp = self._get_component(child) + + if comp.data.get.fault_state < 2: + if child["type"] == ComponentType.COUNTER.value: + unmeasured_home_consumption += self._calc_unmeasured_home_consumption_from_counter(child) + else: + log.warning( + f"Komponente {element['type']}{comp.num} ist im Fehlerzustand und wird nicht berücksichtigt.") + + return unmeasured_home_consumption + def _calc_home_consumption(self) -> Tuple[float, float, Dict]: evu_id = self.get_id_evu_counter() @@ -178,12 +199,20 @@ def _calc_home_consumption(self) -> Tuple[float, float, Dict]: # Rekursion startet immer beim EVU-Zähler. home_consumption, not_in_home_consumption = self._calc_home_consumption_from_counter( evu_element, CounterMode.HOME_CONSUMPTION.value) - home_consumption -= self.data.set.smarthome_power_excluded_from_home_consumption not_in_home_consumption += self.data.set.smarthome_power_excluded_from_home_consumption return home_consumption, not_in_home_consumption, evu_element + def _calc_unmeasured_home_consumption(self) -> float: + evu_id = self.get_id_evu_counter() + evu_element = {"id": evu_id, "type": ComponentType.COUNTER.value} + + unmeasured_home_consumption = self._calc_unmeasured_home_consumption_from_counter(evu_element) + unmeasured_home_consumption -= self.data.set.smarthome_power_excluded_from_home_consumption + + return unmeasured_home_consumption + def _add_hybrid_bat(self, id: int) -> List: elements = [] inverter_children = self.get_entry_of_element(id)["children"] diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py index af1fb6246a..2290372a07 100644 --- a/packages/control/counter_all/counter_all_data.py +++ b/packages/control/counter_all/counter_all_data.py @@ -23,6 +23,8 @@ class Set: default=False, metadata={"topic": "set/loadmanagement_active"}) home_consumption: float = field(default=0, metadata={"topic": "set/home_consumption"}) not_in_home_consumption: float = field(default=0, metadata={"topic": "set/not_in_home_consumption"}) + unmeasured_home_consumption: float = field( + default=0, metadata={"topic": "set/unmeasured_home_consumption"}) smarthome_power_excluded_from_home_consumption: float = field( default=0, metadata={"topic": "set/smarthome_power_excluded_from_home_consumption"}) diff --git a/packages/control/counter_all/counter_home_consumption_test.py b/packages/control/counter_all/counter_home_consumption_test.py index 1714d67fe2..0b5df25cf4 100644 --- a/packages/control/counter_all/counter_home_consumption_test.py +++ b/packages/control/counter_all/counter_home_consumption_test.py @@ -68,22 +68,26 @@ def test_calc_home_consumption_with_configured_home_consumption_counter( @pytest.mark.parametrize(["home_consumption", "not_in_home_consumption", + "unmeasured_home_consumption", "invalid_home_consumption", "initial_not_in_home_consumption", "expected_home_consumption", "expected_invalid_home_consumption", - "expected_not_in_home_consumption"], - [pytest.param(500, 150, 0, 42, 500, 0, 150, id="valid home consumption"), - pytest.param(-100, 150, 0, 42, 200, 1, 42, id="first invalid home consumption"), - pytest.param(-100, 150, 3, 42, 0, 3, 150, + "expected_not_in_home_consumption", + "expected_unmeasured_home_consumption"], + [pytest.param(500, 150, 350, 0, 42, 500, 0, 150, 350, id="valid home consumption"), + pytest.param(-100, 150, 350, 0, 42, 200, 1, 42, 0, id="first invalid home consumption"), + pytest.param(-100, 150, 350, 3, 42, 0, 3, 150, 0, id="invalid home consumption, reset home consumption")]) def test_set_home_consumption(home_consumption: int, not_in_home_consumption: int, + unmeasured_home_consumption: int, invalid_home_consumption: int, initial_not_in_home_consumption: int, expected_home_consumption: int, expected_invalid_home_consumption: int, expected_not_in_home_consumption: int, + expected_unmeasured_home_consumption: int, monkeypatch, data_): # setup @@ -93,7 +97,10 @@ def test_set_home_consumption(home_consumption: int, c.data.set.home_consumption = 200 c.data.set.not_in_home_consumption = initial_not_in_home_consumption calc_home_consumption_mock = Mock(return_value=[home_consumption, not_in_home_consumption, []]) + calc_unmeasured_home_consumption_mock = Mock(return_value=unmeasured_home_consumption) monkeypatch.setattr(CounterAll, "_calc_home_consumption", calc_home_consumption_mock) + monkeypatch.setattr( + CounterAll, "_calc_unmeasured_home_consumption", calc_unmeasured_home_consumption_mock) # execution c.set_home_consumption() @@ -102,6 +109,7 @@ def test_set_home_consumption(home_consumption: int, assert c.data.set.invalid_home_consumption == expected_invalid_home_consumption assert c.data.set.home_consumption == expected_home_consumption assert c.data.set.not_in_home_consumption == expected_not_in_home_consumption + assert c.data.set.unmeasured_home_consumption == expected_unmeasured_home_consumption @pytest.mark.parametrize( @@ -654,3 +662,293 @@ def data_home_consumption() -> None: spec=CounterGet, power=7150, fault_state=0), config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.AUTO_HOME_CONSUMPTION.value))), }) + + +def test_set_home_consumption_sets_unmeasured_home_consumption_rest_power(): + data.data_init(Mock()) + c = CounterAll() + c.data.get.hierarchy = [{ + "id": 0, + "type": "counter", + "children": [{"id": 7, "type": "consumer", "children": []}], + }] + + data.data.counter_data = { + "counter0": Mock( + spec=Counter, + num=0, + data=Mock( + spec=CounterData, + get=Mock(spec=CounterGet, power=1000, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.AUTO_HOME_CONSUMPTION.value), + ), + ), + } + data.data.consumer_data = { + "consumer7": Mock( + num=7, + data=Mock( + get=Mock(power=300, fault_state=0), + config=Mock(is_home_consumption_consumer=CounterMode.HOME_CONSUMPTION.value), + ), + ), + } + + c.set_home_consumption() + + assert c.data.set.home_consumption == 1000 + assert c.data.set.unmeasured_home_consumption == 700 + + +def test_set_home_consumption_adds_child_counter_rest_to_unmeasured_home_consumption(): + data.data_init(Mock()) + c = CounterAll() + c.data.get.hierarchy = [{ + "id": 0, + "type": "counter", + "children": [{"id": 8, "type": "counter", "children": []}], + }] + + data.data.counter_data = { + "counter0": Mock( + spec=Counter, + num=0, + data=Mock( + spec=CounterData, + get=Mock(spec=CounterGet, power=1000, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.AUTO_HOME_CONSUMPTION.value), + ), + ), + "counter8": Mock( + spec=Counter, + num=8, + data=Mock( + spec=CounterData, + get=Mock(spec=CounterGet, power=300, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.NOT_HOME_CONSUMPTION.value), + ), + ), + } + + c.set_home_consumption() + + assert c.data.set.home_consumption == 700 + assert c.data.set.unmeasured_home_consumption == 1000 + + +def test_set_home_consumption_adds_child_counter_rest_behind_chargepoint_to_unmeasured_home_consumption(): + data.data_init(Mock()) + c = CounterAll() + c.data.get.hierarchy = [{ + "id": 0, + "type": "counter", + "children": [{ + "id": 8, + "type": "counter", + "children": [{"id": 3, "type": "cp", "children": []}], + }], + }] + + data.data.counter_data = { + "counter0": Mock( + spec=Counter, + num=0, + data=Mock( + spec=CounterData, + get=Mock(spec=CounterGet, power=5000, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.AUTO_HOME_CONSUMPTION.value), + ), + ), + "counter8": Mock( + spec=Counter, + num=8, + data=Mock( + spec=CounterData, + get=Mock(spec=CounterGet, power=5000, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.HOME_CONSUMPTION.value), + ), + ), + } + data.data.cp_data = { + "cp3": Mock( + spec=Chargepoint, + data=Mock( + spec=ChargepointData, + get=Mock(spec=Get, power=3000, fault_state=0), + ), + ), + } + + c.set_home_consumption() + + assert c.data.set.home_consumption == 2000 + assert c.data.set.unmeasured_home_consumption == 2000 + + +def test_calc_unmeasured_home_consumption_uses_downstream_elements_on_each_counter(monkeypatch): + data.data_init(Mock()) + c = CounterAll() + c.data.get.hierarchy = [{ + "id": 0, + "type": "counter", + "children": [{"id": 8, "type": "counter", "children": []}], + }] + + data.data.counter_data = { + "counter0": Mock( + spec=Counter, + num=0, + data=Mock( + spec=CounterData, + get=Mock(spec=CounterGet, power=5000, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.AUTO_HOME_CONSUMPTION.value), + ), + ), + "counter8": Mock( + spec=Counter, + num=8, + data=Mock( + spec=CounterData, + get=Mock(spec=CounterGet, power=5000, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.HOME_CONSUMPTION.value), + ), + ), + } + data.data.cp_data = { + "cp3": Mock( + spec=Chargepoint, + data=Mock( + spec=ChargepointData, + get=Mock(spec=Get, power=3000, fault_state=0), + ), + ), + } + + def get_elements_for_downstream_calculation(counter_id): + if counter_id == 0: + return [{"id": 8, "type": "counter", "children": []}] + if counter_id == 8: + return [{"id": 3, "type": "cp", "children": []}] + return [] + + monkeypatch.setattr(c, "get_elements_for_downstream_calculation", get_elements_for_downstream_calculation) + + assert c._calc_unmeasured_home_consumption() == 2000 + + +def test_calc_unmeasured_home_consumption_subtracts_inverter_power(): + data.data_init(Mock()) + c = CounterAll() + c.data.get.hierarchy = [{ + "id": 0, + "type": "counter", + "children": [{"id": 1, "type": "inverter", "children": []}], + }] + + data.data.counter_data = { + "counter0": Mock( + spec=Counter, + num=0, + data=Mock( + spec=CounterData, + get=Mock(spec=CounterGet, power=5000, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.AUTO_HOME_CONSUMPTION.value), + ), + ), + } + data.data.pv_data = { + "pv1": Mock( + spec=Pv, + data=Mock( + spec=PvData, + get=Mock(spec=PvGet, power=3000, fault_state=0), + config=Mock(spec=PvConfig, max_ac_out=10000), + ), + ), + } + + assert c._calc_unmeasured_home_consumption() == 2000 + + +def test_calc_unmeasured_home_consumption_subtracts_battery_power(): + data.data_init(Mock()) + c = CounterAll() + c.data.get.hierarchy = [{ + "id": 0, + "type": "counter", + "children": [{"id": 2, "type": "bat", "children": []}], + }] + + data.data.counter_data = { + "counter0": Mock( + spec=Counter, + num=0, + data=Mock( + spec=CounterData, + get=Mock(spec=CounterGet, power=5000, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.AUTO_HOME_CONSUMPTION.value), + ), + ), + } + data.data.bat_data = { + "bat2": Mock( + spec=Bat, + num=2, + data=Mock( + spec=BatData, + get=Mock(spec=BatGet, power=3000, fault_state=0), + set=Mock(spec=BatSet, power_limit=None), + ), + ), + } + + assert c._calc_unmeasured_home_consumption() == 2000 + + +def test_calc_unmeasured_home_consumption_subtracts_hybrid_battery_at_inverter(): + data.data_init(Mock()) + c = CounterAll() + c.data.get.hierarchy = [{ + "id": 0, + "type": "counter", + "children": [{ + "id": 1, + "type": "inverter", + "children": [{"id": 2, "type": "bat", "children": []}], + }], + }] + + data.data.counter_data = { + "counter0": Mock( + spec=Counter, + num=0, + data=Mock( + spec=CounterData, + get=Mock(spec=CounterGet, power=5000, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.AUTO_HOME_CONSUMPTION.value), + ), + ), + } + data.data.pv_data = { + "pv1": Mock( + spec=Pv, + data=Mock( + spec=PvData, + get=Mock(spec=PvGet, power=1000, fault_state=0), + config=Mock(spec=PvConfig, max_ac_out=10000), + ), + ), + } + data.data.bat_data = { + "bat2": Mock( + spec=Bat, + num=2, + data=Mock( + spec=BatData, + get=Mock(spec=BatGet, power=2000, fault_state=0), + set=Mock(spec=BatSet, power_limit=None), + ), + ), + } + + assert c._calc_unmeasured_home_consumption() == 2000 diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index d67736b248..439ea6ca81 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -953,6 +953,7 @@ def process_counter_topic(self, msg: mqtt.MQTTMessage): self._validate_value(msg, int, [(0, 3)]) elif ("openWB/set/counter/set/home_consumption" in msg.topic or "openWB/set/counter/set/not_in_home_consumption" in msg.topic or + "openWB/set/counter/set/unmeasured_home_consumption" in msg.topic or "openWB/set/counter/set/imported_home_consumption" in msg.topic or "openWB/set/counter/set/daily_yield_home_consumption" in msg.topic or "openWB/set/counter/set/disengageable_smarthome_power" in msg.topic): diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index d96d97a2bb..7bf8f3c703 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -228,6 +228,7 @@ class UpdateConfig: "^openWB/counter/set/invalid_home_consumption$", "^openWB/counter/set/home_consumption$", "^openWB/counter/set/not_in_home_consumption$", + "^openWB/counter/set/unmeasured_home_consumption$", "^openWB/counter/set/daily_yield_home_consumption$", "^openWB/counter/set/simulation$", "^openWB/counter/[0-9]+/get/voltages$", From bb959856b1bfd4b1a784d8c8ae0125c6562bd537 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 9 Oct 2026 11:12:17 +0200 Subject: [PATCH 2/3] error handling --- packages/control/counter_all/counter_all.py | 5 ++- .../counter_home_consumption_test.py | 40 ++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/packages/control/counter_all/counter_all.py b/packages/control/counter_all/counter_all.py index 6673d4a004..71d44d99b9 100644 --- a/packages/control/counter_all/counter_all.py +++ b/packages/control/counter_all/counter_all.py @@ -54,9 +54,10 @@ def set_home_consumption(self) -> None: home_consumption = round(home_consumption, 2) not_in_home_consumption = round(not_in_home_consumption, 2) unmeasured_home_consumption = round(unmeasured_home_consumption, 2) - if home_consumption < 0: + if home_consumption < 0 or unmeasured_home_consumption < 0: log.error( - f"Ungültiger Hausverbrauch: {home_consumption}W, Berücksichtigte Komponenten neben EVU {elements}") + f"Ungültiger Hausverbrauch: {home_consumption}W, nicht erfasster Verbrauch: " + f"{unmeasured_home_consumption}W, Berücksichtigte Komponenten neben EVU {elements}") hc_counter_source = self.get_evu_counter_str() hc_counter_data = data.data.counter_data[hc_counter_source].data if hc_counter_data.get.fault_state == FaultStateLevel.NO_ERROR: diff --git a/packages/control/counter_all/counter_home_consumption_test.py b/packages/control/counter_all/counter_home_consumption_test.py index 0b5df25cf4..973385eacc 100644 --- a/packages/control/counter_all/counter_home_consumption_test.py +++ b/packages/control/counter_all/counter_home_consumption_test.py @@ -77,8 +77,12 @@ def test_calc_home_consumption_with_configured_home_consumption_counter( "expected_unmeasured_home_consumption"], [pytest.param(500, 150, 350, 0, 42, 500, 0, 150, 350, id="valid home consumption"), pytest.param(-100, 150, 350, 0, 42, 200, 1, 42, 0, id="first invalid home consumption"), + pytest.param(500, 150, -100, 0, 42, 200, 1, 42, 0, + id="first invalid unmeasured home consumption"), pytest.param(-100, 150, 350, 3, 42, 0, 3, 150, 0, - id="invalid home consumption, reset home consumption")]) + id="invalid home consumption, reset home consumption"), + pytest.param(500, 150, -100, 3, 42, 0, 3, 150, 0, + id="invalid unmeasured home consumption, reset home consumption")]) def test_set_home_consumption(home_consumption: int, not_in_home_consumption: int, unmeasured_home_consumption: int, @@ -952,3 +956,37 @@ def test_calc_unmeasured_home_consumption_subtracts_hybrid_battery_at_inverter() } assert c._calc_unmeasured_home_consumption() == 2000 + + +def test_calc_unmeasured_home_consumption_can_return_negative_value_for_invalid_handling(): + data.data_init(Mock()) + c = CounterAll() + c.data.get.hierarchy = [{ + "id": 0, + "type": "counter", + "children": [{"id": 1, "type": "inverter", "children": []}], + }] + + data.data.counter_data = { + "counter0": Mock( + spec=Counter, + num=0, + data=Mock( + spec=CounterData, + get=Mock(spec=CounterGet, power=1000, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.AUTO_HOME_CONSUMPTION.value), + ), + ), + } + data.data.pv_data = { + "pv1": Mock( + spec=Pv, + data=Mock( + spec=PvData, + get=Mock(spec=PvGet, power=3000, fault_state=0), + config=Mock(spec=PvConfig, max_ac_out=10000), + ), + ), + } + + assert c._calc_unmeasured_home_consumption() == -2000 From ad06778420e3059d4a5266a1e4c1b7a3200a3949 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 9 Oct 2026 11:17:16 +0200 Subject: [PATCH 3/3] ACL version --- data/config/mosquitto/public/default-dynamic-security.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index d3f23d07c1..0c6dbe6186 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -81,7 +81,7 @@ "anonymousGroup": "anonymous", "roles": [ { - "rolename": "openwb-version:10", + "rolename": "openwb-version:11", "textname": "openWB Versionsnummer", "textdescription": "Diese Rolle ist ein Platzhalter für die openWB Versionsnummer und wird automatisch aktualisiert. Sie hat keine direkten Berechtigungen.", "acls": []