diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 3e119b30b9..e20de3a77d 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -39,32 +39,43 @@ } ], "clients": [] - }, { - "groupname": "user", - "textname": "Basisgruppe für normale Benutzer", - "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Benutzer als Ausgangsbasis sinnvoll sind.", - "roles": [{ - "rolename": "basic-display-data" - }, { - "rolename": "basic-system-data" - }, { - "rolename": "basic-theme-data" - }, { - "rolename": "basic-user-data" - }], - "clients": [] - }, { - "groupname": "display", - "textname": "Basisgruppe für alle integrierten Displays", - "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Displays als Ausgangsbasis sinnvoll sind.", - "roles": [{ - "rolename": "basic-display-data" - }, { - "rolename": "basic-system-data" - }, { - "rolename": "basic-user-data" - }], - "clients": [] + }, + { + "groupname": "user", + "textname": "Basisgruppe für normale Benutzer", + "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Benutzer als Ausgangsbasis sinnvoll sind.", + "roles": [ + { + "rolename": "basic-display-data" + }, + { + "rolename": "basic-system-data" + }, + { + "rolename": "basic-theme-data" + }, + { + "rolename": "basic-user-data" + } + ], + "clients": [] + }, + { + "groupname": "display", + "textname": "Basisgruppe für alle integrierten Displays", + "textdescription": "Diese Gruppe stellt grundlegende Rechte bereit, welche für alle Displays als Ausgangsbasis sinnvoll sind.", + "roles": [ + { + "rolename": "basic-display-data" + }, + { + "rolename": "basic-system-data" + }, + { + "rolename": "basic-user-data" + } + ], + "clients": [] } ], "anonymousGroup": "anonymous", @@ -1558,19 +1569,19 @@ }, { "acltype": "publishClientSend", - "topic": "openWB/set/counter/config/home_consumption_source_id", + "topic": "openWB/set/counter/config/consider_less_charging", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/counter/config/consider_less_charging", + "topic": "openWB/set/counter/get/hierarchy", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/counter/get/hierarchy", + "topic": "openWB/set/counter/+/config/max_power_errorcase", "priority": 0, "allow": true }, @@ -1582,13 +1593,13 @@ }, { "acltype": "publishClientSend", - "topic": "openWB/set/counter/+/config/max_power_errorcase", + "topic": "openWB/set/counter/+/config/max_currents", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/counter/+/config/max_currents", + "topic": "openWB/set/counter/+/config/is_home_consumption_counter", "priority": 0, "allow": true }, @@ -1616,12 +1627,6 @@ "priority": 0, "allow": true }, - { - "acltype": "publishClientReceive", - "topic": "openWB/counter/config/home_consumption_source_id", - "priority": 0, - "allow": true - }, { "acltype": "publishClientReceive", "topic": "openWB/counter/config/consider_less_charging", @@ -1658,6 +1663,12 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientReceive", + "topic": "openWB/counter/+/config/is_home_consumption_counter", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/counter/+/config/max_total_power", @@ -2967,4 +2978,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/packages/conftest.py b/packages/conftest.py index d41c6d822e..9b3cdc69ae 100644 --- a/packages/conftest.py +++ b/packages/conftest.py @@ -8,7 +8,7 @@ from control.bat import Set as BatSet from control.chargepoint.chargepoint import Chargepoint, ChargepointData from control.chargepoint.chargepoint_data import Config, Get, Set -from control.counter import Counter, CounterData +from control.counter import Counter, CounterData, CounterMode from control.counter import Config as CounterConfig from control.counter import Get as CounterGet from control.counter import Set as CounterSet @@ -189,11 +189,13 @@ def data_() -> None: fault_state=0), config=Mock(spec=PvConfig, max_ac_out=10000)))}) data.data.counter_data.update({ "counter0": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( - spec=CounterGet, currents=[40]*3, power=6200, daily_imported=45000, daily_exported=3000, fault_state=0))), + spec=CounterGet, currents=[40]*3, power=6200, daily_imported=45000, daily_exported=3000, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.HOME_CONSUMPTION.value))), "counter6": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( spec=CounterGet, currents=[25, 10, 25], power=13800, daily_imported=20000, daily_exported=0, imported=14000, exported=18000, fault_state=0), - config=Mock(spec=CounterConfig, max_currents=[32]*3), + config=Mock(spec=CounterConfig, max_currents=[32]*3, + is_home_consumption_counter=CounterMode.NOT_HOME_CONSUMPTION.value), set=Mock(spec=CounterSet, raw_currents_left=[31]*3)))}) diff --git a/packages/control/counter.py b/packages/control/counter.py index d1bdd8f488..0d1ff56545 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -29,6 +29,7 @@ def get_counter_default_config(): return {"max_power_errorcase": 7000, "max_currents": [35]*3, "max_total_power": 24000, + "is_home_consumption_counter": CounterMode.AUTO_HOME_CONSUMPTION.value, } @@ -38,12 +39,21 @@ class ControlRangeState(Enum): ABOVE = 1 +class CounterMode(Enum): + NOT_HOME_CONSUMPTION = "no_home_consumption" + HOME_CONSUMPTION = "home_consumption" + AUTO_HOME_CONSUMPTION = "auto_home_consumption" + + @dataclass class Config: - max_power_errorcase: float = field(default=7000, metadata={"topic": "get/max_power_errorcase"}) + max_power_errorcase: float = field(default=7000, metadata={"topic": "config/max_power_errorcase"}) max_currents: List[float] = field(default_factory=currents_list_factory, metadata={ - "topic": "get/max_currents"}) - max_total_power: float = field(default=0, metadata={"topic": "get/max_total_power"}) + "topic": "config/max_currents"}) + max_total_power: float = field(default=0, metadata={"topic": "config/max_total_power"}) + + is_home_consumption_counter: str = field(default=CounterMode.AUTO_HOME_CONSUMPTION.value, metadata={ + "topic": "config/is_home_consumption_counter"}) def config_factory() -> Config: diff --git a/packages/control/counter_all/counter_all.py b/packages/control/counter_all/counter_all.py index 82faab0667..88d04e968d 100644 --- a/packages/control/counter_all/counter_all.py +++ b/packages/control/counter_all/counter_all.py @@ -2,14 +2,13 @@ """ import copy import logging -from typing import List, Tuple +from typing import Any, Dict, List, Tuple from control import data -from control.counter import Counter from control.counter_all.counter_all_data import CounterAllData from control.counter_all.hierarchy import HierarchyMixin from control.counter_all.loadmanagement_prio import LoadmanagementPrioMixin -from helpermodules.pub import Pub +from control.counter import Counter, CounterMode from modules.common.component_type import ComponentType from modules.common.fault_state import FaultStateLevel from modules.common.simcount import SimCounter @@ -22,6 +21,10 @@ class CounterAll(HierarchyMixin, LoadmanagementPrioMixin): def __init__(self): self.data = CounterAllData() + # Hilfsvariablen für die rekursiven Funktionen + self.connected_counters = [] + self.connected_chargepoints = [] + self.childless = [] self.sim_counter = SimCounter("", "", ComponentType.COUNTER.value) self.sim_counter.topic = "openWB/set/counter/set/" @@ -45,15 +48,11 @@ def get_id_evu_counter(self) -> int: def set_home_consumption(self) -> None: try: - self._validate_home_consumption_counter() home_consumption, elements = self._calc_home_consumption() if home_consumption < 0: log.error( f"Ungültiger Hausverbrauch: {home_consumption}W, Berücksichtigte Komponenten neben EVU {elements}") - if self.data.config.home_consumption_source_id is None: - hc_counter_source = self.get_evu_counter_str() - else: - hc_counter_source = f"counter{self.data.config.home_consumption_source_id}" + 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: hc_counter_data.get.fault_state = FaultStateLevel.WARNING.value @@ -74,49 +73,91 @@ def set_home_consumption(self) -> None: except Exception: log.exception("Fehler in der allgemeinen Zähler-Klasse") - EVU_IS_HC_COUNTER_ERROR = ("Der EVU-Zähler kann nicht als Quelle für den Hausverbrauch verwendet werden. Meist ist " - "der Zähler am EVU-Punkt installiert, dann muss im Lastmanagement unter Hausverbrauch" - " 'von openWB berechnen' ausgewählt werden. Wenn der Zähler im Hausverbrauchszweig " - "installiert ist, einen virtuellen Zähler anlegen und im Lastmanagement ganz links " - "anordnen.") - - def _validate_home_consumption_counter(self): - if self.data.config.home_consumption_source_id is not None: - if self.data.config.home_consumption_source_id == self.get_id_evu_counter(): - hc_counter_data = data.data.counter_data[self.get_evu_counter_str()].data - hc_counter_data.get.fault_state = FaultStateLevel.ERROR.value - hc_counter_data.get.fault_str = self.EVU_IS_HC_COUNTER_ERROR - evu_counter = self.get_id_evu_counter() - Pub().pub(f"openWB/set/counter/{evu_counter}/get/fault_state", - hc_counter_data.get.fault_state) - Pub().pub(f"openWB/set/counter/{evu_counter}/get/fault_str", - hc_counter_data.get.fault_str) - raise Exception(self.EVU_IS_HC_COUNTER_ERROR) - - def _calc_home_consumption(self) -> Tuple[float, List]: - power = 0 - if self.data.config.home_consumption_source_id is None: - id_source = self.get_id_evu_counter() + def _get_component(self, element: Dict) -> Any: + if element["type"] == ComponentType.COUNTER.value: + return data.data.counter_data[f"counter{element['id']}"] + elif element["type"] == ComponentType.CHARGEPOINT.value: + return data.data.cp_data[f"cp{element['id']}"] + elif element["type"] == ComponentType.BAT.value: + return data.data.bat_data[f"bat{element['id']}"] + elif element["type"] == ComponentType.INVERTER.value: + return data.data.pv_data[f"pv{element['id']}"] else: - id_source = self.data.config.home_consumption_source_id - elements_to_sum_up = self.get_elements_for_downstream_calculation(id_source) - for element in elements_to_sum_up: - if element["type"] == ComponentType.CHARGEPOINT.value: - component = data.data.cp_data[f"cp{element['id']}"] - elif element["type"] == ComponentType.BAT.value: - component = data.data.bat_data[f"bat{element['id']}"] - elif element["type"] == ComponentType.COUNTER.value: - component = data.data.counter_data[f"counter{element['id']}"] - elif element["type"] == ComponentType.INVERTER.value: - component = data.data.pv_data[f"pv{element['id']}"] - - if component.data.get.fault_state < 2: - power += component.data.get.power + raise ValueError(f"Unbekannter Komponententyp: {element['type']}") + + def _get_is_home_consumption(self, counter: Counter, parent_home_consumption: str) -> str: + # Wenn auto ausgeählt ist, wird die einstellung vom Parent übernommen + # Wenn nicht, wird die Einstellung vom Zähler selbst genommen + if counter.data.config.is_home_consumption_counter == CounterMode.AUTO_HOME_CONSUMPTION.value: + return parent_home_consumption + + return counter.data.config.is_home_consumption_counter + + def _get_local_power_from_counter(self, element: Dict) -> float: + # Wird nur von Countern aufgerufen + # Gib den lokalen Verbrauch des Zählers zurück + # Bewertet noch nicht, ob Hausverbrauch oder nicht + local_power = data.data.counter_data[f"counter{element['id']}"].data.get.power + + for child in element["children"]: + comp = self._get_component(child) + + if comp.data.get.fault_state < 2: + local_power -= comp.data.get.power + else: + log.warning( + f"Komponente {element['type']}{comp.num} ist im Fehlerzustand und wird nicht berücksichtigt.") + + return local_power + + def _calc_home_consumption_from_counter( + self, element: Dict, parent_home_consumption: str) -> float: + # Wird nur von Countern aufgerufen + # Bewertet, ob Hausverbrauch oder nicht + # Gibt den Hausverbrauch des Zählers zurück + + home_consumption = 0.0 + local_power = self._get_local_power_from_counter(element) + + counter = self._get_component(element) + child_home_consumption = self._get_is_home_consumption(counter, parent_home_consumption) + + if child_home_consumption == CounterMode.HOME_CONSUMPTION.value: + home_consumption += local_power + + for child in element["children"]: + comp = self._get_component(child) + + if comp.data.get.fault_state < 2: + if child["type"] == ComponentType.COUNTER.value: + home_consumption += self._calc_home_consumption_from_counter(child, child_home_consumption) else: log.warning( - f"Komponente {element['type']}{component.num} ist im Fehlerzustand und wird nicht berücksichtigt.") - evu = data.data.counter_data[f"counter{id_source}"].data.get.power - return evu - power - self.data.set.smarthome_power_excluded_from_home_consumption, elements_to_sum_up + f"Komponente {element['type']}{comp.num} ist im Fehlerzustand und wird nicht berücksichtigt.") + + return home_consumption + + def _calc_home_consumption(self) -> Tuple[float, Dict]: + evu_id = self.get_id_evu_counter() + + # get_elements_for_downstream_calculation berücksichtigt Hybrid-Batterien + # wo die Bat im Wechselrichter ist (als child) und nicht direkt unter einem Zähler hängt + # + # get_elements_for_downstream_calculation liefert nur die Elemente unterhalb des EVU. + # Für die Rekursion bauen wir daher ein virtuelles Root-Element für den EVU-Zähler, + # ohne die echte Hierarchie zu verändern. + + elements = self.get_elements_for_downstream_calculation(evu_id) + evu_element = {"id": evu_id, "type": ComponentType.COUNTER.value, "children": elements} + + home_consumption = 0.0 + + # Rekursion startet immer beim EVU-Zähler. + home_consumption = self._calc_home_consumption_from_counter(evu_element, CounterMode.NOT_HOME_CONSUMPTION.value) + + home_consumption -= self.data.set.smarthome_power_excluded_from_home_consumption + + return home_consumption, evu_element def _add_hybrid_bat(self, id: int) -> List: elements = [] @@ -136,6 +177,32 @@ def get_elements_for_downstream_calculation(self, id: int): elements_to_sum_up.extend(self._add_hybrid_bat(element['id'])) return elements_to_sum_up + def _is_home_consumption_counter_by_id(self, counter_id: int) -> str: + counter_entry = self.get_entry_of_element(counter_id) + if not counter_entry: + raise IndexError(f"Element {counter_id} konnte nicht in der Hierarchie gefunden werden.") + if counter_entry["type"] != ComponentType.COUNTER.value: + raise ValueError(f"Element {counter_id} ist kein Zähler.") + + counter_obj = data.data.counter_data[f"counter{counter_id}"] + + # Explizite Einstellung hat Vorrang, nur Auto wird vom Parent geerbt. + if not counter_obj.data.config.is_home_consumption_counter == CounterMode.AUTO_HOME_CONSUMPTION.value: + return counter_obj.data.config.is_home_consumption_counter + + parent = self.get_entry_of_parent(counter_id) + if not parent or parent["type"] != ComponentType.COUNTER.value: + # Auto am Wurzel-Zähler entspricht dem bisherigen Startwert CounterMode.NOT_HOME_CONSUMPTION. + return CounterMode.NOT_HOME_CONSUMPTION.value + + return self._is_home_consumption_counter_by_id(parent["id"]) + + def is_home_consumption_counter(self, counter_id: int) -> bool: + """Ermittelt den effektiven Home-Consumption-Status eines Zählers. + Berücksichtigt den Auto-Parameter entlang aller übergeordneten Zähler. + """ + return self._is_home_consumption_counter_by_id(counter_id) == CounterMode.HOME_CONSUMPTION.value + def get_max_id_in_hierarchy(current_entry: List, max_id: int) -> int: for item in current_entry: diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py index 5b24eb7572..3806edb738 100644 --- a/packages/control/counter_all/counter_all_data.py +++ b/packages/control/counter_all/counter_all_data.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Callable, Dict, Generator, List, Optional, Protocol, Tuple, Union +from typing import Callable, Dict, Generator, List, Protocol, Tuple, Union from control.chargepoint.chargepoint import Chargepoint from dataclass_utils.factories import empty_list_factory @@ -8,8 +8,6 @@ @dataclass class Config: - home_consumption_source_id: Optional[str] = field( - default=None, metadata={"topic": "config/home_consumption_source_id"}) consider_less_charging: bool = field( default=False, metadata={"topic": "config/consider_less_charging"}) diff --git a/packages/control/counter_all/counter_home_consumption_test.py b/packages/control/counter_all/counter_home_consumption_test.py index 034645a50d..cdc7b8ef50 100644 --- a/packages/control/counter_all/counter_home_consumption_test.py +++ b/packages/control/counter_all/counter_home_consumption_test.py @@ -3,26 +3,67 @@ import pytest from control import data -from packages.conftest import hierarchy_hc_counter, hierarchy_standard, hierarchy_hybrid, hierarchy_nested from control.counter_all.counter_all import CounterAll +from control.bat import Bat, BatData +from control.bat import Get as BatGet +from control.bat import Set as BatSet +from control.chargepoint.chargepoint import Chargepoint, ChargepointData +from control.chargepoint.chargepoint_data import Config, Get, Set +from control.counter import Counter, CounterData, CounterMode +from control.counter import Config as CounterConfig +from control.counter import Get as CounterGet +from control.pv import Pv, PvData +from control.pv import Config as PvConfig +from control.pv import Get as PvGet + +from modules.chargepoints.mqtt.chargepoint_module import ChargepointModule +from modules.common.component_state import ChargepointState +from modules.common.store._api import LoggingValueStore + +from packages.conftest import ( + hierarchy_standard, + hierarchy_hybrid, + hierarchy_nested) from modules.common.fault_state import FaultStateLevel @pytest.mark.parametrize("counter_all", [pytest.param(hierarchy_standard, id="standard"), pytest.param(hierarchy_hybrid, id="hybrid"), - pytest.param(hierarchy_nested, id="nested")]) + pytest.param(hierarchy_nested, id="nested") + ]) def test_calc_home_consumption(counter_all: Callable[[], CounterAll], data_): c = counter_all() home_consumption = c._calc_home_consumption()[0] assert home_consumption == 500 -def test_calc_home_consumption_hc_counter(data_hc_counter_): - c = hierarchy_hc_counter() - c.data.config.home_consumption_source_id = 6 +@pytest.mark.parametrize( + ["counter_all", "expected_home_consumption"], + [ + pytest.param("hierarchy_home_consumption_standard", 0, id="hierarchy_home_consumption_standard"), + pytest.param("hierarchy_home_consumption_hybrid", 500, id="hierarchy_home_consumption_hybrid"), + pytest.param("hierarchy_nested_home_consumption_level_3", 500, id="hierarchy_nested_home_consumption_level_3"), + pytest.param("hierarchy_nested_home_consumption_level_2", + 500, id="hierarchy_nested_home_consumption_level_2"), + pytest.param("hierarchy_home_consumption_only_root", + 1000, id="hierarchy_home_consumption_only_root"), + pytest.param("hierarchy_home_consumption_all", + 1000, id="hierarchy_home_consumption_all"), + pytest.param("hierarchy_nested_home_consumption_multi_level_2", 250, + id="hierarchy_nested_home_consumption_multi_level_2"), + pytest.param("hierarchy_nested_home_consumption_2_hc_childs", 500, + id="hierarchy_nested_home_consumption_2_hc_childs") + ], +) +def test_calc_home_consumption_with_configured_home_consumption_counter( + counter_all: str, + expected_home_consumption: int, + data_home_consumption, +): + c = globals()[counter_all]() home_consumption = c._calc_home_consumption()[0] - assert home_consumption == 1100 + assert home_consumption == expected_home_consumption @pytest.mark.parametrize(["home_consumption", @@ -31,7 +72,7 @@ def test_calc_home_consumption_hc_counter(data_hc_counter_): "expected_invalid_home_consumption"], [pytest.param(500, 0, 500, 0, id="valid home consumption"), pytest.param(-100, 0, 200, 1, id="first invalid home consumption"), - pytest.param(-100, 3, 0, 3, id="invalid home consumption, reset home consumption")]) + pytest.param(-100, 3, 0, 3, id="invalid home consumption, reset home consumption")]) def test_set_home_consumption(home_consumption: int, invalid_home_consumption: int, expected_home_consumption: int, @@ -54,13 +95,374 @@ def test_set_home_consumption(home_consumption: int, assert c.data.set.home_consumption == expected_home_consumption -def test_validate_home_consumption_counter(monkeypatch): +def hierarchy_home_consumption_standard() -> CounterAll: + # counter0 + # | + # - cp4 + # - cp5 + # - cp3 + # - inverter1 + # - bat2 + # counter_8 <-- home consumption counter + # + + # UnbekannterVerbraucher/Hausverbrauch am Countern + # counter0 = 750 + # counter8 = 500 + # Final Home Consumption = 0 + c = CounterAll() + c.data.get.hierarchy = [{"id": 0, "type": "counter", + "children": [ + {"id": 4, "type": "cp", "children": []}, + {"id": 5, "type": "cp", "children": []}, + {"id": 3, "type": "cp", "children": []}, + {"id": 1, "type": "inverter", "children": []}, + {"id": 2, "type": "bat", "children": []}]}, + {"id": 8, "type": "counter", + "children": []}] + return c + + +def hierarchy_home_consumption_hybrid() -> CounterAll: + # counter0 + # | + # - cp3 + # - cp4 + # - counter8 <-- home consumption counter + # | + # - cp5 + # - inverter1 + # - bat2 + + # UnbekannterVerbraucher/Hausverbrauch am Countern + # counter0 = 250 + # counter8 = 500 + # Final Home Consumption = 500 + c = CounterAll() + c.data.get.hierarchy = [{"id": 0, "type": "counter", + "children": [ + {"id": 3, "type": "cp", "children": []}, + {"id": 4, "type": "cp", "children": []}, + {"id": 8, "type": "counter", + "children": [ + {"id": 5, "type": "cp", "children": []}]}, + {"id": 1, "type": "inverter", "children": []}, + {"id": 2, "type": "bat", "children": []}]}] + return c + + +def hierarchy_nested_home_consumption_level_3() -> CounterAll: + # counter0 + # | + # - cp3 + # - counter6 + # | + # - cp4 + # - counter8 <-- home consumption counter + # | + # - cp5 + # - inverter1 + # - bat2 + + # UnbekannterVerbraucher/Hausverbrauch am Countern + # counter0 = 250 + # counter6 = 0 + # counter8 = 500 + # Final Home Consumption = 500 + c = CounterAll() + c.data.get.hierarchy = [{"id": 0, "type": "counter", + "children": [ + {"id": 3, "type": "cp", "children": []}, + {"id": 6, "type": "counter", + "children": [ + {"id": 4, "type": "cp", "children": []}, + {"id": 8, "type": "counter", + "children": [ + {"id": 5, "type": "cp", "children": []}]}, + {"id": 1, "type": "inverter", "children": []} + ]}, + {"id": 2, "type": "bat", "children": []}]}] + return c + + +def hierarchy_nested_home_consumption_level_2() -> CounterAll: + # counter0 + # | + # - cp3 + # - counter9 <-- home consumption counter + # | + # - cp4 + # - counter10 <-- home consumption counter + # | + # - cp5 + # + # - inverter1 + # - bat2 + + # UnbekannterVerbraucher/Hausverbrauch am Countern + # counter0 = 250 + # coutner9 = 250 + # counter10 = 250 + # Final Home Consumption = 500 + c = CounterAll() + c.data.get.hierarchy = [{"id": 0, "type": "counter", + "children": [ + {"id": 3, "type": "cp", "children": []}, + {"id": 9, "type": "counter", + "children": [ + {"id": 4, "type": "cp", "children": []}, + {"id": 10, "type": "counter", + "children": [ + {"id": 5, "type": "cp", "children": []}]}, + {"id": 1, "type": "inverter", "children": []}, + ]}, + {"id": 2, "type": "bat", "children": []}]}] + return c + + +def hierarchy_home_consumption_only_root() -> CounterAll: + # counter11 <-- home consumption counter + # | + # - cp3 + # - counter16 + # | + # - cp4 + # - counter17 + # | + # - cp5 + # + # - inverter1 + # - bat2 + + # UnbekannterVerbraucher/Hausverbrauch am Countern + # counter11 = 500 + # coutner16 = 250 + # counter17 = 250 + # Final Home Consumption = 1000 c = CounterAll() - c.data.config.home_consumption_source_id = 0 - monkeypatch.setattr(c, "get_id_evu_counter", lambda: 0) - monkeypatch.setattr(c, "get_evu_counter_str", lambda: "counter0") + c.data.get.hierarchy = [{"id": 11, "type": "counter", + "children": [ + {"id": 3, "type": "cp", "children": []}, + {"id": 16, "type": "counter", + "children": [ + {"id": 4, "type": "cp", "children": []}, + {"id": 17, "type": "counter", + "children": [ + {"id": 5, "type": "cp", "children": []}]}, + {"id": 1, "type": "inverter", "children": []}, + ]}, + {"id": 2, "type": "bat", "children": []}]}] + return c + + +def hierarchy_home_consumption_all() -> CounterAll: + # counter11 <-- home consumption counter + # | + # - cp3 + # - counter9 <-- home consumption counter + # | + # - cp4 + # - counter10 <-- home consumption counter + # | + # - cp5 + # + # - inverter1 + # - bat2 + + # UnbekannterVerbraucher/Hausverbrauch am Countern + # counter11 = 500 + # coutner9 = 250 + # counter10 = 250 + # Final Home Consumption = 1000 + c = CounterAll() + c.data.get.hierarchy = [{"id": 11, "type": "counter", + "children": [ + {"id": 3, "type": "cp", "children": []}, + {"id": 9, "type": "counter", + "children": [ + {"id": 4, "type": "cp", "children": []}, + {"id": 10, "type": "counter", + "children": [ + {"id": 5, "type": "cp", "children": []}]}, + {"id": 1, "type": "inverter", "children": []}, + ]}, + {"id": 2, "type": "bat", "children": []}]}] + return c + + +def hierarchy_nested_home_consumption_multi_level_2() -> CounterAll: + # counter0 + # | + # - cp3 + # - counter6 + # | + # - cp4 + # - counter10 <-- home consumption counter + # | + # - cp5 + # - counter14 + # | + # - inverter1 + # - bat2 + + # UnbekannterVerbraucher/Hausverbrauch am Countern + # counter0 = 250 + # coutner6 = 0 + # counter10 = 250 + # counter14 = 250 + # Final Home Consumption = 250 + c = CounterAll() + c.data.get.hierarchy = [{"id": 0, "type": "counter", + "children": [ + {"id": 3, "type": "cp", "children": []}, + {"id": 6, "type": "counter", + "children": [ + {"id": 4, "type": "cp", "children": []}, + {"id": 10, "type": "counter", + "children": [ + {"id": 5, "type": "cp", "children": []}]}, + {"id": 14, "type": "counter", + "children": [ + {"id": 1, "type": "inverter", "children": []}]}, + ]}, + {"id": 2, "type": "bat", "children": []}]}] + return c + + +def hierarchy_nested_home_consumption_2_hc_childs() -> CounterAll: + # counter0 + # | + # - cp3 + # - counter6 + # | + # - cp4 + # - counter10 <-- home consumption counter + # | + # - cp5 + # - counter15 <-- home consumption counter + # | + # - inverter1 + # - bat2 + + # UnbekannterVerbraucher/Hausverbrauch am Countern + # counter0 = 250 + # coutner6 = 0 + # counter10 = 250 + # counter15 = 250 + # Final Home Consumption = 500 + c = CounterAll() + c.data.get.hierarchy = [{"id": 0, "type": "counter", + "children": [ + {"id": 3, "type": "cp", "children": []}, + {"id": 6, "type": "counter", + "children": [ + {"id": 4, "type": "cp", "children": []}, + {"id": 10, "type": "counter", + "children": [ + {"id": 5, "type": "cp", "children": []}]}, + {"id": 15, "type": "counter", + "children": [ + {"id": 1, "type": "inverter", "children": []}]}, + ]}, + {"id": 2, "type": "bat", "children": []}]}] + return c + + +@pytest.fixture() +def data_home_consumption() -> None: + data.data_init(Mock()) + data.data.cp_data = { + "cp3": Mock(spec=Chargepoint, data=Mock(spec=ChargepointData, + config=Mock(spec=Config, phase_1=1), + get=Mock(spec=Get, currents=[30, 0, 0], power=6900, + daily_imported=10000, daily_exported=0, imported=56000, + fault_state=0), + set=Mock(spec=Set, loadmanagement_available=True)), + chargepoint_module=Mock(spec=ChargepointModule, + store=Mock(spec=LoggingValueStore, + delegate=Mock(spec=LoggingValueStore, + state=ChargepointState(currents=[30, 0, 0], + power=6900, + plug_state=False, + charge_state=False, + imported=None, + exported=None, + phases_in_use=0))))), + "cp4": Mock(spec=Chargepoint, data=Mock(spec=ChargepointData, + config=Mock(spec=Config, phase_1=2), + get=Mock(spec=Get, currents=[0, 15, 15], power=6900, + daily_imported=10000, daily_exported=0, imported=60000, + fault_state=0), + set=Mock(spec=Set, loadmanagement_available=True)), + chargepoint_module=Mock(spec=ChargepointModule, + store=Mock(spec=LoggingValueStore, + delegate=Mock(spec=LoggingValueStore, + state=ChargepointState(currents=[0, 15, 15], + power=6900, + plug_state=False, + charge_state=False, + imported=None, + exported=None, + phases_in_use=0))))), + "cp5": Mock(spec=Chargepoint, data=Mock(spec=ChargepointData, + config=Mock(spec=Config, phase_1=3), + get=Mock(spec=Get, currents=[10]*3, power=6900, + daily_imported=10000, daily_exported=0, imported=62000, + fault_state=0), + set=Mock(spec=Set, loadmanagement_available=True)), + chargepoint_module=Mock(spec=ChargepointModule, + store=Mock(spec=LoggingValueStore, + delegate=Mock(spec=LoggingValueStore, + state=ChargepointState(currents=[10]*3, + power=6900, + plug_state=False, + charge_state=False, + imported=None, + exported=None, + phases_in_use=0)))))} + data.data.bat_data.update({"bat2": Mock(spec=Bat, num=2, data=Mock(spec=BatData, get=Mock( + spec=BatGet, power=-5000, fault_state=0), + set=Mock(spec=BatSet, power_limit=None)))}) + data.data.pv_data.update({"pv1": Mock(spec=Pv, data=Mock( + spec=PvData, get=Mock(spec=PvGet, power=-10000, fault_state=0), config=Mock(spec=PvConfig, max_ac_out=10000)))}) + data.data.counter_data.update({ + "counter0": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=6450, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.NOT_HOME_CONSUMPTION.value))), + "counter6": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=4300, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.NOT_HOME_CONSUMPTION.value))), + "counter7": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=20700, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.NOT_HOME_CONSUMPTION.value))), + "counter13": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=7150, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.NOT_HOME_CONSUMPTION.value))), + "counter14": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=-9750, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.NOT_HOME_CONSUMPTION.value))), + + "counter11": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=6700, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.HOME_CONSUMPTION.value))), - with pytest.raises(Exception) as e: - c._validate_home_consumption_counter() + "counter8": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=7400, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.HOME_CONSUMPTION.value))), + "counter9": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=4300, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.HOME_CONSUMPTION.value))), + "counter10": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=7150, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.HOME_CONSUMPTION.value))), + "counter15": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=-9750, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.HOME_CONSUMPTION.value))), - assert str(e.value) == CounterAll.EVU_IS_HC_COUNTER_ERROR + "counter16": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=4300, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.AUTO_HOME_CONSUMPTION.value))), + "counter17": Mock(spec=Counter, data=Mock(spec=CounterData, get=Mock( + spec=CounterGet, power=7150, fault_state=0), + config=Mock(spec=CounterConfig, is_home_consumption_counter=CounterMode.AUTO_HOME_CONSUMPTION.value))), + }) diff --git a/packages/helpermodules/create_debug.py b/packages/helpermodules/create_debug.py index 1e7e72183d..88e1b95450 100644 --- a/packages/helpermodules/create_debug.py +++ b/packages/helpermodules/create_debug.py @@ -178,7 +178,7 @@ def config_and_state(): f"{component_data.data.config.max_currents} A\n" "--| Counter_Max_Power_Errorcase: " f"{component_data.data.config.max_power_errorcase} W\n") - elif counter_all_data.data.config.home_consumption_source_id == component_data.num: + elif counter_all_data.is_home_consumption_counter(component_data.num): parsed_data += ("--| Counter_Type: Hausverbrauchszähler\n" "--| Counter_Max_Power: " f"{component_data.data.config.max_total_power} W\n" @@ -252,8 +252,7 @@ def get_hierarchy(hierarchy, level=0): counter_all_data = data.data.counter_all_data if counter_all_data.get_evu_counter_str() == f"counter{component_data.num}": counter_type = ("EVU-Zähler") - elif (counter_all_data.data.config.home_consumption_source_id == - component_data.num): + elif counter_all_data.is_home_consumption_counter(component_data.num): counter_type = ("Hausverbrauchszähler") else: counter_type = "Sonstiger Zähler" diff --git a/packages/helpermodules/measurement_logging/write_log.py b/packages/helpermodules/measurement_logging/write_log.py index c9a2cb3313..5c31c94d0c 100644 --- a/packages/helpermodules/measurement_logging/write_log.py +++ b/packages/helpermodules/measurement_logging/write_log.py @@ -257,10 +257,23 @@ def create_entry(log_type: LogType, sh_log_data: LegacySmartHomeLogData, previou log.exception("Fehler im Werte-Logging-Modul für EV "+str(ev)) counter_dict = {} + counter_all_data = data.data.counter_all_data + # Zählt alle effektiven Hausverbrauchs-Zähler, auch bei Auto-Vererbung über den Parent. + is_home_consumption_by_counter = {} + for current_counter in data.data.counter_data.values(): + try: + is_home_consumption_by_counter[current_counter.num] = counter_all_data.is_home_consumption_counter( + current_counter.num) + except Exception: + log.exception("Fehler beim Ermitteln der Hausverbrauchszähler.") + is_home_consumption_by_counter[current_counter.num] = False + + home_consumption_counter_count = sum(1 for is_hc in is_home_consumption_by_counter.values() if is_hc) + for counter in data.data.counter_data.values(): try: - home_consumption_source_id = data.data.counter_all_data.data.config.home_consumption_source_id - if home_consumption_source_id is not None and counter.num == home_consumption_source_id: + is_home_consumption_counter = is_home_consumption_by_counter.get(counter.num, False) + if is_home_consumption_counter and home_consumption_counter_count == 1: continue skip_counter = False for consumer in data.data.consumer_data: @@ -272,7 +285,7 @@ def create_entry(log_type: LogType, sh_log_data: LegacySmartHomeLogData, previou {f"counter{counter.num}": { "imported": counter.data.get.imported, "exported": counter.data.get.exported, - "grid": True if data.data.counter_all_data.get_id_evu_counter() == counter.num else False, + "grid": True if counter_all_data.get_id_evu_counter() == counter.num else False, "fault_state": counter.data.get.fault_state}}) except Exception: log.exception("Fehler im Werte-Logging-Modul für Zähler "+str(counter)) diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index b1119b3ef8..bb7b779858 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -958,8 +958,6 @@ def process_counter_topic(self, msg: mqtt.MQTTMessage): elif ("openWB/set/counter/get/hierarchy" in msg.topic or "openWB/set/counter/get/loadmanagement_prios" in msg.topic): self._validate_value(msg, None) - elif "openWB/set/counter/config/home_consumption_source_id" in msg.topic: - self._validate_value(msg, int) elif "openWB/set/counter/set/simulation" in msg.topic: self._validate_value(msg, "json") elif "/set/consumption_left" in msg.topic: @@ -971,6 +969,8 @@ def process_counter_topic(self, msg: mqtt.MQTTMessage): elif ("/config/max_total_power" in msg.topic or "/config/max_power_errorcase" in msg.topic): self._validate_value(msg, int, [(0, float("inf"))]) + elif ("/config/is_home_consumption_counter" in msg.topic): + self._validate_value(msg, str) elif subdata.SubData.counter_data.get(f"counter{get_index(msg.topic)}"): if ("/get/powers" in msg.topic or "/get/currents" in msg.topic): diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index a46bdd05a3..4ee2e58581 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -36,7 +36,7 @@ from control.counter_all import counter_all, counter_all_data from control.bat_all import BatConsiderationMode from control.chargepoint.charging_type import ChargingType -from control.counter import get_counter_default_config +from control.counter import get_counter_default_config, CounterMode from control.ev.charge_template import EcoCharging, get_charge_template_default from control.ev import ev from control.ev.ev_template import EvTemplateData @@ -58,7 +58,7 @@ class UpdateConfig: - DATASTORE_VERSION = 144 + DATASTORE_VERSION = 145 valid_topic = [ "^openWB/bat/config/bat_control_activated$", @@ -213,7 +213,6 @@ class UpdateConfig: "^openWB/consumer/[0-9]+/set/wait_for_start_state$", "^openWB/counter/config/consider_less_charging$", - "^openWB/counter/config/home_consumption_source_id$", "^openWB/counter/get/hierarchy$", "^openWB/counter/get/loadmanagement_prios$", "^openWB/counter/set/disengageable_smarthome_power$", @@ -241,6 +240,7 @@ class UpdateConfig: "^openWB/counter/[0-9]+/config/max_power_errorcase$", "^openWB/counter/[0-9]+/config/max_currents$", "^openWB/counter/[0-9]+/config/max_total_power$", + "^openWB/counter/[0-9]+/config/is_home_consumption_counter$", "^openWB/general/allow_unencrypted_access$", "^openWB/general/extern$", @@ -631,7 +631,6 @@ class UpdateConfig: ("openWB/counter/get/hierarchy", []), ("openWB/counter/get/loadmanagement_prios", [{"type": "vehicle", "id": 0}]), ("openWB/counter/config/consider_less_charging", counter_all_data.Config().consider_less_charging), - ("openWB/counter/config/home_consumption_source_id", counter_all_data.Config().home_consumption_source_id), ("openWB/vehicle/0/name", "Standard-Fahrzeug"), ("openWB/vehicle/0/color", DEFAULT_COLORS.VEHICLE.value), ("openWB/vehicle/0/info", {"manufacturer": None, "model": None}), @@ -3516,6 +3515,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: self._append_datastore_version(136) def upgrade_datastore_137(self) -> None: + # Update all counters with new Parameter and default wert def upgrade(topic: str, payload) -> Optional[dict]: if re.search("openWB/vehicle/template/ev_template/[0-9]+$", topic) is not None: payload = decode_payload(payload) @@ -3724,3 +3724,81 @@ def upgrade(topic: str, payload) -> Optional[dict]: return {topic: payload_device} self._loop_all_received_topics(upgrade) self._append_datastore_version(144) + + def upgrade_datastore_145(self) -> None: + def get_direct_child_counter_ids(hierarchy, parent_counter_id: int) -> List[int]: + def find_counter_entry(elements) -> Optional[dict]: + for element in elements: + if element.get("type") == "counter" and element.get("id") == parent_counter_id: + return element + found = find_counter_entry(element.get("children", [])) + if found is not None: + return found + return None + + parent_entry = find_counter_entry(hierarchy) + if parent_entry is None: + return [] + return [ + child["id"] + for child in parent_entry.get("children", []) + if child.get("type") == "counter" + ] + + def upgrade(topic: str, payload) -> Optional[dict]: + + if re.search("openWB/counter/[0-9]+/config", topic) is not None: + index = get_index(topic) + new_topics = {} + if f"openWB/counter/{index}/config/is_home_consumption_counter" not in self.all_received_topics: + new_topics[f"openWB/counter/{index}/config/is_home_consumption_counter"] = ( + get_counter_default_config()["is_home_consumption_counter"] + ) + return new_topics if new_topics else None + self._loop_all_received_topics(upgrade) + # Remove old Topic + old_topic = "openWB/counter/config/home_consumption_source_id" + if old_topic in self.all_received_topics: + source_id = decode_payload(self.all_received_topics[old_topic]) + hierarchy_topic = "openWB/counter/get/hierarchy" + hierarchy = decode_payload(self.all_received_topics.get(hierarchy_topic, [])) + + if source_id is None: + # Altes Default-Verhalten beibehalten: Bei None den EVU-Zähler (hierarchy[0]) verwenden. + if ( + isinstance(hierarchy, list) + and len(hierarchy) > 0 + and hierarchy[0].get("type") == "counter" + ): + source_id = hierarchy[0].get("id") + else: + log.warning( + "Migration der Hausverbrauchs-Zaehler (upgrade_datastore_138) fehlgeschlagen: " + f"'{old_topic}' ist None und '{hierarchy_topic}' enthaelt keinen gueltigen EVU-Zaehler." + ) + + if source_id is not None: + try: + source_id = int(source_id) + except (TypeError, ValueError): + log.warning(f"Invalid '{old_topic}' value: {source_id!r}; skipping migration") + else: + # Bisherigen Source-Counter explizit als Hausverbrauchs-Zähler setzen. + self.__update_topic( + f"openWB/counter/{source_id}/config/is_home_consumption_counter", + CounterMode.HOME_CONSUMPTION.value) + + # Direkte Kind-Zähler explizit deaktivieren, damit Auto-Vererbung hier endet. + if isinstance(hierarchy, list): + for child_counter_id in get_direct_child_counter_ids(hierarchy, source_id): + self.__update_topic( + f"openWB/counter/{child_counter_id}/config/is_home_consumption_counter", + CounterMode.NOT_HOME_CONSUMPTION.value) + + else: + log.warning( + "Migration der Hausverbrauchs-Zaehler (upgrade_datastore_138) fehlgeschlagen: " + f"ungueltige Hierarchie in '{hierarchy_topic}'. " + "Direkte Kind-Zaehler des bisherigen Hausverbrauchs-Zaehlers wurden nicht angepasst." + ) + self._append_datastore_version(145)