From 2d593a80be056660a43762220c0260589c17e2ac Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Thu, 9 Apr 2026 15:09:15 +0200 Subject: [PATCH 1/8] move files --- packages/conftest.py | 2 +- packages/control/algorithm/common_test.py | 2 +- packages/control/algorithm/filter_chargepoints_test.py | 2 +- packages/control/algorithm/integration_test/conftest.py | 2 +- packages/control/{ => counter_all}/counter_all.py | 5 +++-- .../{ => counter_all}/counter_home_consumption_test.py | 2 +- packages/control/{ => counter_all}/hierarchy_test.py | 2 +- packages/control/data.py | 2 +- packages/helpermodules/command.py | 3 ++- packages/helpermodules/subdata.py | 3 ++- packages/helpermodules/update_config.py | 2 +- packages/modules/common/store/_counter_test.py | 2 +- packages/modules/common/store/_inverter_test.py | 2 +- packages/modules/devices/generic/virtual/counter_test.py | 2 +- 14 files changed, 18 insertions(+), 15 deletions(-) rename packages/control/{ => counter_all}/counter_all.py (99%) rename packages/control/{ => counter_all}/counter_home_consumption_test.py (98%) rename packages/control/{ => counter_all}/hierarchy_test.py (99%) diff --git a/packages/conftest.py b/packages/conftest.py index b58a69d591..fb6cfc8d44 100644 --- a/packages/conftest.py +++ b/packages/conftest.py @@ -12,7 +12,7 @@ from control.counter import Config as CounterConfig from control.counter import Get as CounterGet from control.counter import Set as CounterSet -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.pv import Pv, PvData from control.pv import Config as PvConfig from control.pv import Get as PvGet diff --git a/packages/control/algorithm/common_test.py b/packages/control/algorithm/common_test.py index 707d88c0a2..df53dbb7c2 100644 --- a/packages/control/algorithm/common_test.py +++ b/packages/control/algorithm/common_test.py @@ -8,7 +8,7 @@ from control.chargepoint.chargepoint import Chargepoint from control.ev.ev import Ev from control.counter import Counter -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.io_device import IoActions diff --git a/packages/control/algorithm/filter_chargepoints_test.py b/packages/control/algorithm/filter_chargepoints_test.py index def2524811..66fbd7f277 100644 --- a/packages/control/algorithm/filter_chargepoints_test.py +++ b/packages/control/algorithm/filter_chargepoints_test.py @@ -10,7 +10,7 @@ from control.chargepoint.chargepoint import Chargepoint, ChargepointData from control.chargepoint.chargepoint_data import Log, Set from control.chargepoint.control_parameter import ControlParameter -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.ev.ev import Ev, EvData, Get diff --git a/packages/control/algorithm/integration_test/conftest.py b/packages/control/algorithm/integration_test/conftest.py index dd91361d37..8ea170292a 100644 --- a/packages/control/algorithm/integration_test/conftest.py +++ b/packages/control/algorithm/integration_test/conftest.py @@ -8,7 +8,7 @@ from control.bat_all import BatAll from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_template import CpTemplate -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.counter import Counter from control.ev.ev import Ev from control.io_device import IoActions diff --git a/packages/control/counter_all.py b/packages/control/counter_all/counter_all.py similarity index 99% rename from packages/control/counter_all.py rename to packages/control/counter_all/counter_all.py index 2eeb156d18..5da53e9219 100644 --- a/packages/control/counter_all.py +++ b/packages/control/counter_all/counter_all.py @@ -50,8 +50,9 @@ class Set: @dataclass class Get: - hierarchy: List = field(default_factory=empty_list_factory, metadata={ - "topic": "get/hierarchy"}) + hierarchy: List = field(default_factory=empty_list_factory, metadata={"topic": "get/hierarchy"}) + loadmanagement_prios: List[Dict] = field( + default_factory=empty_list_factory, metadata={"topic": "get/loadmanagement_prios"}) def get_factory() -> Get: diff --git a/packages/control/counter_home_consumption_test.py b/packages/control/counter_all/counter_home_consumption_test.py similarity index 98% rename from packages/control/counter_home_consumption_test.py rename to packages/control/counter_all/counter_home_consumption_test.py index 6303dd63e9..034645a50d 100644 --- a/packages/control/counter_home_consumption_test.py +++ b/packages/control/counter_all/counter_home_consumption_test.py @@ -4,7 +4,7 @@ from control import data from packages.conftest import hierarchy_hc_counter, hierarchy_standard, hierarchy_hybrid, hierarchy_nested -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from modules.common.fault_state import FaultStateLevel diff --git a/packages/control/hierarchy_test.py b/packages/control/counter_all/hierarchy_test.py similarity index 99% rename from packages/control/hierarchy_test.py rename to packages/control/counter_all/hierarchy_test.py index 297a9d7a3b..20805a2309 100644 --- a/packages/control/hierarchy_test.py +++ b/packages/control/counter_all/hierarchy_test.py @@ -5,7 +5,7 @@ from control.counter import Counter -from control.counter_all import CounterAll, get_max_id_in_hierarchy +from control.counter_all.counter_all import CounterAll, get_max_id_in_hierarchy from modules.common.component_type import ComponentType diff --git a/packages/control/data.py b/packages/control/data.py index 1d7c779d01..dfa48c416a 100644 --- a/packages/control/data.py +++ b/packages/control/data.py @@ -19,7 +19,7 @@ from helpermodules.graph import Graph from helpermodules.subdata import SubData from control.counter import Counter -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.ev.charge_template import ChargeTemplate from control.ev.ev import Ev from control.ev.ev_template import EvTemplate diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index f03fd86884..bf2bf0386d 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -17,6 +17,7 @@ from control.chargepoint import chargepoint from control.chargepoint.chargepoint_template import get_chargepoint_template_default +from control.counter_all import counter_all from control.ev.charge_template import ChargeTemplate, get_new_charge_template from control.ev.ev_template import EvTemplateData from helpermodules import pub @@ -38,7 +39,7 @@ from helpermodules.pub import Pub, pub_single from helpermodules.subdata import SubData from helpermodules.utils.topic_parser import decode_payload, get_index -from control import bat, bridge, data, counter, counter_all, pv +from control import bat, bridge, data, counter, pv from control.ev import ev from modules.chargepoints.internal_openwb.chargepoint_module import ChargepointModule from modules.chargepoints.internal_openwb.config import InternalChargepointMode diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 38b26d5490..ba59c3cd7f 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -9,12 +9,13 @@ import subprocess import paho.mqtt.client as mqtt -from control import bat_all, bat, counter, counter_all, general, io_device, optional, pv, pv_all +from control import bat_all, bat, counter, general, io_device, optional, pv, pv_all from control.chargepoint import chargepoint from control.chargepoint.chargepoint_all import AllChargepoints from control.chargepoint.chargepoint_data import Log from control.chargepoint.chargepoint_state_update import ChargepointStateUpdate from control.chargepoint.chargepoint_template import CpTemplate, CpTemplateData +from control.counter_all import counter_all from control.ev.charge_template import ChargeTemplate, ChargeTemplateData from control.ev import ev from control.ev.ev_template import EvTemplate, EvTemplateData diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 3384fdd9ef..880fc5f4e4 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -33,7 +33,7 @@ from helpermodules.utils.json_file_handler import write_and_check from helpermodules.utils.run_command import run_command from helpermodules.utils.topic_parser import decode_payload, get_index, get_second_index -from control import counter_all +from control.counter_all import counter_all from control.bat_all import BatConsiderationMode from control.chargepoint.charging_type import ChargingType from control.counter import get_counter_default_config diff --git a/packages/modules/common/store/_counter_test.py b/packages/modules/common/store/_counter_test.py index b9c8c292d8..ba178d1f2f 100644 --- a/packages/modules/common/store/_counter_test.py +++ b/packages/modules/common/store/_counter_test.py @@ -9,7 +9,7 @@ from control import data from control.chargepoint.chargepoint import Chargepoint from control.counter import Counter, CounterData, Get -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from modules.chargepoints.mqtt.chargepoint_module import ChargepointModule from modules.common.component_state import BatState, ChargepointState, CounterState, InverterState from modules.common.simcount._simcounter import SimCounter diff --git a/packages/modules/common/store/_inverter_test.py b/packages/modules/common/store/_inverter_test.py index 46b2c58092..682c9ca2d8 100644 --- a/packages/modules/common/store/_inverter_test.py +++ b/packages/modules/common/store/_inverter_test.py @@ -1,6 +1,6 @@ from modules.common.store._inverter import PurgeInverterState from modules.common.component_state import InverterState -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from control.bat import Bat, BatData, Get from typing import List, NamedTuple from unittest.mock import Mock diff --git a/packages/modules/devices/generic/virtual/counter_test.py b/packages/modules/devices/generic/virtual/counter_test.py index a19c60436a..645d1da104 100644 --- a/packages/modules/devices/generic/virtual/counter_test.py +++ b/packages/modules/devices/generic/virtual/counter_test.py @@ -6,7 +6,7 @@ from control import data from control.chargepoint.chargepoint import Chargepoint -from control.counter_all import CounterAll +from control.counter_all.counter_all import CounterAll from modules.chargepoints.mqtt.chargepoint_module import ChargepointModule from modules.chargepoints.mqtt.config import Mqtt from modules.common.component_state import BatState, ChargepointState, CounterState, InverterState From f53a71e650cbccba39d90f4420cd37da8c1d1101 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 10 Apr 2026 07:57:21 +0200 Subject: [PATCH 2/8] counter all refactoring, mixin --- packages/control/counter_all/counter_all.py | 357 +----------------- .../control/counter_all/counter_all_data.py | 95 +++++ packages/control/counter_all/hierarchy.py | 317 ++++++++++++++++ packages/helpermodules/update_config.py | 6 +- 4 files changed, 420 insertions(+), 355 deletions(-) create mode 100644 packages/control/counter_all/counter_all_data.py create mode 100644 packages/control/counter_all/hierarchy.py diff --git a/packages/control/counter_all/counter_all.py b/packages/control/counter_all/counter_all.py index 5da53e9219..3da1249863 100644 --- a/packages/control/counter_all/counter_all.py +++ b/packages/control/counter_all/counter_all.py @@ -1,84 +1,26 @@ """Zähler-Logik """ import copy -from dataclasses import dataclass, field import logging -import re -from typing import Callable, Dict, List, Optional, Tuple, Union +from typing import List, Tuple 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 control.counter_all.counter_all_data import CounterAllData +from control.counter_all.hierarchy import HierarchyMixin from helpermodules.pub import Pub -from modules.common.component_type import ComponentType, component_type_to_readable_text +from modules.common.component_type import ComponentType from modules.common.fault_state import FaultStateLevel from modules.common.simcount import SimCounter log = logging.getLogger(__name__) -@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"}) - - -def config_factory() -> Config: - return Config() - - -@dataclass -class Set: - loadmanagement_active: bool = field( - default=False, metadata={"topic": "set/loadmanagement_active"}) - home_consumption: float = field(default=0, metadata={"topic": "set/home_consumption"}) - smarthome_power_excluded_from_home_consumption: float = field( - default=0, - metadata={"topic": "set/smarthome_power_excluded_from_home_consumption"}) - invalid_home_consumption: int = field( - default=0, metadata={"topic": "set/invalid_home_consumption"}) - daily_yield_home_consumption: float = field( - default=0, metadata={"topic": "set/daily_yield_home_consumption"}) - imported_home_consumption: float = field( - default=0, metadata={"topic": "set/imported_home_consumption"}) - disengageable_smarthome_power: float = field( - default=0, metadata={"topic": "set/disengageable_smarthome_power"}) - - -@dataclass -class Get: - hierarchy: List = field(default_factory=empty_list_factory, metadata={"topic": "get/hierarchy"}) - loadmanagement_prios: List[Dict] = field( - default_factory=empty_list_factory, metadata={"topic": "get/loadmanagement_prios"}) - - -def get_factory() -> Get: - return Get() - - -def set_factory() -> Set: - return Set() - - -@dataclass -class CounterAllData: - config: Config = field(default_factory=config_factory) - get: Get = field(default_factory=get_factory) - set: Set = field(default_factory=set_factory) - - -class CounterAll: +class CounterAll(HierarchyMixin): MISSING_EVU_COUNTER = "Bitte erst einen EVU-Zähler konfigurieren." def __init__(self): self.data = CounterAllData() - # Hilfsvariablen für die rekursiven Funktionen - self.connected_counters = [] - self.connected_chargepoints = [] - self.childless = [] self.sim_counter = SimCounter("", "", prefix="bezug") self.sim_counter.topic = "openWB/set/counter/set/" @@ -193,295 +135,6 @@ 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 - # Hierarchie analysieren - - def get_all_elements_without_children(self, id: int) -> List[Dict]: - self.childless.clear() - self.get_all_elements_without_children_recursive(self.get_entry_of_element(id)) - return self.childless - - def get_all_elements_without_children_recursive(self, child: Dict) -> None: - for child in child["children"]: - try: - if len(child["children"]) != 0: - self.get_all_elements_without_children_recursive(child) - else: - self.childless.append(child) - except Exception: - log.exception("Fehler in der allgemeinen Zähler-Klasse") - - def get_chargepoints_of_counter(self, counter: str) -> List[str]: - """ gibt eine Liste der Ladepunkte, die in den folgenden Zweigen des Zählers sind, zurück. - """ - self.connected_chargepoints.clear() - if counter == self.get_evu_counter_str(): - counter_object = self.data.get.hierarchy[0] - else: - counter_object = self.__get_entry( - self.data.get.hierarchy[0], - int(counter[7:]), - self.__get_entry_of_element) - try: - self._get_all_cp_connected_to_counter(counter_object) - except KeyError: - # Kein Ladepunkt unter dem Zähler - pass - return self.connected_chargepoints - - def _get_all_cp_connected_to_counter(self, child: Dict) -> None: - """ Rekursive Funktion, die alle Ladepunkte ermittelt, die an den angegebenen Zähler angeschlossen sind. - """ - # Alle Objekte der Ebene durchgehen - for child in child["children"]: - try: - if child["type"] == ComponentType.CHARGEPOINT.value: - self.connected_chargepoints.append(f"cp{child['id']}") - # Wenn das Objekt noch Kinder hat, diese ebenfalls untersuchen. - elif len(child["children"]) != 0: - self._get_all_cp_connected_to_counter(child) - except Exception: - log.exception("Fehler in der allgemeinen Zähler-Klasse") - - def get_counters_to_check(self, num: int) -> List[str]: - """ ermittelt alle Zähler im Zweig des Ladepunkts. - """ - self.connected_counters.clear() - self.__get_all_counter_in_branch(self.data.get.hierarchy[0], num) - return self.connected_counters - - def get_entry_of_element(self, id_to_find: int) -> Dict: - item = self.__is_id_in_top_level(id_to_find) - if item: - return item - else: - return self.__get_entry(self.data.get.hierarchy[0], id_to_find, self.__get_entry_of_element) - - def get_entry_of_parent(self, id_to_find: int) -> Dict: - if self.__is_id_in_top_level(id_to_find): - return {} - for child in self.data.get.hierarchy[0]["children"]: - if child["id"] == id_to_find: - return self.data.get.hierarchy[0] - else: - return self.__get_entry(self.data.get.hierarchy[0], id_to_find, self.__get_entry_of_parent) - - def __is_id_in_top_level(self, id_to_find: int) -> Dict: - for item in self.data.get.hierarchy: - if item["id"] == id_to_find: - return item - else: - return {} - - def __get_all_counter_in_branch(self, child: Dict, id_to_find: int) -> bool: - """ Rekursive Funktion, die alle Zweige durchgeht, bis der entsprechende Ladepunkt gefunden wird und dann alle - Zähler in diesem Pfad der Liste anhängt. - """ - parent_id = child["id"] - for child in child["children"]: - if child["id"] == id_to_find: - self.connected_counters.append(f"counter{parent_id}") - return True - if len(child["children"]) != 0: - found = self.__get_all_counter_in_branch(child, id_to_find) - if found: - self.connected_counters.append(f"counter{parent_id}") - return True - else: - return False - - def __get_entry(self, child: Dict, id_to_find: int, func: Callable[[Dict, int], bool]) -> Dict: - for child in child["children"]: - found = func(child, id_to_find) - if found: - return child - if len(child["children"]) != 0: - entry = self.__get_entry(child, id_to_find, func) - if entry: - return entry - else: - return {} - - def __get_entry_of_element(self, child: Dict, id_to_find: int) -> bool: - if child["id"] == id_to_find: - return True - else: - return False - - def __get_entry_of_parent(self, child: Dict, id_to_find: int) -> bool: - for child2 in child["children"]: - if child2["id"] == id_to_find: - return True - else: - return False - - def hierarchy_add_item_aside(self, new_id: int, new_type: ComponentType, id_to_find: int) -> None: - """ ruft die rekursive Funktion zum Hinzufügen eines Zählers oder Ladepunkts in die Zählerhierarchie auf - derselben Ebene wie das angegebene Element. - """ - if self.__is_id_in_top_level(id_to_find): - self.data.get.hierarchy.append({"id": new_id, "type": new_type.value, "children": []}) - else: - if (self.__edit_element_in_hierarchy( - self.data.get.hierarchy[0], - id_to_find, self._add_item_aside, new_id, new_type) is False): - raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") - - def _add_item_aside( - self, child: Dict, current_entry: Dict, id_to_find: int, new_id: int, new_type: ComponentType) -> bool: - if id_to_find == child["id"]: - current_entry["children"].append({"id": new_id, "type": new_type.value, "children": []}) - return True - else: - return False - - def hierarchy_remove_item(self, id_to_find: int, keep_children: bool = True) -> None: - """ruft die rekursive Funktion zum Löschen eines Elements. Je nach Flag werden die Kinder gelöscht oder auf die - Ebene des gelöschten Elements gehoben. - """ - item = self.__is_id_in_top_level(id_to_find) - if item: - if keep_children: - self.data.get.hierarchy.extend(item["children"]) - self.data.get.hierarchy.remove(item) - else: - if (self.__edit_element_in_hierarchy( - self.data.get.hierarchy[0], - id_to_find, self._remove_item, keep_children) is False): - raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") - - def _remove_item(self, child: Dict, current_entry: Dict, id: str, keep_children: bool) -> bool: - if id == child["id"]: - if keep_children: - current_entry["children"].extend(child["children"]) - current_entry["children"].remove(child) - return True - else: - return False - - def hierarchy_add_item_below_evu(self, new_id: int, new_type: ComponentType) -> None: - try: - self.hierarchy_add_item_below(new_id, new_type, self.get_id_evu_counter()) - except (TypeError, IndexError): - if new_type == ComponentType.COUNTER: - # es gibt noch keinen EVU-Zähler - hierarchy = [{ - "id": new_id, - "type": ComponentType.COUNTER.value, - "children": self.data.get.hierarchy - }] - self.data.get.hierarchy = hierarchy - else: - raise ValueError(self.MISSING_EVU_COUNTER) - - def hierarchy_add_item_below(self, new_id: int, new_type: ComponentType, id_to_find: int) -> None: - """ruft die rekursive Funktion zum Hinzufügen eines Elements als Kind des angegebenen Elements. - """ - item = self.__is_id_in_top_level(id_to_find) - if item: - item["children"].append({"id": new_id, "type": new_type.value, "children": []}) - else: - if (self.__edit_element_in_hierarchy( - self.data.get.hierarchy[0], - id_to_find, self._add_item_below, new_id, new_type) is False): - raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") - - def _add_item_below( - self, child: Dict, current_entry: Dict, id_to_find: int, new_id: int, new_type: ComponentType) -> bool: - if id_to_find == child["id"]: - child["children"].append({"id": new_id, "type": new_type.value, "children": []}) - return True - else: - return False - - def __edit_element_in_hierarchy(self, current_entry: Dict, id_to_find: int, func: Callable, *args) -> bool: - for child in current_entry["children"]: - if func(child, current_entry, id_to_find, *args): - return True - else: - if len(child["children"]) != 0: - if self.__edit_element_in_hierarchy(child, id_to_find, func, *args): - return True - else: - return False - - def get_list_of_elements_per_level(self) -> List[List[Dict[str, Union[int, str]]]]: - elements_per_level: List[List[Dict[str, Union[int, str]]]] = [] - for item in self.data.get.hierarchy: - list(zip(elements_per_level, self._get_list_of_elements_per_level(elements_per_level, item, 0))) - return elements_per_level - - def _get_list_of_elements_per_level(self, elements_per_level: List, child: Dict, index: int) -> List: - try: - elements_per_level[index].extend([{"type": child["type"], "id": child["id"]}]) - except IndexError: - elements_per_level.insert(index, [{"type": child["type"], "id": child["id"]}]) - for child in child["children"]: - elements_per_level = self._get_list_of_elements_per_level(elements_per_level, child, index+1) - return elements_per_level - - def validate_hierarchy(self): - try: - self._delete_obsolete_entries() - self._add_missing_entries() - except Exception: - log.exception("Fehler bei der Validierung der Hierarchie") - - def _delete_obsolete_entries(self): - def check_and_remove(name, type_name: ComponentType, data_structure): - if element["type"] == type_name.value: - if f"{name}{element['id']}" not in data_structure: - self.hierarchy_remove_item(element["id"]) - pub_system_message({}, f"{component_type_to_readable_text(type_name)} mit ID {element['id']} wurde" - " aus der Hierarchie entfernt, da keine gültige Konfiguration gefunden wurde.", - MessageType.WARNING) - - for level in self.get_list_of_elements_per_level(): - for element in level: - check_and_remove("bat", ComponentType.BAT, data.data.bat_data) - check_and_remove("counter", ComponentType.COUNTER, data.data.counter_data) - check_and_remove("cp", ComponentType.CHARGEPOINT, data.data.cp_data) - check_and_remove("pv", ComponentType.INVERTER, data.data.pv_data) - - def _add_missing_entries(self): - def check_and_add(type_name: ComponentType, data_structure): - for entry in data_structure: - break_flag = False - re_result = re.search("[0-9]+", entry) - if re_result is not None: - entry_num = int(re_result.group()) - for level in self.get_list_of_elements_per_level(): - for element in level: - if entry_num == element["id"] and element["type"] == type_name.value: - break_flag = True - break - if break_flag: - break - else: - try: - self.hierarchy_add_item_below_evu(entry_num, type_name) - except ValueError: - pub_system_message({}, "Die Struktur des Lastmanagements ist nicht plausibel. Bitte prüfe die " - "Konfiguration und Anordnung der Komponenten in der Hierarchie.", - MessageType.WARNING) - - pub_system_message({}, f"{component_type_to_readable_text(type_name)} mit ID {element['id']} wurde" - " in der Struktur des Lastmanagements hinzugefügt, da kein Eintrag in der " - "Struktur gefunden wurde. Bitte prüfe die Anordnung der Komponenten in der " - "Struktur.", - MessageType.WARNING) - - # Falls EVU-Zähler fehlt, zuerst hinzufügen. - check_and_add(ComponentType.COUNTER, data.data.counter_data) - try: - self.get_id_evu_counter() - check_and_add(ComponentType.BAT, data.data.bat_data) - check_and_add(ComponentType.CHARGEPOINT, data.data.cp_data) - check_and_add(ComponentType.INVERTER, data.data.pv_data) - except TypeError: - pub_system_message({}, ("Es konnte kein Zähler gefunden werden, der als EVU-Zähler an die Spitze des " - "Lastmanagements gesetzt werden kann. Bitte zuerst einen EVU-Zähler hinzufügen."), - MessageType.ERROR) - 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 new file mode 100644 index 0000000000..1633fb9990 --- /dev/null +++ b/packages/control/counter_all/counter_all_data.py @@ -0,0 +1,95 @@ +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Protocol, Union + +from dataclass_utils.factories import empty_list_factory +from modules.common.component_type import ComponentType + + +@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"}) + + +def config_factory() -> Config: + return Config() + + +@dataclass +class Set: + loadmanagement_active: bool = field( + default=False, metadata={"topic": "set/loadmanagement_active"}) + home_consumption: float = field(default=0, metadata={"topic": "set/home_consumption"}) + smarthome_power_excluded_from_home_consumption: float = field( + default=0, + metadata={"topic": "set/smarthome_power_excluded_from_home_consumption"}) + invalid_home_consumption: int = field( + default=0, metadata={"topic": "set/invalid_home_consumption"}) + daily_yield_home_consumption: float = field( + default=0, metadata={"topic": "set/daily_yield_home_consumption"}) + imported_home_consumption: float = field( + default=0, metadata={"topic": "set/imported_home_consumption"}) + disengageable_smarthome_power: float = field( + default=0, metadata={"topic": "set/disengageable_smarthome_power"}) + + +@dataclass +class Get: + hierarchy: List = field(default_factory=empty_list_factory, metadata={"topic": "get/hierarchy"}) + loadmanagement_prios: List[Dict] = field( + default_factory=empty_list_factory, metadata={"topic": "get/loadmanagement_prios"}) + + +def get_factory() -> Get: + return Get() + + +def set_factory() -> Set: + return Set() + + +@dataclass +class CounterAllData: + config: Config = field(default_factory=config_factory) + get: Get = field(default_factory=get_factory) + set: Set = field(default_factory=set_factory) + + +class HierarchyProtocol(Protocol): + @property + def childless(self) -> List: ... + @property + def connected_chargepoints(self) -> List: ... + @property + def connected_counters(self) -> List: ... + @property + def data(self) -> CounterAllData: ... + @property + def MISSING_EVU_COUNTER(self) -> str: ... + + def _add_item_aside(self, child: Dict, current_entry: Dict, id_to_find: int, + new_id: int, new_type: ComponentType) -> bool: ... + def _add_item_below(self, child: Dict, current_entry: Dict, id_to_find: int, + new_id: int, new_type: ComponentType) -> bool: ... + + def _add_missing_entries(self): ... + def _delete_obsolete_entries(self): ... + def _edit_element_in_hierarchy(self, current_entry: Dict, id_to_find: int, func: Callable, *args) -> bool: ... + def _get_all_counter_in_branch(self, child: Dict, id_to_find: int) -> bool: ... + def _get_all_cp_connected_to_counter(self, child: Dict) -> None: ... + def _get_all_elements_without_children_recursive(self, child: Dict) -> None: ... + def _get_entry(self, child: Dict, id_to_find: int, func: Callable[[Dict, int], bool]) -> Dict: ... + def _get_entry_of_element(self, child: Dict, id_to_find: int) -> bool: ... + def _get_entry_of_parent(self, child: Dict, id_to_find: int) -> bool: ... + def _get_list_of_elements_per_level(self, elements_per_level: List, child: Dict, index: int) -> List: ... + def _is_id_in_top_level(self, id_to_find: int) -> Dict: ... + def _remove_item(self, child: Dict, current_entry: Dict, id: str, keep_children: bool) -> bool: ... + def get_entry_of_element(self, id: int) -> Dict: ... + def get_evu_counter_str(self) -> str: ... + def get_id_evu_counter(self) -> int: ... + def get_list_of_elements_per_level(self) -> List[List[Dict[str, Union[int, str]]]]: ... + def hierarchy_add_item_below(self, new_id: int, new_type: ComponentType, id_to_find: int) -> None: ... + def hierarchy_add_item_below_evu(self, new_id: int, new_type: ComponentType) -> None: ... + def hierarchy_remove_item(self, id_to_find: int, keep_children: bool = True) -> None: ... diff --git a/packages/control/counter_all/hierarchy.py b/packages/control/counter_all/hierarchy.py new file mode 100644 index 0000000000..bddddd8a4b --- /dev/null +++ b/packages/control/counter_all/hierarchy.py @@ -0,0 +1,317 @@ +import logging +import re +from typing import Callable, Dict, List, Union + +from control import data +from control.counter_all.counter_all_data import HierarchyProtocol +from helpermodules.messaging import MessageType, pub_system_message +from modules.common.component_type import ComponentType, component_type_to_readable_text + +log = logging.getLogger(__name__) + + +class HierarchyMixin: + def get_all_elements_without_children(self: HierarchyProtocol, id: int) -> List[Dict]: + self.childless = [] + self._get_all_elements_without_children_recursive(self.get_entry_of_element(id)) + return self.childless + + def _get_all_elements_without_children_recursive(self: HierarchyProtocol, child: Dict) -> None: + for child in child["children"]: + try: + if len(child["children"]) != 0: + self._get_all_elements_without_children_recursive(child) + else: + self.childless.append(child) + except Exception: + log.exception("Fehler in der allgemeinen Zähler-Klasse") + + def get_chargepoints_of_counter(self: HierarchyProtocol, counter: str) -> List[str]: + """ gibt eine Liste der Ladepunkte, die in den folgenden Zweigen des Zählers sind, zurück. + """ + self.connected_chargepoints = [] + if counter == self.get_evu_counter_str(): + counter_object = self.data.get.hierarchy[0] + else: + counter_object = self._get_entry( + self.data.get.hierarchy[0], + int(counter[7:]), + self._get_entry_of_element) + try: + self._get_all_cp_connected_to_counter(counter_object) + except KeyError: + # Kein Ladepunkt unter dem Zähler + pass + return self.connected_chargepoints + + def _get_all_cp_connected_to_counter(self: HierarchyProtocol, child: Dict) -> None: + """ Rekursive Funktion, die alle Ladepunkte ermittelt, die an den angegebenen Zähler angeschlossen sind. + """ + # Alle Objekte der Ebene durchgehen + for child in child["children"]: + try: + if child["type"] == ComponentType.CHARGEPOINT.value: + self.connected_chargepoints.append(f"cp{child['id']}") + # Wenn das Objekt noch Kinder hat, diese ebenfalls untersuchen. + elif len(child["children"]) != 0: + self._get_all_cp_connected_to_counter(child) + except Exception: + log.exception("Fehler in der allgemeinen Zähler-Klasse") + + def get_counters_to_check(self: HierarchyProtocol, num: int) -> List[str]: + """ ermittelt alle Zähler im Zweig des Ladepunkts. + """ + self.connected_counters = [] + self._get_all_counter_in_branch(self.data.get.hierarchy[0], num) + return self.connected_counters + + def get_entry_of_element(self: HierarchyProtocol, id_to_find: int) -> Dict: + item = self._is_id_in_top_level(id_to_find) + if item: + return item + else: + return self._get_entry(self.data.get.hierarchy[0], id_to_find, self._get_entry_of_element) + + def get_entry_of_parent(self: HierarchyProtocol, id_to_find: int) -> Dict: + if self._is_id_in_top_level(id_to_find): + return {} + for child in self.data.get.hierarchy[0]["children"]: + if child["id"] == id_to_find: + return self.data.get.hierarchy[0] + else: + return self._get_entry(self.data.get.hierarchy[0], id_to_find, self._get_entry_of_parent) + + def _is_id_in_top_level(self: HierarchyProtocol, id_to_find: int) -> Dict: + for item in self.data.get.hierarchy: + if item["id"] == id_to_find: + return item + else: + return {} + + def _get_all_counter_in_branch(self: HierarchyProtocol, child: Dict, id_to_find: int) -> bool: + """ Rekursive Funktion, die alle Zweige durchgeht, bis der entsprechende Ladepunkt gefunden wird und dann alle + Zähler in diesem Pfad der Liste anhängt. + """ + parent_id = child["id"] + for child in child["children"]: + if child["id"] == id_to_find: + self.connected_counters.append(f"counter{parent_id}") + return True + if len(child["children"]) != 0: + found = self._get_all_counter_in_branch(child, id_to_find) + if found: + self.connected_counters.append(f"counter{parent_id}") + return True + else: + return False + + def _get_entry(self: HierarchyProtocol, child: Dict, id_to_find: int, func: Callable[[Dict, int], bool]) -> Dict: + for child in child["children"]: + found = func(child, id_to_find) + if found: + return child + if len(child["children"]) != 0: + entry = self._get_entry(child, id_to_find, func) + if entry: + return entry + else: + return {} + + def _get_entry_of_element(self: HierarchyProtocol, child: Dict, id_to_find: int) -> bool: + if child["id"] == id_to_find: + return True + else: + return False + + def _get_entry_of_parent(self: HierarchyProtocol, child: Dict, id_to_find: int) -> bool: + for child2 in child["children"]: + if child2["id"] == id_to_find: + return True + else: + return False + + def hierarchy_add_item_aside(self: HierarchyProtocol, + new_id: int, new_type: ComponentType, + id_to_find: int) -> None: + """ ruft die rekursive Funktion zum Hinzufügen eines Zählers oder Ladepunkts in die Zählerhierarchie auf + derselben Ebene wie das angegebene Element. + """ + if self._is_id_in_top_level(id_to_find): + self.data.get.hierarchy.append({"id": new_id, "type": new_type.value, "children": []}) + else: + if (self._edit_element_in_hierarchy( + self.data.get.hierarchy[0], + id_to_find, self._add_item_aside, new_id, new_type) is False): + raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") + + def _add_item_aside(self: HierarchyProtocol, + child: Dict, + current_entry: List, + id_to_find: int, + new_id: int, + new_type: ComponentType) -> bool: + if id_to_find == child["id"]: + current_entry["children"].append({"id": new_id, "type": new_type.value, "children": []}) + return True + else: + return False + + def hierarchy_remove_item(self: HierarchyProtocol, id_to_find: int, keep_children: bool = True) -> None: + """ruft die rekursive Funktion zum Löschen eines Elements. Je nach Flag werden die Kinder gelöscht oder auf die + Ebene des gelöschten Elements gehoben. + """ + item = self._is_id_in_top_level(id_to_find) + if item: + if keep_children: + self.data.get.hierarchy.extend(item["children"]) + self.data.get.hierarchy.remove(item) + else: + if (self._edit_element_in_hierarchy( + self.data.get.hierarchy[0], + id_to_find, self._remove_item, keep_children) is False): + raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") + + def _remove_item(self: HierarchyProtocol, child: Dict, current_entry: Dict, id: str, keep_children: bool) -> bool: + if id == child["id"]: + if keep_children: + current_entry["children"].extend(child["children"]) + current_entry["children"].remove(child) + return True + else: + return False + + def hierarchy_add_item_below_evu(self: HierarchyProtocol, new_id: int, new_type: ComponentType) -> None: + try: + self.hierarchy_add_item_below(new_id, new_type, self.get_id_evu_counter()) + except (TypeError, IndexError): + if new_type == ComponentType.COUNTER: + # es gibt noch keinen EVU-Zähler + hierarchy = [{ + "id": new_id, + "type": ComponentType.COUNTER.value, + "children": self.data.get.hierarchy + }] + self.data.get.hierarchy = hierarchy + else: + raise ValueError(self.MISSING_EVU_COUNTER) + + def hierarchy_add_item_below(self: HierarchyProtocol, + new_id: int, + new_type: ComponentType, id_to_find: int) -> None: + """ruft die rekursive Funktion zum Hinzufügen eines Elements als Kind des angegebenen Elements. + """ + item = self._is_id_in_top_level(id_to_find) + if item: + item["children"].append({"id": new_id, "type": new_type.value, "children": []}) + else: + if (self._edit_element_in_hierarchy( + self.data.get.hierarchy[0], + id_to_find, self._add_item_below, new_id, new_type) is False): + raise IndexError(f"Element {id_to_find} konnte nicht in der Hierarchie gefunden werden.") + + def _add_item_below(self: HierarchyProtocol, + child: Dict, current_entry: Dict, + id_to_find: int, + new_id: int, + new_type: ComponentType) -> bool: + if id_to_find == child["id"]: + child["children"].append({"id": new_id, "type": new_type.value, "children": []}) + return True + else: + return False + + def _edit_element_in_hierarchy(self: HierarchyProtocol, + current_entry: Dict, + id_to_find: int, + func: Callable, + *args) -> bool: + for child in current_entry["children"]: + if func(child, current_entry, id_to_find, *args): + return True + else: + if len(child["children"]) != 0: + if self._edit_element_in_hierarchy(child, id_to_find, func, *args): + return True + else: + return False + + def get_list_of_elements_per_level(self: HierarchyProtocol) -> List[List[Dict[str, Union[int, str]]]]: + elements_per_level: List[List[Dict[str, Union[int, str]]]] = [] + for item in self.data.get.hierarchy: + list(zip(elements_per_level, self._get_list_of_elements_per_level(elements_per_level, item, 0))) + return elements_per_level + + def _get_list_of_elements_per_level(self: HierarchyProtocol, + elements_per_level: List[List[Dict[str, Union[int, str]]]], + child: Dict, + index: int) -> List: + try: + elements_per_level[index].extend([{"type": child["type"], "id": child["id"]}]) + except IndexError: + elements_per_level.insert(index, [{"type": child["type"], "id": child["id"]}]) + for child in child["children"]: + elements_per_level = self._get_list_of_elements_per_level(elements_per_level, child, index+1) + return elements_per_level + + def validate_hierarchy(self: HierarchyProtocol): + try: + self._delete_obsolete_entries() + self._add_missing_entries() + except Exception: + log.exception("Fehler bei der Validierung der Hierarchie") + + def _delete_obsolete_entries(self: HierarchyProtocol): + def check_and_remove(name, type_name: ComponentType, data_structure): + if element["type"] == type_name.value: + if f"{name}{element['id']}" not in data_structure: + self.hierarchy_remove_item(element["id"]) + pub_system_message({}, f"{component_type_to_readable_text(type_name)} mit ID {element['id']} wurde" + " aus der Hierarchie entfernt, da keine gültige Konfiguration gefunden wurde.", + MessageType.WARNING) + + for level in self.get_list_of_elements_per_level(): + for element in level: + check_and_remove("bat", ComponentType.BAT, data.data.bat_data) + check_and_remove("counter", ComponentType.COUNTER, data.data.counter_data) + check_and_remove("cp", ComponentType.CHARGEPOINT, data.data.cp_data) + check_and_remove("pv", ComponentType.INVERTER, data.data.pv_data) + + def _add_missing_entries(self: HierarchyProtocol): + def check_and_add(type_name: ComponentType, data_structure): + for entry in data_structure: + break_flag = False + re_result = re.search("[0-9]+", entry) + if re_result is not None: + entry_num = int(re_result.group()) + for level in self.get_list_of_elements_per_level(): + for element in level: + if entry_num == element["id"] and element["type"] == type_name.value: + break_flag = True + break + if break_flag: + break + else: + try: + self.hierarchy_add_item_below_evu(entry_num, type_name) + except ValueError: + pub_system_message({}, "Die Struktur des Lastmanagements ist nicht plausibel. Bitte prüfe die " + "Konfiguration und Anordnung der Komponenten in der Hierarchie.", + MessageType.WARNING) + + pub_system_message({}, f"{component_type_to_readable_text(type_name)} mit ID {element['id']} wurde" + " in der Struktur des Lastmanagements hinzugefügt, da kein Eintrag in der " + "Struktur gefunden wurde. Bitte prüfe die Anordnung der Komponenten in der " + "Struktur.", + MessageType.WARNING) + + # Falls EVU-Zähler fehlt, zuerst hinzufügen. + check_and_add(ComponentType.COUNTER, data.data.counter_data) + try: + self.get_id_evu_counter() + check_and_add(ComponentType.BAT, data.data.bat_data) + check_and_add(ComponentType.CHARGEPOINT, data.data.cp_data) + check_and_add(ComponentType.INVERTER, data.data.pv_data) + except TypeError: + pub_system_message({}, ("Es konnte kein Zähler gefunden werden, der als EVU-Zähler an die Spitze des " + "Lastmanagements gesetzt werden kann. Bitte zuerst einen EVU-Zähler hinzufügen."), + MessageType.ERROR) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 880fc5f4e4..29f5a6dd44 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -33,7 +33,7 @@ from helpermodules.utils.json_file_handler import write_and_check from helpermodules.utils.run_command import run_command from helpermodules.utils.topic_parser import decode_payload, get_index, get_second_index -from control.counter_all import counter_all +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 @@ -569,8 +569,8 @@ class UpdateConfig: ("openWB/chargepoint/get/power", 0), ("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/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/info", {"manufacturer": None, "model": None}), ("openWB/vehicle/0/charge_template", ev.Ev(0).charge_template.data.id), From ff9dee42c26cdfc618675228401a9b5922989c26 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 10 Apr 2026 16:06:59 +0200 Subject: [PATCH 3/8] loadmanagement prio operations --- packages/control/counter_all/counter_all.py | 3 +- .../control/counter_all/counter_all_data.py | 11 ++ .../counter_all/loadmanagement_prio.py | 70 +++++++ .../counter_all/loadmanagement_prio_test.py | 187 ++++++++++++++++++ 4 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 packages/control/counter_all/loadmanagement_prio.py create mode 100644 packages/control/counter_all/loadmanagement_prio_test.py diff --git a/packages/control/counter_all/counter_all.py b/packages/control/counter_all/counter_all.py index 3da1249863..375857a426 100644 --- a/packages/control/counter_all/counter_all.py +++ b/packages/control/counter_all/counter_all.py @@ -8,6 +8,7 @@ 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 modules.common.component_type import ComponentType from modules.common.fault_state import FaultStateLevel @@ -16,7 +17,7 @@ log = logging.getLogger(__name__) -class CounterAll(HierarchyMixin): +class CounterAll(HierarchyMixin, LoadmanagementPrioMixin): MISSING_EVU_COUNTER = "Bitte erst einen EVU-Zähler konfigurieren." def __init__(self): diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py index 1633fb9990..cf5a8ade89 100644 --- a/packages/control/counter_all/counter_all_data.py +++ b/packages/control/counter_all/counter_all_data.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, field from typing import Callable, Dict, List, Optional, Protocol, Union +from control.chargepoint.chargepoint import Chargepoint from dataclass_utils.factories import empty_list_factory from modules.common.component_type import ComponentType @@ -93,3 +94,13 @@ def get_list_of_elements_per_level(self) -> List[List[Dict[str, Union[int, str]] def hierarchy_add_item_below(self, new_id: int, new_type: ComponentType, id_to_find: int) -> None: ... def hierarchy_add_item_below_evu(self, new_id: int, new_type: ComponentType) -> None: ... def hierarchy_remove_item(self, id_to_find: int, keep_children: bool = True) -> None: ... + + +class LoadmanagementPrioProtocol(Protocol): + @property + def data(self) -> CounterAllData: ... + def add_loadmanagement_prio_item(self, type: str, id: int) -> None: ... + def remove_loadmanagement_prio_item(self, id: int) -> None: ... + def _remove_loadmanagement_prio_item(self, id: int, entry: Dict) -> None: ... + def sort_cps_by_loadmanagement_prios_nested(self, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: ... + def sort_cps_by_loadmanagement_prios_flat(self, filtered_cps: List[Chargepoint]) -> List[Chargepoint]: ... diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py new file mode 100644 index 0000000000..c901779f7f --- /dev/null +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -0,0 +1,70 @@ +import logging +from typing import Dict, List + +from control.chargepoint.chargepoint import Chargepoint +from control.counter_all.counter_all_data import LoadmanagementPrioProtocol + + +log = logging.getLogger(__name__) + + +class LoadmanagementPrioMixin: + def add_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, type: str, id: int) -> None: + self.data.get.loadmanagement_prios.append({"type": type, "id": id}) + + def remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, id: int) -> None: + if self._remove_loadmanagement_prio_item(id, self.data.get.loadmanagement_prios) is False: + raise IndexError(f"Element {id} konnte nicht in der Prioritätensteuerung gefunden werden.") + + def _remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, id: int, entry: List[Dict]) -> bool: + for item in entry: + if item["type"] == "vehicle": + if item["id"] == id: + entry.remove(item) + return True + elif item["type"] == "group": + removed_item = self._remove_loadmanagement_prio_item(id, item["children"]) + if removed_item and len(item["children"]) == 0: + entry.remove(item) + if removed_item: + return True + return False + + def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, + filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: + sorted_cps = [] + for entry in self.data.get.loadmanagement_prios: + if entry["type"] == "vehicle": + grouped_cps = [] + for cp in filtered_cps: + if cp.data.config.ev == entry["id"]: + grouped_cps.append(cp) + if len(grouped_cps) > 0: + sorted_cps.append(grouped_cps) + elif entry["type"] == "group": + grouped_cps = [] + for group_entry in entry["children"]: + for cp in filtered_cps: + if cp.data.config.ev == group_entry["id"]: + grouped_cps.append(cp) + if len(grouped_cps) > 0: + sorted_cps.append(grouped_cps) + return sorted_cps + + def sort_cps_by_loadmanagement_prios_flat(self: LoadmanagementPrioProtocol, + filtered_cps: List[Chargepoint]) -> List[Chargepoint]: + sorted_cps = [] + for entry in self.data.get.loadmanagement_prios: + if entry["type"] == "vehicle": + for cp in filtered_cps: + if cp.data.config.ev == entry["id"]: + sorted_cps.append(cp) + elif entry["type"] == "group": + for group_entry in entry["children"]: + for cp in filtered_cps: + if cp.data.config.ev == group_entry["id"]: + sorted_cps.append(cp) + if len(sorted_cps) != len(filtered_cps): + raise ValueError( + "Fahrzeuge der Prioritätensteuerung konnten nicht korrekt den Ladepunkten zugeordnet werden.") + return sorted_cps diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py new file mode 100644 index 0000000000..7e63cb336d --- /dev/null +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -0,0 +1,187 @@ + +from typing import Dict, List + +import pytest + +from control.chargepoint.chargepoint import Chargepoint +from control.counter_all.counter_all import CounterAll + + +@pytest.mark.parametrize( + "loadmanagement_prios, id, type, expected_loadmanagement_prios", + [ + pytest.param([], 2, "vehicle", [{"type": "vehicle", "id": 2}], id="emtpy list"), + pytest.param([{"type": "vehicle", "id": 3}], 2, "vehicle", [{"type": "vehicle", "id": 3}, + {"type": "vehicle", "id": 2}], id="flat list"), + pytest.param([ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], 4, "vehicle", [ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + {"type": "vehicle", "id": 4}, + ], id="nested list"), + ] +) +def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected_loadmanagement_prios: List[Dict]): + # setup + c = CounterAll() + c.data.get.loadmanagement_prios = loadmanagement_prios + + # execution + c.add_loadmanagement_prio_item(type, id) + + # assert + assert c.data.get.loadmanagement_prios == expected_loadmanagement_prios + + +@pytest.mark.parametrize( + "loadmanagement_prios, id, type, expected_loadmanagement_prios", + [ + pytest.param([{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 2}], + 2, "vehicle", [{"type": "vehicle", "id": 3}], id="flat list"), + pytest.param([ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], 2, "vehicle", [ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + ], id="nested list"), + pytest.param([ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], 0, "vehicle", [ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], id="nested list, remove from group"), + pytest.param([ + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], 0, "vehicle", [{"type": "vehicle", "id": 2}], id="nested list, empty group"), + ] +) +def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], + id: int, + type: str, + expected_loadmanagement_prios: List[Dict]): + # setup + c = CounterAll() + c.data.get.loadmanagement_prios = loadmanagement_prios + + # execution + c.remove_loadmanagement_prio_item(id) + + # assert + assert c.data.get.loadmanagement_prios == expected_loadmanagement_prios + + +@pytest.mark.parametrize( + "config_ev_list, loadmanagement_prios, sorted_cp_ids", + [ + pytest.param([0]*3, + [{"type": "vehicle", "id": 2}, {"type": "vehicle", "id": 0}], + [1, 2, 3], + id="alle LP haben das gleiche Fahrzeug zugeordnet"), + pytest.param([1, 2, 3], + [{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 1}, {"type": "vehicle", "id": 0}], + [3, 1, 2], + id="alle LP haben unterschiedliche Fahrzeuge"), + pytest.param([1, 2, 3], + [{"type": "vehicle", "id": 3}, { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [{"type": "vehicle", "id": 1}, { + "type": "vehicle", "id": 2}]}], + [3, 1, 2], + id="alle LP haben unterschiedliche Fahrzeuge mit Gruppe"), + ] +) +def test_sort_cps_by_loadmanagement_prios_flat(config_ev_list: List[int], + loadmanagement_prios: List[Dict], + sorted_cp_ids: List[int]): + # setup + cp1 = Chargepoint(1, None) + cp1.data.config.ev = config_ev_list[0] + cp2 = Chargepoint(2, None) + cp2.data.config.ev = config_ev_list[1] + cp3 = Chargepoint(3, None) + cp3.data.config.ev = config_ev_list[2] + + c = CounterAll() + c.data.get.loadmanagement_prios = loadmanagement_prios + + # execution + sorted_cps = c.sort_cps_by_loadmanagement_prios_flat([cp1, cp2, cp3]) + + # assert + for i, cp in enumerate(sorted_cps): + assert cp.num == sorted_cp_ids[i] + + +def test_sort_cps_by_loadmanagement_prios_nested(): + # setup + cp1 = Chargepoint(1, None) + cp1.data.config.ev = 1 + cp2 = Chargepoint(2, None) + cp2.data.config.ev = 2 + cp3 = Chargepoint(3, None) + cp3.data.config.ev = 3 + + c = CounterAll() + c.data.get.loadmanagement_prios = [{"type": "vehicle", "id": 3}, { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [{"type": "vehicle", "id": 1}, { + "type": "vehicle", "id": 2}]}] + + # execution + sorted_cps = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) + + # assert + assert sorted_cps == [[cp3], [cp1, cp2]] From 08ca171e2647b44353c7ec07a530f43c61eb7744 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 13 Apr 2026 09:53:38 +0200 Subject: [PATCH 4/8] fix pytest --- packages/control/counter_all/loadmanagement_prio_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index 7e63cb336d..19c335af50 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -129,7 +129,7 @@ def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], [1, 2, 3], id="alle LP haben das gleiche Fahrzeug zugeordnet"), pytest.param([1, 2, 3], - [{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 1}, {"type": "vehicle", "id": 0}], + [{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 1}, {"type": "vehicle", "id": 2}], [3, 1, 2], id="alle LP haben unterschiedliche Fahrzeuge"), pytest.param([1, 2, 3], From 2ca4aee95bc34a9e73dcdb55e563c7a83bf455a5 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 13 Apr 2026 14:33:27 +0200 Subject: [PATCH 5/8] draft --- .../control/algorithm/additional_current.py | 7 +-- packages/control/algorithm/bidi_charging.py | 6 +- .../control/algorithm/filter_chargepoints.py | 4 +- .../algorithm/filter_chargepoints_test.py | 2 +- packages/control/algorithm/min_current.py | 5 +- .../control/algorithm/surplus_controlled.py | 6 +- .../control/counter_all/counter_all_data.py | 7 ++- .../counter_all/loadmanagement_prio.py | 57 ++++++++++++------- .../counter_all/loadmanagement_prio_test.py | 34 +++-------- 9 files changed, 60 insertions(+), 68 deletions(-) diff --git a/packages/control/algorithm/additional_current.py b/packages/control/algorithm/additional_current.py index 76ee2d0031..3e13202c39 100644 --- a/packages/control/algorithm/additional_current.py +++ b/packages/control/algorithm/additional_current.py @@ -1,5 +1,6 @@ import logging +from control import data from control.algorithm import common from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT from control.limiting_value import LoadmanagementLimit @@ -24,9 +25,8 @@ def set_additional_current(self) -> None: if preferenced_chargepoints: common.update_raw_data(preferenced_chargepoints) log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") - while len(preferenced_chargepoints): - cp = preferenced_chargepoints[0] - missing_currents, counts = common.get_missing_currents_left(preferenced_chargepoints) + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(preferenced_chargepoints): + missing_currents, counts = common.get_missing_currents_left(group) available_currents, limit = Loadmanagement().get_available_currents(missing_currents, counter, cp) log.debug(f"cp {cp.num} available currents {available_currents} missing currents " f"{missing_currents} limit {limit.message}") @@ -40,7 +40,6 @@ def set_additional_current(self) -> None: cp.data.control_parameter.min_current, current, cp) - preferenced_chargepoints.pop(0) if preferenced_cps_without_set_current: for cp in preferenced_cps_without_set_current: cp.data.set.current = cp.data.set.target_current diff --git a/packages/control/algorithm/bidi_charging.py b/packages/control/algorithm/bidi_charging.py index 4dec5a7b4c..91e4b090fd 100644 --- a/packages/control/algorithm/bidi_charging.py +++ b/packages/control/algorithm/bidi_charging.py @@ -20,9 +20,8 @@ def set_bidi(self): if preferenced_cps: log.info( f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {grid_counter.num}") - while len(preferenced_cps): - cp = preferenced_cps[0] - zero_point_adjustment = grid_counter.data.set.surplus_power_left / len(preferenced_cps) + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(preferenced_cps): + zero_point_adjustment = grid_counter.data.set.surplus_power_left / len(group) log.debug(f"Nullpunktanpassung für LP{cp.num}: verbleibende Leistung {zero_point_adjustment}W") missing_currents = [zero_point_adjustment / cp.data.get.phases_in_use / 230 for i in range(0, cp.data.get.phases_in_use)] @@ -43,4 +42,3 @@ def set_bidi(self): grid_counter.update_surplus_values_left(missing_currents, voltages_mean(cp.data.get.voltages)) cp.data.set.current = missing_currents[0] log.info(f"LP{cp.num}: Stromstärke {missing_currents}A") - preferenced_cps.pop(0) diff --git a/packages/control/algorithm/filter_chargepoints.py b/packages/control/algorithm/filter_chargepoints.py index f523b1d399..041bd337a6 100644 --- a/packages/control/algorithm/filter_chargepoints.py +++ b/packages/control/algorithm/filter_chargepoints.py @@ -47,7 +47,7 @@ def get_chargepoints_with_required_current_by_chargemode( def get_preferenced_chargepoint_charging( chargepoints: List[Chargepoint]) -> Tuple[List[Chargepoint], List[Chargepoint]]: - preferenced_chargepoints = _get_preferenced_chargepoint(chargepoints) + preferenced_chargepoints = get_preferenced_chargepoint(chargepoints) preferenced_chargepoints_with_set_current = [] preferenced_chargepoints_without_set_current = [] for cp in preferenced_chargepoints: @@ -67,7 +67,7 @@ def get_preferenced_chargepoint_charging( # tested -def _get_preferenced_chargepoint(valid_chargepoints: List[Chargepoint]) -> List: +def get_preferenced_chargepoint(valid_chargepoints: List[Chargepoint]) -> List: """ermittelt die Ladepunkte in der Reihenfolge, in der sie geladen/gestoppt werden sollen. Die Bedingungen sind: geringste Mindeststromstärke, niedrigster SoC, frühester Ansteck-Zeitpunkt(Einschalten)/Lademenge(Abschalten), diff --git a/packages/control/algorithm/filter_chargepoints_test.py b/packages/control/algorithm/filter_chargepoints_test.py index 66fbd7f277..68078b266b 100644 --- a/packages/control/algorithm/filter_chargepoints_test.py +++ b/packages/control/algorithm/filter_chargepoints_test.py @@ -78,7 +78,7 @@ def mock_cp(cp: Chargepoint, num: int): cp2 = mock_cp(mock_cp2, 2) cp3 = mock_cp(mock_cp3, 3) # execution - preferenced_chargepoints = filter_chargepoints._get_preferenced_chargepoint([cp1, cp2, cp3]) + preferenced_chargepoints = filter_chargepoints.get_preferenced_chargepoint([cp1, cp2, cp3]) # evaluation assert preferenced_chargepoints == params.expected_sort diff --git a/packages/control/algorithm/min_current.py b/packages/control/algorithm/min_current.py index 48ce9eba6a..b21279ea02 100644 --- a/packages/control/algorithm/min_current.py +++ b/packages/control/algorithm/min_current.py @@ -3,6 +3,7 @@ from control import data from control.algorithm import common from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_MIN_CURRENT, CONSIDERED_CHARGE_MODES_PV_ONLY +from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state import ChargepointState from control.loadmanagement import Loadmanagement from control.algorithm.filter_chargepoints import get_chargepoints_by_mode_and_counter @@ -21,8 +22,7 @@ def set_min_current(self) -> None: if preferenced_chargepoints: log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") common.update_raw_data(preferenced_chargepoints, diff_to_zero=True) - while len(preferenced_chargepoints): - cp = preferenced_chargepoints[0] + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(preferenced_chargepoints): missing_currents, counts = common.get_min_current(cp) if max(missing_currents) > 0: available_currents, limit = Loadmanagement().get_available_currents( @@ -52,4 +52,3 @@ def set_min_current(self) -> None: except Exception: log.exception(f"Fehler in der PV-gesteuerten Ladung bei {cp.num}") cp.data.set.current = 0 - preferenced_chargepoints.pop(0) diff --git a/packages/control/algorithm/surplus_controlled.py b/packages/control/algorithm/surplus_controlled.py index d7239b9696..144a655b53 100644 --- a/packages/control/algorithm/surplus_controlled.py +++ b/packages/control/algorithm/surplus_controlled.py @@ -51,9 +51,8 @@ def _set(self, counter: Counter) -> None: log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") common.update_raw_data(chargepoints, surplus=True) - while len(chargepoints): - cp = chargepoints[0] - missing_currents, counts = common.get_missing_currents_left(chargepoints) + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(chargepoints): + missing_currents, counts = common.get_missing_currents_left(group) available_currents, limit = Loadmanagement().get_available_currents_surplus( missing_currents, voltages_mean(cp.data.get.voltages), @@ -88,7 +87,6 @@ def _set(self, limited_current, cp, surplus=True) - chargepoints.pop(0) def _set_loadmangement_message(self, current: float, diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py index cf5a8ade89..c137c5add5 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, List, Optional, Protocol, Union +from typing import Callable, Dict, Generator, List, Optional, Protocol, Tuple, Union from control.chargepoint.chargepoint import Chargepoint from dataclass_utils.factories import empty_list_factory @@ -103,4 +103,7 @@ def add_loadmanagement_prio_item(self, type: str, id: int) -> None: ... def remove_loadmanagement_prio_item(self, id: int) -> None: ... def _remove_loadmanagement_prio_item(self, id: int, entry: Dict) -> None: ... def sort_cps_by_loadmanagement_prios_nested(self, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: ... - def sort_cps_by_loadmanagement_prios_flat(self, filtered_cps: List[Chargepoint]) -> List[Chargepoint]: ... + + def generator_cps_by_loadmanagement_prios( + self, + filtered_cps: List[Chargepoint]) -> Generator[Tuple[Chargepoint, List[Chargepoint]], None, None]: ... diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index c901779f7f..667c1aa269 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -1,6 +1,8 @@ import logging -from typing import Dict, List +from pprint import pprint +from typing import Dict, Generator, Iterable, List, Tuple +from control.algorithm.filter_chargepoints import get_preferenced_chargepoint from control.chargepoint.chargepoint import Chargepoint from control.counter_all.counter_all_data import LoadmanagementPrioProtocol @@ -30,6 +32,17 @@ def _remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, id: int, return True return False + def generator_cps_by_loadmanagement_prios( + self: LoadmanagementPrioProtocol, + filtered_cps: List[Chargepoint]) -> Generator[Tuple[Chargepoint, List[Chargepoint]], None, None]: + sorted_cps = self.sort_cps_by_loadmanagement_prios_nested(filtered_cps) + log.debug("Ladepunkte sortiert nach Prioritätensteuerung: ") + log.debug(pprint({[f"LP {cp.num}" for group in sorted_cps for cp in group]})) + for group in sorted_cps: + cp: Chargepoint + for cp in group: + yield cp, group + def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: sorted_cps = [] @@ -40,31 +53,33 @@ def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, if cp.data.config.ev == entry["id"]: grouped_cps.append(cp) if len(grouped_cps) > 0: - sorted_cps.append(grouped_cps) + sorted_grouped_cps = get_preferenced_chargepoint(grouped_cps) + sorted_cps.append(sorted_grouped_cps) elif entry["type"] == "group": - grouped_cps = [] + grouped_cps, sorted_grouped_cps = [], [] for group_entry in entry["children"]: for cp in filtered_cps: if cp.data.config.ev == group_entry["id"]: grouped_cps.append(cp) + sorted_grouped_cps.extend(get_preferenced_chargepoint(grouped_cps)) if len(grouped_cps) > 0: - sorted_cps.append(grouped_cps) + sorted_cps.append(sorted_grouped_cps) return sorted_cps - def sort_cps_by_loadmanagement_prios_flat(self: LoadmanagementPrioProtocol, - filtered_cps: List[Chargepoint]) -> List[Chargepoint]: - sorted_cps = [] - for entry in self.data.get.loadmanagement_prios: - if entry["type"] == "vehicle": - for cp in filtered_cps: - if cp.data.config.ev == entry["id"]: - sorted_cps.append(cp) - elif entry["type"] == "group": - for group_entry in entry["children"]: - for cp in filtered_cps: - if cp.data.config.ev == group_entry["id"]: - sorted_cps.append(cp) - if len(sorted_cps) != len(filtered_cps): - raise ValueError( - "Fahrzeuge der Prioritätensteuerung konnten nicht korrekt den Ladepunkten zugeordnet werden.") - return sorted_cps + # def sort_cps_by_loadmanagement_prios_flat(self: LoadmanagementPrioProtocol, + # filtered_cps: List[Chargepoint]) -> List[Chargepoint]: + # sorted_cps = [] + # for entry in self.data.get.loadmanagement_prios: + # if entry["type"] == "vehicle": + # for cp in filtered_cps: + # if cp.data.config.ev == entry["id"]: + # sorted_cps.append(cp) + # elif entry["type"] == "group": + # for group_entry in entry["children"]: + # for cp in filtered_cps: + # if cp.data.config.ev == group_entry["id"]: + # sorted_cps.append(cp) + # if len(sorted_cps) != len(filtered_cps): + # raise ValueError( + # "Fahrzeuge der Prioritätensteuerung konnten nicht korrekt den Ladepunkten zugeordnet werden.") + # return sorted_cps diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index 19c335af50..845be3386d 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -142,46 +142,26 @@ def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], id="alle LP haben unterschiedliche Fahrzeuge mit Gruppe"), ] ) -def test_sort_cps_by_loadmanagement_prios_flat(config_ev_list: List[int], - loadmanagement_prios: List[Dict], - sorted_cp_ids: List[int]): +def test_sort_cps_by_loadmanagement_prios_nested(config_ev_list: List[int], + loadmanagement_prios: List[Dict], + sorted_cp_ids: List[int]): # setup cp1 = Chargepoint(1, None) cp1.data.config.ev = config_ev_list[0] + cp1.data.control_parameter.required_current = 8 cp2 = Chargepoint(2, None) cp2.data.config.ev = config_ev_list[1] + cp2.data.control_parameter.required_current = 7 cp3 = Chargepoint(3, None) cp3.data.config.ev = config_ev_list[2] + cp3.data.control_parameter.required_current = 6 c = CounterAll() c.data.get.loadmanagement_prios = loadmanagement_prios # execution - sorted_cps = c.sort_cps_by_loadmanagement_prios_flat([cp1, cp2, cp3]) + sorted_cps = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) # assert for i, cp in enumerate(sorted_cps): assert cp.num == sorted_cp_ids[i] - - -def test_sort_cps_by_loadmanagement_prios_nested(): - # setup - cp1 = Chargepoint(1, None) - cp1.data.config.ev = 1 - cp2 = Chargepoint(2, None) - cp2.data.config.ev = 2 - cp3 = Chargepoint(3, None) - cp3.data.config.ev = 3 - - c = CounterAll() - c.data.get.loadmanagement_prios = [{"type": "vehicle", "id": 3}, { - "type": "group", - "label": "Wichtige Fahrzeuge", - "children": [{"type": "vehicle", "id": 1}, { - "type": "vehicle", "id": 2}]}] - - # execution - sorted_cps = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) - - # assert - assert sorted_cps == [[cp3], [cp1, cp2]] From e8790ad6d5acd0a5cf2fb7e70813c93acd54a073 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 13 Apr 2026 15:58:51 +0200 Subject: [PATCH 6/8] fixes --- .../control/algorithm/additional_current.py | 3 +- .../algorithm/integration_test/conftest.py | 8 ++ packages/control/algorithm/min_current.py | 4 +- .../counter_all/loadmanagement_prio.py | 10 +- .../counter_all/loadmanagement_prio_test.py | 120 ++++++++++++------ packages/helpermodules/command.py | 6 + packages/helpermodules/setdata.py | 3 +- packages/helpermodules/update_config.py | 2 + 8 files changed, 110 insertions(+), 46 deletions(-) diff --git a/packages/control/algorithm/additional_current.py b/packages/control/algorithm/additional_current.py index 3e13202c39..87893fa10e 100644 --- a/packages/control/algorithm/additional_current.py +++ b/packages/control/algorithm/additional_current.py @@ -25,7 +25,8 @@ def set_additional_current(self) -> None: if preferenced_chargepoints: common.update_raw_data(preferenced_chargepoints) log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") - for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(preferenced_chargepoints): + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios( + preferenced_chargepoints): missing_currents, counts = common.get_missing_currents_left(group) available_currents, limit = Loadmanagement().get_available_currents(missing_currents, counter, cp) log.debug(f"cp {cp.num} available currents {available_currents} missing currents " diff --git a/packages/control/algorithm/integration_test/conftest.py b/packages/control/algorithm/integration_test/conftest.py index 8ea170292a..bb02b7c782 100644 --- a/packages/control/algorithm/integration_test/conftest.py +++ b/packages/control/algorithm/integration_test/conftest.py @@ -27,6 +27,7 @@ def data_() -> None: for i in range(3, 6): data.data.cp_data[f"cp{i}"].template = CpTemplate() data.data.cp_data[f"cp{i}"].data.config.phase_1 = i-2 + data.data.cp_data[f"cp{i}"].data.config.ev = i data.data.cp_data[f"cp{i}"].data.set.charging_ev_data = Ev(i) data.data.cp_data[f"cp{i}"].data.set.charging_ev_data.ev_template.data.max_current_single_phase = 32 data.data.cp_data[f"cp{i}"].data.get.plug_state = True @@ -49,6 +50,13 @@ def data_() -> None: data.data.counter_data["counter6"].data.config.max_total_power = 11000 data.data.counter_all_data = CounterAll() data.data.counter_all_data.data.get.hierarchy = NESTED_HIERARCHY + data.data.counter_all_data.data.get.loadmanagement_prios = [{ + "type": "group", + "label": "Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 4}, {"type": "vehicle", "id": 5} + ] + }] data.data.counter_all_data.data.config.consider_less_charging = True data.data.io_actions = IoActions() diff --git a/packages/control/algorithm/min_current.py b/packages/control/algorithm/min_current.py index b21279ea02..634404d7db 100644 --- a/packages/control/algorithm/min_current.py +++ b/packages/control/algorithm/min_current.py @@ -3,7 +3,6 @@ from control import data from control.algorithm import common from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_MIN_CURRENT, CONSIDERED_CHARGE_MODES_PV_ONLY -from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state import ChargepointState from control.loadmanagement import Loadmanagement from control.algorithm.filter_chargepoints import get_chargepoints_by_mode_and_counter @@ -22,7 +21,8 @@ def set_min_current(self) -> None: if preferenced_chargepoints: log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") common.update_raw_data(preferenced_chargepoints, diff_to_zero=True) - for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios(preferenced_chargepoints): + for cp, group in data.data.counter_all_data.generator_cps_by_loadmanagement_prios( + preferenced_chargepoints): missing_currents, counts = common.get_min_current(cp) if max(missing_currents) > 0: available_currents, limit = Loadmanagement().get_available_currents( diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index 667c1aa269..522328d1bf 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -1,6 +1,5 @@ import logging -from pprint import pprint -from typing import Dict, Generator, Iterable, List, Tuple +from typing import Dict, Generator, List, Tuple from control.algorithm.filter_chargepoints import get_preferenced_chargepoint from control.chargepoint.chargepoint import Chargepoint @@ -37,7 +36,7 @@ def generator_cps_by_loadmanagement_prios( filtered_cps: List[Chargepoint]) -> Generator[Tuple[Chargepoint, List[Chargepoint]], None, None]: sorted_cps = self.sort_cps_by_loadmanagement_prios_nested(filtered_cps) log.debug("Ladepunkte sortiert nach Prioritätensteuerung: ") - log.debug(pprint({[f"LP {cp.num}" for group in sorted_cps for cp in group]})) + log.debug([[f"LP {cp.num}" for cp in group] for group in sorted_cps]) for group in sorted_cps: cp: Chargepoint for cp in group: @@ -56,13 +55,14 @@ def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, sorted_grouped_cps = get_preferenced_chargepoint(grouped_cps) sorted_cps.append(sorted_grouped_cps) elif entry["type"] == "group": - grouped_cps, sorted_grouped_cps = [], [] + sorted_grouped_cps = [] for group_entry in entry["children"]: + grouped_cps = [] for cp in filtered_cps: if cp.data.config.ev == group_entry["id"]: grouped_cps.append(cp) sorted_grouped_cps.extend(get_preferenced_chargepoint(grouped_cps)) - if len(grouped_cps) > 0: + if len(sorted_grouped_cps) > 0: sorted_cps.append(sorted_grouped_cps) return sorted_cps diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index 845be3386d..e90ccd9735 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -7,6 +7,27 @@ from control.counter_all.counter_all import CounterAll +@pytest.fixture +def cp1(): + cp = Chargepoint(1, None) + cp.data.control_parameter.required_current = 8 + return cp + + +@pytest.fixture +def cp2(): + cp = Chargepoint(2, None) + cp.data.control_parameter.required_current = 7 + return cp + + +@pytest.fixture +def cp3(): + cp = Chargepoint(3, None) + cp.data.control_parameter.required_current = 6 + return cp + + @pytest.mark.parametrize( "loadmanagement_prios, id, type, expected_loadmanagement_prios", [ @@ -121,47 +142,72 @@ def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], assert c.data.get.loadmanagement_prios == expected_loadmanagement_prios -@pytest.mark.parametrize( - "config_ev_list, loadmanagement_prios, sorted_cp_ids", - [ - pytest.param([0]*3, - [{"type": "vehicle", "id": 2}, {"type": "vehicle", "id": 0}], - [1, 2, 3], - id="alle LP haben das gleiche Fahrzeug zugeordnet"), - pytest.param([1, 2, 3], - [{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 1}, {"type": "vehicle", "id": 2}], - [3, 1, 2], - id="alle LP haben unterschiedliche Fahrzeuge"), - pytest.param([1, 2, 3], - [{"type": "vehicle", "id": 3}, { - "type": "group", - "label": "Wichtige Fahrzeuge", - "children": [{"type": "vehicle", "id": 1}, { - "type": "vehicle", "id": 2}]}], - [3, 1, 2], - id="alle LP haben unterschiedliche Fahrzeuge mit Gruppe"), +def test_sort_cps_by_loadmanagement_prios_nested_same_vehicle(cp1, cp2, cp3): + """Alle LP haben das gleiche Fahrzeug zugeordnet""" + # setup + cp1.data.config.ev = 0 + cp2.data.config.ev = 0 + cp3.data.config.ev = 0 + + c = CounterAll() + c.data.get.loadmanagement_prios = [{"type": "vehicle", "id": 2}, {"type": "vehicle", "id": 0}] + + # execution + result = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) + + # assert - eine Gruppe mit allen CPs (sortiert nach required_current) + assert len(result) == 1 + assert result[0] == [cp3, cp2, cp1] # direkte Objektvergleiche! + + +def test_sort_cps_by_loadmanagement_prios_nested_different_vehicles(cp1, cp2, cp3): + """Alle LP haben unterschiedliche Fahrzeuge""" + # setup + cp1.data.config.ev = 1 + cp2.data.config.ev = 2 + cp3.data.config.ev = 3 + + c = CounterAll() + c.data.get.loadmanagement_prios = [ + {"type": "vehicle", "id": 3}, + {"type": "vehicle", "id": 1}, + {"type": "vehicle", "id": 2} ] -) -def test_sort_cps_by_loadmanagement_prios_nested(config_ev_list: List[int], - loadmanagement_prios: List[Dict], - sorted_cp_ids: List[int]): + + # execution + result = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) + + # assert - drei separate Gruppen + assert len(result) == 3 + assert result[0] == [cp3] # vehicle id=3 + assert result[1] == [cp1] # vehicle id=1 + assert result[2] == [cp2] # vehicle id=2 + + +def test_sort_cps_by_loadmanagement_prios_nested_with_group(cp1, cp2, cp3): + """LP mit unterschiedlichen Fahrzeugen, einige in Gruppe""" # setup - cp1 = Chargepoint(1, None) - cp1.data.config.ev = config_ev_list[0] - cp1.data.control_parameter.required_current = 8 - cp2 = Chargepoint(2, None) - cp2.data.config.ev = config_ev_list[1] - cp2.data.control_parameter.required_current = 7 - cp3 = Chargepoint(3, None) - cp3.data.config.ev = config_ev_list[2] - cp3.data.control_parameter.required_current = 6 + cp1.data.config.ev = 1 + cp2.data.config.ev = 2 + cp3.data.config.ev = 3 c = CounterAll() - c.data.get.loadmanagement_prios = loadmanagement_prios + c.data.get.loadmanagement_prios = [ + {"type": "vehicle", "id": 3}, + { + "type": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 1}, + {"type": "vehicle", "id": 2} + ] + } + ] # execution - sorted_cps = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) + result = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) - # assert - for i, cp in enumerate(sorted_cps): - assert cp.num == sorted_cp_ids[i] + # assert - zwei Gruppen + assert len(result) == 2 + assert result[0] == [cp3] # vehicle id=3 einzeln + assert result[1] == [cp1, cp2] # vehicles id=1,2 in der Gruppe diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index bf2bf0386d..a768e58e0d 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -773,6 +773,9 @@ def addVehicle(self, connection_id: str, payload: dict) -> None: # add ACL roles for vehicle access, if user management is active if SubData.system_data["system"].data["security"]["user_management_active"]: add_acl_role("vehicle--access", new_id) + data.data.counter_all_data.add_loadmanagement_prio_item("vehicle", new_id) + Pub().pub("openWB/set/counter/get/loadmanagement_prios", + data.data.counter_all_data.data.get.loadmanagement_prios) pub_user_message(payload, connection_id, f'Neues EV mit ID \'{new_id}\' hinzugefügt.', MessageType.SUCCESS) def removeVehicle(self, connection_id: str, payload: dict) -> None: @@ -788,6 +791,9 @@ def removeVehicle(self, connection_id: str, payload: dict) -> None: if SubData.system_data["system"].data["security"]["user_management_active"]: remove_acl_role("vehicle--access", payload["data"]["id"]) remove_acl_role("vehicle--write-access", payload["data"]["id"]) + data.data.counter_all_data.remove_loadmanagement_prio_item(payload["data"]["id"]) + Pub().pub("openWB/set/counter/get/loadmanagement_prios", + data.data.counter_all_data.data.get.loadmanagement_prios) pub_user_message( payload, connection_id, f'EV mit ID \'{payload["data"]["id"]}\' gelöscht.', MessageType.SUCCESS) diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 6df640429a..e5dc648193 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -938,7 +938,8 @@ def process_counter_topic(self, msg: mqtt.MQTTMessage): "openWB/set/counter/set/daily_yield_home_consumption" in msg.topic or "openWB/set/counter/set/disengageable_smarthome_power" in msg.topic): self._validate_value(msg, float, [(0, float("inf"))]) - elif "openWB/set/counter/get/hierarchy" in msg.topic: + 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) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 29f5a6dd44..301b82e15c 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -187,6 +187,7 @@ class UpdateConfig: "^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$", "^openWB/counter/set/imported_home_consumption$", "^openWB/counter/set/invalid_home_consumption$", @@ -569,6 +570,7 @@ class UpdateConfig: ("openWB/chargepoint/get/power", 0), ("openWB/chargepoint/template/0", get_chargepoint_template_default()), ("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"), From 110c159cfcc139880a31b87a4abeab22e8cd6362 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Mon, 13 Apr 2026 16:33:43 +0200 Subject: [PATCH 7/8] fixes --- .../counter_all/loadmanagement_prio.py | 6 ++- .../counter_all/loadmanagement_prio_test.py | 52 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index 522328d1bf..2ac9cbc403 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -39,8 +39,10 @@ def generator_cps_by_loadmanagement_prios( log.debug([[f"LP {cp.num}" for cp in group] for group in sorted_cps]) for group in sorted_cps: cp: Chargepoint - for cp in group: - yield cp, group + while len(group) > 0: + cp = group[0] + yield cp, group.copy() + group.pop(0) def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index e90ccd9735..f6a7c01433 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -1,5 +1,6 @@ from typing import Dict, List +from unittest.mock import Mock import pytest @@ -211,3 +212,54 @@ def test_sort_cps_by_loadmanagement_prios_nested_with_group(cp1, cp2, cp3): assert len(result) == 2 assert result[0] == [cp3] # vehicle id=3 einzeln assert result[1] == [cp1, cp2] # vehicles id=1,2 in der Gruppe + + +def test_generator_cps_by_loadmanagement_prios(cp1, cp2, cp3, monkeypatch): + # setup + mock_sort_cps = Mock(return_value=[[cp3], [cp1, cp2]]) + monkeypatch.setattr(CounterAll, "sort_cps_by_loadmanagement_prios_nested", mock_sort_cps) + + c = CounterAll() + + # execution + result = list(c.generator_cps_by_loadmanagement_prios([cp1, cp2, cp3])) + + # assert + assert len(result) == 3 + assert result[0] == (cp3, [cp3]) + assert result[1] == (cp1, [cp1, cp2]) + assert result[2] == (cp2, [cp2]) + + +def test_generator_cps_by_loadmanagement_prios_flat(cp1, cp2, cp3, monkeypatch): + # setup + mock_sort_cps = Mock(return_value=[[cp3], [cp1], [cp2]]) + monkeypatch.setattr(CounterAll, "sort_cps_by_loadmanagement_prios_nested", mock_sort_cps) + + c = CounterAll() + + # execution + result = list(c.generator_cps_by_loadmanagement_prios([cp1, cp2, cp3])) + + # assert + assert len(result) == 3 + assert result[0] == (cp3, [cp3]) + assert result[1] == (cp1, [cp1]) + assert result[2] == (cp2, [cp2]) + + +def test_generator_cps_by_loadmanagement_prios_one_group(cp1, cp2, cp3, monkeypatch): + # setup + mock_sort_cps = Mock(return_value=[[cp3, cp1, cp2]]) + monkeypatch.setattr(CounterAll, "sort_cps_by_loadmanagement_prios_nested", mock_sort_cps) + + c = CounterAll() + + # execution + result = list(c.generator_cps_by_loadmanagement_prios([cp1, cp2, cp3])) + + # assert + assert len(result) == 3 + assert result[0] == (cp3, [cp3, cp1, cp2]) + assert result[1] == (cp1, [cp1, cp2]) + assert result[2] == (cp2, [cp2]) From 42ad4be94672d8e9f751547be8f23a269771ea14 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 29 Apr 2026 15:28:05 +0200 Subject: [PATCH 8/8] update openwb_local.conf --- data/config/mosquitto/local/openwb_local.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/data/config/mosquitto/local/openwb_local.conf b/data/config/mosquitto/local/openwb_local.conf index f540b3dcf1..8d37590972 100644 --- a/data/config/mosquitto/local/openwb_local.conf +++ b/data/config/mosquitto/local/openwb_local.conf @@ -1,4 +1,4 @@ -# openwb-version:19 +# openwb-version:20 listener 1886 localhost allow_anonymous true @@ -52,7 +52,7 @@ topic openWB/optional/# out 2 topic openWB/counter/config/# out 2 topic openWB/counter/set/# out 2 -topic openWB/counter/get/hierarchy out 2 +topic openWB/counter/get/# out 2 topic openWB/counter/+/module/# out 2 topic openWB/counter/+/config/# out 2 topic openWB/counter/+/get/# out 2