diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 10ed378e2f..965b348b9e 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -1498,61 +1498,61 @@ }, { "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 }, { "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 }, { "acltype": "publishClientSend", - "topic": "openWB/set/counter/+/config/max_total_power", + "topic": "openWB/set/counter/+/config/is_home_consumption_counter_auto", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/pv/+/config/max_ac_out", + "topic": "openWB/set/counter/+/config/max_total_power", "priority": 0, "allow": true }, { - "acltype": "publishClientReceive", - "topic": "openWB/system/security/access/LoadManagementConfiguration", + "acltype": "publishClientSend", + "topic": "openWB/set/pv/+/config/max_ac_out", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/bat/+/config/max_power", + "topic": "openWB/system/security/access/LoadManagementConfiguration", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/counter/config/home_consumption_source_id", + "topic": "openWB/bat/+/config/max_power", "priority": 0, "allow": true }, @@ -1586,6 +1586,18 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientReceive", + "topic": "openWB/counter/+/config/is_home_consumption_counter", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/counter/+/config/is_home_consumption_counter_auto", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/counter/+/config/max_total_power", diff --git a/packages/conftest.py b/packages/conftest.py index b58a69d591..15959bfbe7 100644 --- a/packages/conftest.py +++ b/packages/conftest.py @@ -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=True, is_home_consumption_counter_auto=False))), "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=False, is_home_consumption_counter_auto=False), set=Mock(spec=CounterSet, raw_currents_left=[31]*3)))}) diff --git a/packages/control/counter.py b/packages/control/counter.py index e7351b3cef..c16124e61e 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -27,6 +27,8 @@ def get_counter_default_config(): return {"max_power_errorcase": 7000, "max_currents": [35]*3, "max_total_power": 24000, + "is_home_consumption_counter": False, + "is_home_consumption_counter_auto": True } @@ -38,10 +40,13 @@ class ControlRangeState(Enum): @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: bool = field(default=False, metadata={"topic": "config/is_home_consumption_counter"}) + is_home_consumption_counter_auto: bool = field( + default=True, metadata={"topic": "config/is_home_consumption_counter_auto"}) def config_factory() -> Config: diff --git a/packages/control/counter_all.py b/packages/control/counter_all.py index 89f214b773..3c4677edf7 100644 --- a/packages/control/counter_all.py +++ b/packages/control/counter_all.py @@ -4,13 +4,12 @@ from dataclasses import dataclass, field import logging import re -from typing import Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Tuple, Union from control import data from control.counter import Counter from dataclass_utils.factories import empty_list_factory from helpermodules.messaging import MessageType, pub_system_message -from helpermodules.pub import Pub from modules.common.component_type import ComponentType, component_type_to_readable_text from modules.common.fault_state import FaultStateLevel from modules.common.simcount import SimCounter @@ -20,8 +19,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"}) @@ -101,15 +98,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 @@ -130,49 +123,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 _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: + raise ValueError(f"Unbekannter Komponententyp: {element['type']}") - 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_is_home_consumption(self, counter: Counter, parent_home_consumption: bool) -> bool: + # 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_auto: + return parent_home_consumption 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 + 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: bool) -> 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: + home_consumption += local_power + + for child in element["children"]: + comp = self._get_component(child) + + if comp.data.get.fault_state < 2: + if isinstance(comp, Counter): + 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, List]: + 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, False) + + 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 = [] @@ -481,6 +516,32 @@ def check_and_add(type_name: ComponentType, data_structure): "Lastmanagements gesetzt werden kann. Bitte zuerst einen EVU-Zähler hinzufügen."), MessageType.ERROR) + def _is_home_consumption_counter_by_id(self, counter_id: int) -> bool: + 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_auto: + 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 False. + return False + + 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) + def get_max_id_in_hierarchy(current_entry: List, max_id: int) -> int: for item in current_entry: diff --git a/packages/control/counter_home_consumption_test.py b/packages/control/counter_home_consumption_test.py index 6303dd63e9..3ad22809d6 100644 --- a/packages/control/counter_home_consumption_test.py +++ b/packages/control/counter_home_consumption_test.py @@ -3,26 +3,69 @@ import pytest from control import data -from packages.conftest import hierarchy_hc_counter, hierarchy_standard, hierarchy_hybrid, hierarchy_nested + + +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 +from control.counter import Config as CounterConfig +from control.counter import Get as CounterGet from control.counter_all import CounterAll +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 +74,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 +97,386 @@ 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.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.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": 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=False, + is_home_consumption_counter_auto=False))), + "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=False, + is_home_consumption_counter_auto=False))), + "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=False, + is_home_consumption_counter_auto=False))), + "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=False, + is_home_consumption_counter_auto=False))), + "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=False, + is_home_consumption_counter_auto=False))), + + "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=True, + is_home_consumption_counter_auto=False))), - 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=True, + is_home_consumption_counter_auto=False))), + "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=True, + is_home_consumption_counter_auto=False))), + "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=True, + is_home_consumption_counter_auto=False))), + "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=True, + is_home_consumption_counter_auto=False))), - 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=False, + is_home_consumption_counter_auto=True))), + "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=False, + is_home_consumption_counter_auto=True))), + }) diff --git a/packages/helpermodules/create_debug.py b/packages/helpermodules/create_debug.py index cb032927a5..40b447fc4e 100644 --- a/packages/helpermodules/create_debug.py +++ b/packages/helpermodules/create_debug.py @@ -35,8 +35,8 @@ def get_common_data(): ip_address = None try: updateAvailable = subdata.SubData.system_data["system"].data["current_branch_commit"] and \ - subdata.SubData.system_data["system"].data["current_branch_commit"] != \ - subdata.SubData.system_data["system"].data["current_commit"] + subdata.SubData.system_data["system"].data["current_branch_commit"] != \ + subdata.SubData.system_data["system"].data["current_commit"] except Exception: updateAvailable = False @@ -177,7 +177,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" @@ -251,8 +251,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" @@ -461,9 +460,9 @@ def write_to_file(file_handler, func, default: Optional[Any] = None): json_rsp = req.get_http_session().put("https://debughandler.wb-solution.de", data=data, params={ - 'debugemail': debug_email, - 'ticketnumber': ticketnumber, - 'subject': subject + 'debugemail': debug_email, + 'ticketnumber': ticketnumber, + 'subject': subject }, timeout=10).json() diff --git a/packages/helpermodules/measurement_logging/write_log.py b/packages/helpermodules/measurement_logging/write_log.py index fd8d51aa4b..7d5270428e 100644 --- a/packages/helpermodules/measurement_logging/write_log.py +++ b/packages/helpermodules/measurement_logging/write_log.py @@ -246,15 +246,29 @@ 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 None or counter.num != home_consumption_source_id): + is_home_consumption_counter = is_home_consumption_by_counter.get(counter.num, False) + # Nur bei genau einem HV-Zähler ausblenden, bei mehreren einzeln anzeigen. + if not is_home_consumption_counter or home_consumption_counter_count > 1: counter_dict.update( {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 7973936181..fbd8f89a4c 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -933,8 +933,6 @@ def process_counter_topic(self, msg: mqtt.MQTTMessage): self._validate_value(msg, float, [(0, float("inf"))]) elif "openWB/set/counter/get/hierarchy" 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: @@ -946,6 +944,9 @@ 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 + or "/config/is_home_consumption_counter_auto" in msg.topic): + self._validate_value(msg, bool) 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 48d2f72b1e..e1e69f95b3 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -58,7 +58,7 @@ class UpdateConfig: - DATASTORE_VERSION = 137 + DATASTORE_VERSION = 138 valid_topic = [ "^openWB/bat/config/bat_control_activated$", @@ -186,7 +186,6 @@ class UpdateConfig: "^openWB/command/todo$", "^openWB/counter/config/consider_less_charging$", - "^openWB/counter/config/home_consumption_source_id$", "^openWB/counter/get/hierarchy$", "^openWB/counter/set/disengageable_smarthome_power$", "^openWB/counter/set/imported_home_consumption$", @@ -213,6 +212,8 @@ 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/counter/[0-9]+/config/is_home_consumption_counter_auto$", "^openWB/general/allow_unencrypted_access$", "^openWB/general/extern$", @@ -588,7 +589,6 @@ class UpdateConfig: ("openWB/chargepoint/template/0", get_chargepoint_template_default()), ("openWB/counter/get/hierarchy", []), ("openWB/counter/config/consider_less_charging", counter_all.Config().consider_less_charging), - ("openWB/counter/config/home_consumption_source_id", counter_all.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}), @@ -3472,6 +3472,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) @@ -3481,3 +3482,68 @@ def upgrade(topic: str, payload) -> Optional[dict]: return {topic: payload} self._loop_all_received_topics(upgrade) self._append_datastore_version(137) + + def upgrade_datastore_138(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"] + ) + if f"openWB/counter/{index}/config/is_home_consumption_counter_auto" not in self.all_received_topics: + new_topics[f"openWB/counter/{index}/config/is_home_consumption_counter_auto"] = ( + get_counter_default_config()["is_home_consumption_counter_auto"] + ) + 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]) + 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", True) + self.__update_topic(f"openWB/counter/{source_id}/config/is_home_consumption_counter_auto", False) + + # Direkte Kind-Zähler explizit deaktivieren, damit Auto-Vererbung hier endet. + hierarchy_topic = "openWB/counter/get/hierarchy" + hierarchy = decode_payload(self.all_received_topics.get(hierarchy_topic, [])) + 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", False) + self.__update_topic( + f"openWB/counter/{child_counter_id}/config/is_home_consumption_counter_auto", False) + 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(138)