From 64fd26c284b2c0f07c8a90283464a28892df50ad Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 8 Jul 2026 14:15:35 +0200 Subject: [PATCH 01/21] Squashed commit of the following: feature_loadmanagement_prio_list commit a8dc062b108fe5e01e2ac5e77ff74d0fd2bd2dc6 Author: LKuemmel Date: Tue Jun 16 15:29:58 2026 +0200 falke8 commit accc10c070ea3c641f1707cd24d11c9ba34e0170 Author: LKuemmel Date: Tue Jun 2 12:22:21 2026 +0200 fix missing import commit e831cfb33ee5fa910501e52ea5a123ed181e2ef4 Author: LKuemmel Date: Tue Jun 2 12:19:27 2026 +0200 update config for prio list commit 41ef889b723cfe444220664e3d9eadae401abab0 Author: LKuemmel Date: Fri May 29 12:58:59 2026 +0200 add loadmanagement prios to user management commit 095b8f8baf95eaf70a3896bc2150dbbbfcb18e4e Author: LKuemmel Date: Wed Apr 29 15:28:05 2026 +0200 update openwb_local.conf commit 3fade667c1f5d723a523e7cf69fbeae5a8aeecd8 Author: LKuemmel Date: Mon Apr 13 16:33:43 2026 +0200 fixes commit b5a293c2295f6830e01f01fccd43915e59e69142 Author: LKuemmel Date: Mon Apr 13 15:58:51 2026 +0200 fixes commit 81adc29fd13cb5f696682620b9adc5a300eb7621 Author: LKuemmel Date: Mon Apr 13 14:33:27 2026 +0200 draft commit 65a8d8f8c746b4930aa8492e1a88caca43e02284 Author: LKuemmel Date: Mon Apr 13 09:53:38 2026 +0200 fix pytest commit 2e85b83bbc7616bfa98555713e8b384500ce2f27 Author: LKuemmel Date: Fri Apr 10 16:06:59 2026 +0200 loadmanagement prio operations commit 914826c61b2837973ee9c76990572d083c5a3cf6 Author: LKuemmel Date: Fri Apr 10 07:57:21 2026 +0200 counter all refactoring, mixin commit 1237c187a9b2d77eec19ef392e71697758355893 Author: LKuemmel Date: Thu Apr 9 15:09:15 2026 +0200 move files --- data/config/mosquitto/local/openwb_local.conf | 4 +- .../public/default-dynamic-security.json | 12 + packages/conftest.py | 2 +- .../control/algorithm/additional_current.py | 8 +- packages/control/algorithm/bidi_charging.py | 6 +- packages/control/algorithm/common_test.py | 2 +- .../control/algorithm/filter_chargepoints.py | 4 +- .../algorithm/filter_chargepoints_test.py | 4 +- .../algorithm/integration_test/conftest.py | 10 +- packages/control/algorithm/min_current.py | 5 +- .../control/algorithm/surplus_controlled.py | 6 +- packages/control/counter_all.py | 497 ------------------ packages/control/counter_all/counter_all.py | 152 ++++++ .../control/counter_all/counter_all_data.py | 109 ++++ .../counter_home_consumption_test.py | 2 +- packages/control/counter_all/hierarchy.py | 317 +++++++++++ .../{ => counter_all}/hierarchy_test.py | 2 +- .../counter_all/loadmanagement_prio.py | 87 +++ .../counter_all/loadmanagement_prio_test.py | 265 ++++++++++ packages/control/data.py | 2 +- packages/control/ev/charge_template.py | 2 +- packages/helpermodules/command.py | 8 +- packages/helpermodules/setdata.py | 3 +- packages/helpermodules/subdata.py | 3 +- packages/helpermodules/update_config.py | 36 +- .../modules/common/store/_counter_test.py | 2 +- .../modules/common/store/_inverter_test.py | 2 +- .../devices/generic/virtual/counter_test.py | 2 +- 28 files changed, 1019 insertions(+), 535 deletions(-) delete mode 100644 packages/control/counter_all.py create mode 100644 packages/control/counter_all/counter_all.py create mode 100644 packages/control/counter_all/counter_all_data.py rename packages/control/{ => counter_all}/counter_home_consumption_test.py (98%) create mode 100644 packages/control/counter_all/hierarchy.py rename packages/control/{ => counter_all}/hierarchy_test.py (99%) create mode 100644 packages/control/counter_all/loadmanagement_prio.py create mode 100644 packages/control/counter_all/loadmanagement_prio_test.py 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 diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 871ee46abc..2113ee99a1 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -1508,6 +1508,12 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/counter/get/loadmanagement_prios", + "priority": 0, + "allow": true + }, { "acltype": "publishClientSend", "topic": "openWB/set/counter/+/config/max_power_errorcase", @@ -1562,6 +1568,12 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientReceive", + "topic": "openWB/counter/get/loadmanagement_prios", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/system/device/+/component/+/config", 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/additional_current.py b/packages/control/algorithm/additional_current.py index 76ee2d0031..87893fa10e 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,9 @@ 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 +41,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/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.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 def2524811..68078b266b 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 @@ -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/integration_test/conftest.py b/packages/control/algorithm/integration_test/conftest.py index cbc98f72ff..d52abb41ea 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 @@ -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 @@ -50,6 +51,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() data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches = True diff --git a/packages/control/algorithm/min_current.py b/packages/control/algorithm/min_current.py index 48ce9eba6a..634404d7db 100644 --- a/packages/control/algorithm/min_current.py +++ b/packages/control/algorithm/min_current.py @@ -21,8 +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) - 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 1a39d27678..dc5d3fd36d 100644 --- a/packages/control/algorithm/surplus_controlled.py +++ b/packages/control/algorithm/surplus_controlled.py @@ -53,9 +53,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), @@ -90,7 +89,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.py b/packages/control/counter_all.py deleted file mode 100644 index 89f214b773..0000000000 --- a/packages/control/counter_all.py +++ /dev/null @@ -1,497 +0,0 @@ -"""Zähler-Logik -""" -import copy -from dataclasses import dataclass, field -import logging -import re -from typing import 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 - -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"}) - - -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: - 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("", "", ComponentType.COUNTER.value) - self.sim_counter.topic = "openWB/set/counter/set/" - - def get_evu_counter(self) -> Counter: - return data.data.counter_data[f"counter{self.get_id_evu_counter()}"] - - def get_evu_counter_str(self) -> str: - return f"counter{self.get_id_evu_counter()}" - - def get_id_evu_counter(self) -> int: - try: - if ComponentType.COUNTER.value == self.data.get.hierarchy[0]["type"]: - return self.data.get.hierarchy[0]['id'] - else: - raise TypeError - except Exception: - log.error( - "Ohne Konfiguration eines EVU-Zählers an der Spitze der Hierarchie ist keine Regelung und keine Ladung " - "möglich.") - raise - - 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_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 - hc_counter_data.get.fault_str = ("Hinweis: Es gibt mehr Stromerzeuger im Haus als in der openWB " - "eingetragen sind. Der Hausverbrauch kann nicht korrekt berechnet " - "werden. Dies hat auf die PV-Überschussladung keine negativen " - "Auswirkungen.") - if self.data.set.invalid_home_consumption < 3: - self.data.set.invalid_home_consumption += 1 - return - else: - home_consumption = 0 - else: - self.data.set.invalid_home_consumption = 0 - self.data.set.home_consumption = home_consumption - imported, _ = self.sim_counter.sim_count(self.data.set.home_consumption) - self.data.set.imported_home_consumption = imported - except Exception: - log.exception("Fehler in der allgemeinen Zähler-Klasse") - - EVU_IS_HC_COUNTER_ERROR = ("Der EVU-Zähler kann nicht als Quelle für den Hausverbrauch verwendet werden. Meist ist " - "der Zähler am EVU-Punkt installiert, dann muss im Lastmanagement unter Hausverbrauch" - " 'von openWB berechnen' ausgewählt werden. Wenn der Zähler im Hausverbrauchszweig " - "installiert ist, einen virtuellen Zähler anlegen und im Lastmanagement ganz links " - "anordnen.") - - def _validate_home_consumption_counter(self): - if self.data.config.home_consumption_source_id is not None: - if self.data.config.home_consumption_source_id == self.get_id_evu_counter(): - hc_counter_data = data.data.counter_data[self.get_evu_counter_str()].data - hc_counter_data.get.fault_state = FaultStateLevel.ERROR.value - hc_counter_data.get.fault_str = self.EVU_IS_HC_COUNTER_ERROR - evu_counter = self.get_id_evu_counter() - Pub().pub(f"openWB/set/counter/{evu_counter}/get/fault_state", - hc_counter_data.get.fault_state) - Pub().pub(f"openWB/set/counter/{evu_counter}/get/fault_str", - hc_counter_data.get.fault_str) - raise Exception(self.EVU_IS_HC_COUNTER_ERROR) - - def _calc_home_consumption(self) -> Tuple[float, List]: - power = 0 - if self.data.config.home_consumption_source_id is None: - id_source = self.get_id_evu_counter() - 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 - 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 - - def _add_hybrid_bat(self, id: int) -> List: - elements = [] - inverter_children = self.get_entry_of_element(id)["children"] - for child in inverter_children: - if child["type"] == ComponentType.BAT.value: - elements.append(child) - return elements - - def get_elements_for_downstream_calculation(self, id: int): - """returns a list of elements that are relevant for the calculation of the counter values based on the - downstream components, eg home consumption or virtual counter.""" - elements = copy.deepcopy(self.get_entry_of_element(id)["children"]) - elements_to_sum_up = elements - for element in elements: - if element["type"] == ComponentType.INVERTER.value: - 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[str, List[Dict[str, Union[int, str]]]]: - 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: - if item["id"] > max_id: - max_id = item["id"] - if len(item["children"]) != 0: - max_id = get_max_id_in_hierarchy(item["children"], max_id) - else: - return max_id - - -def get_counter_default_config(): - return {"max_currents": [16, 16, 16], - "max_total_power": 11000} diff --git a/packages/control/counter_all/counter_all.py b/packages/control/counter_all/counter_all.py new file mode 100644 index 0000000000..82faab0667 --- /dev/null +++ b/packages/control/counter_all/counter_all.py @@ -0,0 +1,152 @@ +"""Zähler-Logik +""" +import copy +import logging +from typing import List, Tuple + +from control import data +from control.counter import Counter +from control.counter_all.counter_all_data import CounterAllData +from control.counter_all.hierarchy import HierarchyMixin +from control.counter_all.loadmanagement_prio import LoadmanagementPrioMixin +from helpermodules.pub import Pub +from modules.common.component_type import ComponentType +from modules.common.fault_state import FaultStateLevel +from modules.common.simcount import SimCounter + +log = logging.getLogger(__name__) + + +class CounterAll(HierarchyMixin, LoadmanagementPrioMixin): + MISSING_EVU_COUNTER = "Bitte erst einen EVU-Zähler konfigurieren." + + def __init__(self): + self.data = CounterAllData() + self.sim_counter = SimCounter("", "", ComponentType.COUNTER.value) + self.sim_counter.topic = "openWB/set/counter/set/" + + def get_evu_counter(self) -> Counter: + return data.data.counter_data[f"counter{self.get_id_evu_counter()}"] + + def get_evu_counter_str(self) -> str: + return f"counter{self.get_id_evu_counter()}" + + def get_id_evu_counter(self) -> int: + try: + if ComponentType.COUNTER.value == self.data.get.hierarchy[0]["type"]: + return self.data.get.hierarchy[0]['id'] + else: + raise TypeError + except Exception: + log.error( + "Ohne Konfiguration eines EVU-Zählers an der Spitze der Hierarchie ist keine Regelung und keine Ladung " + "möglich.") + raise + + 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_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 + hc_counter_data.get.fault_str = ("Hinweis: Es gibt mehr Stromerzeuger im Haus als in der openWB " + "eingetragen sind. Der Hausverbrauch kann nicht korrekt berechnet " + "werden. Dies hat auf die PV-Überschussladung keine negativen " + "Auswirkungen.") + if self.data.set.invalid_home_consumption < 3: + self.data.set.invalid_home_consumption += 1 + return + else: + home_consumption = 0 + else: + self.data.set.invalid_home_consumption = 0 + self.data.set.home_consumption = home_consumption + imported, _ = self.sim_counter.sim_count(self.data.set.home_consumption) + self.data.set.imported_home_consumption = imported + except Exception: + log.exception("Fehler in der allgemeinen Zähler-Klasse") + + EVU_IS_HC_COUNTER_ERROR = ("Der EVU-Zähler kann nicht als Quelle für den Hausverbrauch verwendet werden. Meist ist " + "der Zähler am EVU-Punkt installiert, dann muss im Lastmanagement unter Hausverbrauch" + " 'von openWB berechnen' ausgewählt werden. Wenn der Zähler im Hausverbrauchszweig " + "installiert ist, einen virtuellen Zähler anlegen und im Lastmanagement ganz links " + "anordnen.") + + def _validate_home_consumption_counter(self): + if self.data.config.home_consumption_source_id is not None: + if self.data.config.home_consumption_source_id == self.get_id_evu_counter(): + hc_counter_data = data.data.counter_data[self.get_evu_counter_str()].data + hc_counter_data.get.fault_state = FaultStateLevel.ERROR.value + hc_counter_data.get.fault_str = self.EVU_IS_HC_COUNTER_ERROR + evu_counter = self.get_id_evu_counter() + Pub().pub(f"openWB/set/counter/{evu_counter}/get/fault_state", + hc_counter_data.get.fault_state) + Pub().pub(f"openWB/set/counter/{evu_counter}/get/fault_str", + hc_counter_data.get.fault_str) + raise Exception(self.EVU_IS_HC_COUNTER_ERROR) + + def _calc_home_consumption(self) -> Tuple[float, List]: + power = 0 + if self.data.config.home_consumption_source_id is None: + id_source = self.get_id_evu_counter() + 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 + 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 + + def _add_hybrid_bat(self, id: int) -> List: + elements = [] + inverter_children = self.get_entry_of_element(id)["children"] + for child in inverter_children: + if child["type"] == ComponentType.BAT.value: + elements.append(child) + return elements + + def get_elements_for_downstream_calculation(self, id: int): + """returns a list of elements that are relevant for the calculation of the counter values based on the + downstream components, eg home consumption or virtual counter.""" + elements = copy.deepcopy(self.get_entry_of_element(id)["children"]) + elements_to_sum_up = elements + for element in elements: + if element["type"] == ComponentType.INVERTER.value: + elements_to_sum_up.extend(self._add_hybrid_bat(element['id'])) + return elements_to_sum_up + + +def get_max_id_in_hierarchy(current_entry: List, max_id: int) -> int: + for item in current_entry: + if item["id"] > max_id: + max_id = item["id"] + if len(item["children"]) != 0: + max_id = get_max_id_in_hierarchy(item["children"], max_id) + else: + return max_id + + +def get_counter_default_config(): + return {"max_currents": [16, 16, 16], + "max_total_power": 11000} 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..c137c5add5 --- /dev/null +++ b/packages/control/counter_all/counter_all_data.py @@ -0,0 +1,109 @@ +from dataclasses import dataclass, field +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 +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: ... + + +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 generator_cps_by_loadmanagement_prios( + self, + filtered_cps: List[Chargepoint]) -> Generator[Tuple[Chargepoint, List[Chargepoint]], None, None]: ... 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/counter_all/hierarchy.py b/packages/control/counter_all/hierarchy.py new file mode 100644 index 0000000000..109e3b0397 --- /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[str, List[Dict[str, Union[int, str]]]]: + 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/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/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py new file mode 100644 index 0000000000..2ac9cbc403 --- /dev/null +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -0,0 +1,87 @@ +import logging +from typing import Dict, Generator, 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 + + +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 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([[f"LP {cp.num}" for cp in group] for group in sorted_cps]) + for group in sorted_cps: + cp: Chargepoint + 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]]: + 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_grouped_cps = get_preferenced_chargepoint(grouped_cps) + sorted_cps.append(sorted_grouped_cps) + elif entry["type"] == "group": + 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(sorted_grouped_cps) > 0: + 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 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..f6a7c01433 --- /dev/null +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -0,0 +1,265 @@ + +from typing import Dict, List +from unittest.mock import Mock + +import pytest + +from control.chargepoint.chargepoint import Chargepoint +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", + [ + 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 + + +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} + ] + + # 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.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": "group", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 1}, + {"type": "vehicle", "id": 2} + ] + } + ] + + # execution + result = c.sort_cps_by_loadmanagement_prios_nested([cp1, cp2, cp3]) + + # 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 + + +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]) diff --git a/packages/control/data.py b/packages/control/data.py index 298ced12f5..5d73d59ec1 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/control/ev/charge_template.py b/packages/control/ev/charge_template.py index 20f7ba88bb..41d0b0df05 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -110,7 +110,7 @@ def chargemode_factory() -> Chargemode: class ChargeTemplateData: id: int = 0 name: str = "Lade-Profil" - prio: bool = False + prio: bool = False # OBSOLET seit 2.3.0 load_default: bool = False time_charging: TimeCharging = field(default_factory=time_charging_factory) chargemode: Chargemode = field(default_factory=chargemode_factory) diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 2b429f135e..55998e30e5 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -39,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, counter, counter_all, pv +from control import bat, bridge, counter, counter_all, data, pv from control.ev import ev from modules.chargepoints.internal_openwb.chargepoint_module import ChargepointModule from modules.chargepoints.internal_openwb.config import InternalChargepointMode @@ -775,6 +775,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: @@ -790,6 +793,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 7973936181..ff9a2ddd36 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -931,7 +931,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/subdata.py b/packages/helpermodules/subdata.py index 4c59046940..503259077d 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 6fe83c3caf..d3a508740f 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -12,6 +12,7 @@ from typing import List, Optional from paho.mqtt.client import Client as MqttClient, MQTTMessage +from control.chargemode import Chargemode from control.limiting_value import LoadmanagementLimit import dataclass_utils @@ -34,7 +35,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, counter_all_data from control.bat_all import BatConsiderationMode from control.chargepoint.charging_type import ChargingType from control.counter import get_counter_default_config @@ -58,7 +59,7 @@ class UpdateConfig: - DATASTORE_VERSION = 135 + DATASTORE_VERSION = 136 valid_topic = [ "^openWB/bat/config/bat_control_activated$", @@ -187,6 +188,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$", @@ -586,8 +588,9 @@ 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/get/loadmanagement_prios", [{"type": "vehicle", "id": 0}]), + ("openWB/counter/config/consider_less_charging", counter_all_data.Config().consider_less_charging), + ("openWB/counter/config/home_consumption_source_id", counter_all_data.Config().home_consumption_source_id), ("openWB/vehicle/0/name", "Standard-Fahrzeug"), ("openWB/vehicle/0/color", DEFAULT_COLORS.VEHICLE.value), ("openWB/vehicle/0/info", {"manufacturer": None, "model": None}), @@ -3448,3 +3451,28 @@ def upgrade(topic: str, payload) -> None: return {topic: configuration_payload} self._loop_all_received_topics(upgrade) self._append_datastore_version(135) + + def upgrade_datastore_136(self) -> None: + CHARGEMODES = ((Chargemode.SCHEDULED_CHARGING.value, True), + (Chargemode.SCHEDULED_CHARGING.value, False), + (Chargemode.INSTANT_CHARGING.value, True), + (Chargemode.INSTANT_CHARGING.value, False), + (Chargemode.ECO_CHARGING.value, True), + (Chargemode.PV_CHARGING.value, True), + (Chargemode.PV_CHARGING.value, False), + (Chargemode.STOP.value, True), + (Chargemode.STOP.value, False),) + + def upgrade(topic: str, payload) -> None: + if re.search("openWB/vehicle/[0-9]+/charge_template", topic) is not None: + charge_template_id = decode_payload(payload) + charge_template = decode_payload( + self.all_received_topics[f"openWB/vehicle/template/charge_template/{charge_template_id}"]) + if charge_template["chargemode"]["selected"] == chargemode and charge_template["prio"] == prio: + loadmanagement_prios.append({"type": "vehicle", "id": int(get_index(topic))}) + + loadmanagement_prios = [] + for chargemode, prio in CHARGEMODES: + self._loop_all_received_topics(upgrade) + self.__update_topic("openWB/counter/get/loadmanagement_prios", loadmanagement_prios) + self._append_datastore_version(136) diff --git a/packages/modules/common/store/_counter_test.py b/packages/modules/common/store/_counter_test.py index 989e177c12..c238cb0fa7 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.component_type import ComponentType 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 300078c412ff53333c586b7f3d2f47806b3b61b2 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 8 Jul 2026 14:16:41 +0200 Subject: [PATCH 02/21] Squashed commit of the following: move_feed_in_limit commit cce183980e1e6d555b2826da9c6b6db08fac823a Author: LKuemmel Date: Fri May 29 13:41:27 2026 +0200 add topics to user management commit e4145437e59f0b9b5ea8e5cb162c6ed9ab0e2c35 Author: LKuemmel Date: Fri May 29 13:27:42 2026 +0200 move feed_in_limit and feed_in_yield --- .../public/default-dynamic-security.json | 16 +++++++-- .../control/algorithm/surplus_controlled.py | 21 ++--------- .../algorithm/surplus_controlled_test.py | 36 +++++-------------- packages/control/counter.py | 23 ++++++------ packages/control/counter_test.py | 4 +-- packages/control/ev/charge_template.py | 1 - packages/control/ev/ev.py | 4 +-- packages/control/general.py | 6 ++-- packages/control/loadmanagement.py | 17 ++++----- packages/control/loadmanagement_test.py | 4 +-- packages/helpermodules/create_debug.py | 7 ++-- packages/helpermodules/setdata.py | 3 +- packages/helpermodules/update_config.py | 26 ++++++++++++-- 13 files changed, 85 insertions(+), 83 deletions(-) diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index 2113ee99a1..fadc141c52 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -1051,7 +1051,13 @@ }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/feed_in_yield", + "topic": "openWB/set/general/chargemode_config/pv_charging/feed_in_limit", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/general/chargemode_config/feed_in_yield", "priority": 0, "allow": true }, @@ -1105,7 +1111,13 @@ }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/feed_in_yield", + "topic": "openWB/general/chargemode_config/pv_charging/feed_in_limit", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/chargemode_config/feed_in_yield", "priority": 0, "allow": true }, diff --git a/packages/control/algorithm/surplus_controlled.py b/packages/control/algorithm/surplus_controlled.py index dc5d3fd36d..c750fa2b10 100644 --- a/packages/control/algorithm/surplus_controlled.py +++ b/packages/control/algorithm/surplus_controlled.py @@ -33,12 +33,7 @@ def set_surplus_current(self) -> None: for mode_tuple, counter in common.mode_and_counter_generator(CONSIDERED_CHARGE_MODES_SURPLUS): preferenced_chargepoints, preferenced_cps_without_set_current = get_preferenced_chargepoint_charging( get_chargepoints_by_mode_and_counter(mode_tuple, f"counter{counter.num}")) - cp_with_feed_in, cp_without_feed_in = self.filter_by_feed_in_limit(preferenced_chargepoints) - if cp_without_feed_in: - self._set(cp_without_feed_in, 0, mode_tuple, counter) - feed_in_yield = data.data.general_data.data.chargemode_config.pv_charging.feed_in_yield - if cp_with_feed_in: - self._set(cp_with_feed_in, feed_in_yield, mode_tuple, counter) + self._set(preferenced_chargepoints, mode_tuple, counter) if preferenced_cps_without_set_current: for cp in preferenced_cps_without_set_current: cp.data.set.current = cp.data.set.target_current @@ -48,7 +43,6 @@ def set_surplus_current(self) -> None: def _set(self, chargepoints: List[Chargepoint], - feed_in_yield: Optional[int], mode_tuple: Tuple[Optional[str], str, bool], counter: Counter) -> None: log.info(f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {counter.num}") @@ -59,14 +53,13 @@ def _set(self, missing_currents, voltages_mean(cp.data.get.voltages), counter, - cp, - feed_in=feed_in_yield + cp ) # im PV-Laden wird der Strom immer durch die Leistung begrenzt if limit.limiting_value is not None and limit.limiting_value != LimitingValue.POWER: cp.data.control_parameter.limit = limit available_for_cp = common.available_current_for_cp(cp, counts, available_currents, missing_currents) - if counter.get_control_range_state(feed_in_yield) == ControlRangeState.MIDDLE: + if counter.get_control_range_state() == ControlRangeState.MIDDLE: pv_charging = data.data.general_data.data.chargemode_config.pv_charging dif_to_old_current = available_for_cp + cp.data.set.target_current - cp.data.set.current_prev # Wenn die Differenz zwischen altem und neuem Soll-Strom größer als der Regelbereich ist, trotzdem @@ -103,14 +96,6 @@ def _set_loadmangement_message(self, chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" f"{limit.message}") - # tested - def filter_by_feed_in_limit(self, chargepoints: List[Chargepoint]) -> Tuple[List[Chargepoint], List[Chargepoint]]: - cp_with_feed_in = list(filter(lambda cp: cp.data.set.charge_template.data.chargemode. - pv_charging.feed_in_limit is True, chargepoints)) - cp_without_feed_in = list(filter(lambda cp: cp.data.set.charge_template.data.chargemode. - pv_charging.feed_in_limit is False, chargepoints)) - return cp_with_feed_in, cp_without_feed_in - def _fix_deviating_evse_current(self, chargepoint: Chargepoint) -> float: """Wenn Autos nicht die volle Ladeleistung nutzen, wird unnötig eingespeist. Dann kann um den noch nicht genutzten Soll-Strom hochgeregelt werden. Wenn Fahrzeuge entgegen der Norm mehr Ladeleistung beziehen, als diff --git a/packages/control/algorithm/surplus_controlled_test.py b/packages/control/algorithm/surplus_controlled_test.py index a77060c8b0..b9d75339b2 100644 --- a/packages/control/algorithm/surplus_controlled_test.py +++ b/packages/control/algorithm/surplus_controlled_test.py @@ -30,29 +30,6 @@ def mock_cp3() -> Chargepoint: return Chargepoint(3, None) -@pytest.mark.parametrize("feed_in_limit_1, feed_in_limit_2, feed_in_limit_3, expected_sorted", - [pytest.param(True, True, True, ([mock_cp1, mock_cp2, mock_cp3], [])), - pytest.param(True, False, True, ([mock_cp1, mock_cp3], [mock_cp2])), - pytest.param(False, False, False, ([], [mock_cp1, mock_cp2, mock_cp3]))]) -def test_filter_by_feed_in_limit(feed_in_limit_1: bool, - feed_in_limit_2: bool, - feed_in_limit_3: bool, - expected_sorted: int): - # setup - def setup_cp(cp: Chargepoint, feed_in_limit: bool) -> Chargepoint: - cp.data = ChargepointData() - cp.data.set.charge_template.data.chargemode.pv_charging.feed_in_limit = feed_in_limit - return cp - - cp1 = setup_cp(mock_cp1, feed_in_limit_1) - cp2 = setup_cp(mock_cp2, feed_in_limit_2) - cp3 = setup_cp(mock_cp3, feed_in_limit_3) - # execution - cp_with_feed_in, cp_without_feed_in = SurplusControlled().filter_by_feed_in_limit([cp1, cp2, cp3]) - # evaluation - assert (cp_with_feed_in, cp_without_feed_in) == expected_sorted - - @pytest.mark.parametrize("new_current, expected_current", [ pytest.param(7, 10), @@ -134,16 +111,21 @@ def test_add_unused_evse_current(evse_current: float, @pytest.mark.parametrize( - "submode_1, submode_2, expected_chargepoints", + "submode_1, submode_2, expected_cp_indices", [ - pytest.param(Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, [mock_cp1, mock_cp2]), - pytest.param(Chargemode.INSTANT_CHARGING, Chargemode.PV_CHARGING, [mock_cp2]), + pytest.param(Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, [1, 2]), + pytest.param(Chargemode.INSTANT_CHARGING, Chargemode.PV_CHARGING, [2]), pytest.param(Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING, []), ]) def test_get_chargepoints_submode_pv_charging(submode_1: Chargemode, submode_2: Chargemode, - expected_chargepoints: List[Chargepoint]): + expected_cp_indices: List[int], + mock_cp1: Chargepoint, + mock_cp2: Chargepoint): # setup + cp_mapping = {1: mock_cp1, 2: mock_cp2} + expected_chargepoints = [cp_mapping[i] for i in expected_cp_indices] + def setup_cp(cp: Chargepoint, submode: str) -> Chargepoint: cp.data.set.charging_ev_data = Ev(0) cp.data.control_parameter.chargemode = Chargemode.PV_CHARGING diff --git a/packages/control/counter.py b/packages/control/counter.py index e7351b3cef..6253c0a28d 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -231,9 +231,13 @@ def calc_raw_surplus(self): ranged_surplus = surplus + self._control_range_offset() return ranged_surplus - def get_control_range_state(self, feed_in_yield: int) -> ControlRangeState: + def get_control_range_state(self) -> ControlRangeState: control_range_low = data.data.general_data.data.chargemode_config.pv_charging.control_range[0] control_range_high = data.data.general_data.data.chargemode_config.pv_charging.control_range[1] + if data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit: + feed_in_yield = data.data.general_data.data.chargemode_config.feed_in_yield + else: + feed_in_yield = 0 surplus = data.data.counter_all_data.get_evu_counter().data.get.power + feed_in_yield if control_range_low > surplus: return ControlRangeState.BELOW @@ -270,8 +274,8 @@ def calc_switch_on_power(self, chargepoint: Chargepoint) -> Tuple[float, float]: control_parameter = chargepoint.data.control_parameter pv_config = data.data.general_data.data.chargemode_config.pv_charging - if chargepoint.data.set.charge_template.data.chargemode.pv_charging.feed_in_limit: - threshold = pv_config.feed_in_yield + if data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit: + threshold = data.data.general_data.data.chargemode_config.feed_in_yield else: threshold = pv_config.switch_on_threshold*control_parameter.phases return surplus, threshold @@ -280,8 +284,7 @@ def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: try: message = None control_parameter = chargepoint.data.control_parameter - feed_in_limit = chargepoint.data.set.charge_template.data.chargemode.pv_charging.\ - feed_in_limit + feed_in_limit = data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit pv_config = data.data.general_data.data.chargemode_config.pv_charging timestamp_switch_on_off = control_parameter.timestamp_switch_on_off @@ -305,7 +308,7 @@ def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: message = self.SWITCH_ON_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( timestamp_switch_on_off, pv_config.switch_on_delay)) if feed_in_limit: - message += "Die Einspeisegrenze wird berücksichtigt." + message += " Die Einspeisegrenze wird berücksichtigt." control_parameter.state = ChargepointState.SWITCH_ON_DELAY else: # Einschaltschwelle nicht erreicht @@ -343,8 +346,8 @@ def switch_on_timer_expired(self, chargepoint: Chargepoint) -> None: msg = self.SWITCH_ON_EXPIRED.format(pv_config.switch_on_threshold) control_parameter.state = ChargepointState.WAIT_FOR_USING_PHASES - if chargepoint.data.set.charge_template.data.chargemode.pv_charging.feed_in_limit: - feed_in_yield = pv_config.feed_in_yield + if pv_config.feed_in_limit: + feed_in_yield = data.data.general_data.data.chargemode_config.feed_in_yield else: feed_in_yield = 0 ev_template = charging_ev_data.ev_template @@ -391,11 +394,11 @@ def switch_off_check_timer(self, chargepoint: Chargepoint) -> None: def calc_switch_off_threshold(self, chargepoint: Chargepoint) -> float: pv_config = data.data.general_data.data.chargemode_config.pv_charging control_parameter = chargepoint.data.control_parameter - if chargepoint.data.set.charge_template.data.chargemode.pv_charging.feed_in_limit: + if pv_config.feed_in_limit: # Der EVU-Überschuss muss ggf um die Einspeisegrenze bereinigt werden. # Wnn die Leistung nicht Einspeisegrenze + Einschaltschwelle erreicht, darf die Ladung nicht pulsieren. # Abschaltschwelle um Einschaltschwelle reduzieren. - threshold = (-data.data.general_data.data.chargemode_config.pv_charging.feed_in_yield + threshold = (-data.data.general_data.data.chargemode_config.feed_in_yield + pv_config.switch_on_threshold*control_parameter.phases) else: threshold = pv_config.switch_off_threshold diff --git a/packages/control/counter_test.py b/packages/control/counter_test.py index 4b3e59466a..c480715d1c 100644 --- a/packages/control/counter_test.py +++ b/packages/control/counter_test.py @@ -122,7 +122,7 @@ class Params: -681, 15000, 1652683250.0, ChargepointState.SWITCH_ON_DELAY, Counter.SWITCH_ON_FALLEN_BELOW.format(1500), None, 0), Params("Feed_in_limit, Timer starten", True, 0, 15001, 15000, None, ChargepointState.NO_CHARGING_ALLOWED, - Counter.SWITCH_ON_WAITING.format("30 Sek."), 1652683252.0, 1500), + Counter.SWITCH_ON_WAITING.format("30 Sek.")+" Die Einspeisegrenze wird berücksichtigt.", 1652683252.0, 1500), Params("Feed_in_limit, Einschaltschwelle nicht erreicht", True, 0, 14999, 15000, None, ChargepointState.NO_CHARGING_ALLOWED, Counter.SWITCH_ON_NOT_EXCEEDED.format(1500), None, 0), Params("Feed_in_limit, Einschaltschwelle läuft", True, 1500, 15001, @@ -141,7 +141,7 @@ def test_switch_on_threshold_reached(params: Params, caplog, general_data_fixtur cp.data.control_parameter.state = params.state cp.data.control_parameter.timestamp_switch_on_off = params.timestamp_switch_on_off ev.data.charge_template = ChargeTemplate() - ev.data.charge_template.data.chargemode.pv_charging.feed_in_limit = params.feed_in_limit + data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit = params.feed_in_limit cp.data.set.charging_ev_data = ev mock_calc_switch_on_power = Mock(return_value=[params.surplus, params.threshold]) monkeypatch.setattr(Counter, "calc_switch_on_power", mock_calc_switch_on_power) diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index 41d0b0df05..98ec9df5c2 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -64,7 +64,6 @@ class InstantCharging: class PvCharging: dc_min_current: float = 145 dc_min_soc_current: float = 145 - feed_in_limit: bool = False limit: Limit = field(default_factory=limit_factory) min_current: int = 0 min_soc_current: int = 10 diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index fab9167328..98c6103f3f 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -316,8 +316,8 @@ def auto_phase_switch(self, phases_to_use = control_parameter.phases phases_in_use = control_parameter.phases pv_config = data.data.general_data.data.chargemode_config.pv_charging - if charge_template.data.chargemode.pv_charging.feed_in_limit: - feed_in_yield = pv_config.feed_in_yield + if pv_config.feed_in_limit: + feed_in_yield = data.data.general_data.data.chargemode_config.feed_in_yield else: feed_in_yield = 0 delay = pv_config.phase_switch_delay * 60 diff --git a/packages/control/general.py b/packages/control/general.py index 80941ead81..717f1d5a74 100644 --- a/packages/control/general.py +++ b/packages/control/general.py @@ -24,8 +24,8 @@ class PvCharging: "topic": "chargemode_config/pv_charging/bat_power_reserve_active"}) control_range: List = field(default_factory=control_range_factory, metadata={ "topic": "chargemode_config/pv_charging/control_range"}) - feed_in_yield: int = field(default=15000, metadata={ - "topic": "chargemode_config/pv_charging/feed_in_yield"}) + feed_in_limit: bool = field(default=False, metadata={ + "topic": "chargemode_config/pv_charging/feed_in_limit"}) phase_switch_delay: int = field(default=7, metadata={ "topic": "chargemode_config/pv_charging/phase_switch_delay"}) bat_power_discharge: int = field(default=1500, metadata={ @@ -57,6 +57,8 @@ def pv_charging_factory() -> PvCharging: @dataclass class ChargemodeConfig: + feed_in_yield: int = field(default=15000, metadata={ + "topic": "chargemode_config/feed_in_yield"}) pv_charging: PvCharging = field(default_factory=pv_charging_factory) unbalanced_load_limit: int = field( default=18, metadata={"topic": "chargemode_config/unbalanced_load_limit"}) diff --git a/packages/control/loadmanagement.py b/packages/control/loadmanagement.py index ae12e06f3c..040c4106ee 100644 --- a/packages/control/loadmanagement.py +++ b/packages/control/loadmanagement.py @@ -17,8 +17,7 @@ class Loadmanagement: def get_available_currents(self, missing_currents: List[float], counter: Counter, - cp: Chargepoint, - feed_in: int = 0) -> Tuple[List[float], LoadmanagementLimit]: + cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: raw_currents_left = counter.data.set.raw_currents_left try: available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, cp) @@ -36,7 +35,7 @@ def get_available_currents(self, limit = new_limit if new_limit.limiting_value is not None else limit available_currents, new_limit = self._limit_by_power( - counter, available_currents, voltages_mean(cp.data.get.voltages), counter.data.set.raw_power_left, feed_in) + counter, available_currents, voltages_mean(cp.data.get.voltages), counter.data.set.raw_power_left) limit = new_limit if new_limit.limiting_value is not None else limit if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str(): @@ -50,8 +49,7 @@ def get_available_currents_surplus(self, missing_currents: List[float], cp_voltage: float, counter: Counter, - cp: Chargepoint, - feed_in: int = 0) -> Tuple[List[float], LoadmanagementLimit]: + cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: raw_currents_left = counter.data.set.raw_currents_left available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, cp) @@ -62,7 +60,7 @@ def get_available_currents_surplus(self, limit = new_limit if new_limit.limiting_value is not None else limit available_currents, new_limit = self._limit_by_power( - counter, available_currents, cp_voltage, counter.data.set.surplus_power_left, feed_in) + counter, available_currents, cp_voltage, counter.data.set.surplus_power_left) limit = new_limit if new_limit.limiting_value is not None else limit if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str(): @@ -96,8 +94,7 @@ def _limit_by_power(self, counter: Counter, available_currents: List[float], cp_voltage: float, - raw_power_left: Optional[float], - feed_in: Optional[float]) -> Tuple[List[float], LoadmanagementLimit]: + raw_power_left: Optional[float]) -> Tuple[List[float], LoadmanagementLimit]: # Mittelwert der Spannungen verwenden, um Phasenverdrehung zu kompensieren # (Probleme bei einphasig angeschlossenen Wallboxen) currents = available_currents.copy() @@ -105,8 +102,8 @@ def _limit_by_power(self, if raw_power_left is None: return currents, limit elif raw_power_left > 0: - if feed_in is not None: - raw_power_left = max(raw_power_left - feed_in, 0) + if data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit: + raw_power_left = max(raw_power_left - data.data.general_data.data.chargemode_config.feed_in_yield, 0) log.debug(f"Verbleibende Leistung unter Berücksichtigung der Einspeisegrenze: {raw_power_left}W") if sum([c * cp_voltage for c in available_currents]) > raw_power_left: for i in range(0, 3): diff --git a/packages/control/loadmanagement_test.py b/packages/control/loadmanagement_test.py index 48de9d41e9..d1237aff89 100644 --- a/packages/control/loadmanagement_test.py +++ b/packages/control/loadmanagement_test.py @@ -33,10 +33,10 @@ def test_limit_by_power(available_currents: List[float], counter_name_mock = Mock(return_value=COUNTER_NAME) monkeypatch.setattr(loadmanagement, "get_component_name_by_id", counter_name_mock) # evaluation - ret = Loadmanagement()._limit_by_power(Counter(0), available_currents, 230, raw_power_left, None) + currents = Loadmanagement()._limit_by_power(Counter(0), available_currents, 230, raw_power_left) # assertion - assert ret == expected_ret + assert currents == expected_ret @pytest.mark.parametrize( diff --git a/packages/helpermodules/create_debug.py b/packages/helpermodules/create_debug.py index cb032927a5..c2b8706d7e 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 @@ -120,7 +120,8 @@ def config_and_state(): f"Switch_On_Delay: {chargemode_config.pv_charging.switch_on_delay}s\n" f"Switch_Off_Threshold: {chargemode_config.pv_charging.switch_off_threshold}W\n" f"Switch_Off_Delay: {chargemode_config.pv_charging.switch_off_delay}s\n" - f"Feed_In_Yield: {chargemode_config.pv_charging.feed_in_yield}W\n" + f"Feed_In_Yield: {chargemode_config.feed_in_yield}W\n" + f"Feed_In_Limit_Vehicle: {chargemode_config.pv_charging.feed_in_limit}W\n" f"Bat_Mode: {chargemode_config.pv_charging.bat_mode}\n" f"Min_Bat_SoC: {chargemode_config.pv_charging.min_bat_soc}%\n" f"Bat_Power_Reserve_Active: {chargemode_config.pv_charging.bat_power_reserve_active}\n" diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index ff9a2ddd36..03fcb26733 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -763,11 +763,12 @@ def process_general_topic(self, msg: mqtt.MQTTMessage): elif "openWB/set/general/chargemode_config/unbalanced_load_limit" in msg.topic: self._validate_value(msg, int, [(10, 32)]) elif ("openWB/set/general/chargemode_config/unbalanced_load" in msg.topic or + "openWB/set/general/chargemode_config/pv_charging/feed_in_limit" in msg.topic or "openWB/set/general/chargemode_config/pv_charging/retry_failed_phase_switches" in msg.topic or "openWB/set/general/chargemode_config/pv_charging/bat_power_discharge_active" in msg.topic or "openWB/set/general/chargemode_config/pv_charging/bat_power_reserve_active" in msg.topic): self._validate_value(msg, bool) - elif ("openWB/set/general/chargemode_config/pv_charging/feed_in_yield" in msg.topic or + elif ("openWB/set/general/chargemode_config/feed_in_yield" in msg.topic or "openWB/set/general/chargemode_config/pv_charging/switch_on_threshold" in msg.topic or "openWB/set/general/chargemode_config/pv_charging/switch_on_delay" in msg.topic or "openWB/set/general/chargemode_config/pv_charging/switch_off_delay" in msg.topic): diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index d3a508740f..6eb6945047 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -59,7 +59,7 @@ class UpdateConfig: - DATASTORE_VERSION = 136 + DATASTORE_VERSION = 137 valid_topic = [ "^openWB/bat/config/bat_control_activated$", @@ -231,7 +231,8 @@ class UpdateConfig: "^openWB/general/chargemode_config/unbalanced_load_limit$", "^openWB/general/chargemode_config/unbalanced_load$", "^openWB/general/chargemode_config/pv_charging/bat_mode$", - "^openWB/general/chargemode_config/pv_charging/feed_in_yield$", + "^openWB/general/chargemode_config/feed_in_yield$", + "^openWB/general/chargemode_config/pv_charging/feed_in_limit$", "^openWB/general/chargemode_config/pv_charging/switch_on_threshold$", "^openWB/general/chargemode_config/pv_charging/switch_on_delay$", "^openWB/general/chargemode_config/pv_charging/switch_off_threshold$", @@ -608,6 +609,7 @@ class UpdateConfig: ("openWB/general/chargemode_config/pv_charging/bat_mode", BatConsiderationMode.EV_MODE.value), ("openWB/general/chargemode_config/pv_charging/bat_power_discharge", 1000), ("openWB/general/chargemode_config/pv_charging/bat_power_discharge_active", True), + ("openWB/general/chargemode_config/pv_charging/feed_in_limit", False), ("openWB/general/chargemode_config/pv_charging/min_bat_soc", 50), ("openWB/general/chargemode_config/pv_charging/max_bat_soc", 70), ("openWB/general/chargemode_config/pv_charging/bat_power_reserve", 200), @@ -617,7 +619,7 @@ class UpdateConfig: ("openWB/general/chargemode_config/pv_charging/switch_off_delay", 60), ("openWB/general/chargemode_config/pv_charging/switch_on_delay", 30), ("openWB/general/chargemode_config/pv_charging/switch_on_threshold", 1500), - ("openWB/general/chargemode_config/pv_charging/feed_in_yield", 0), + ("openWB/general/chargemode_config/feed_in_yield", 0), ("openWB/general/chargemode_config/pv_charging/phase_switch_delay", 7), ("openWB/general/chargemode_config/pv_charging/retry_failed_phase_switches", PvCharging().retry_failed_phase_switches), @@ -3476,3 +3478,21 @@ def upgrade(topic: str, payload) -> None: self._loop_all_received_topics(upgrade) self.__update_topic("openWB/counter/get/loadmanagement_prios", loadmanagement_prios) self._append_datastore_version(136) + + def upgrade_datastore_137(self) -> None: + payload_yield = self.all_received_topics.get("openWB/general/chargemode_config/pv_charging/feed_in_yield") + if payload_yield is not None: + self.__update_topic("openWB/general/chargemode_config/feed_in_yield", decode_payload(payload_yield)) + feed_in_limit = False + for topic, payload in self.all_received_topics.items(): + try: + if re.search("openWB/vehicle/template/charge_template/[0-9]+$", topic) is not None: + payload = decode_payload(payload) + if payload["chargemode"]["pv_charging"].get("feed_in_limit") is True: + feed_in_limit = True + payload["chargemode"]["pv_charging"].pop("feed_in_limit", None) + self.__update_topic(topic, payload) + except KeyError: + log.exception(f"Fehler beim Lesen des feed_in_limit im Fahrzeugtemplate {payload}") + self.__update_topic("openWB/general/chargemode_config/pv_charging/feed_in_limit", feed_in_limit) + self._append_datastore_version(137) From b46730737a7dc117934813baa03319e61580dbdb Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 16 Jun 2026 15:56:11 +0200 Subject: [PATCH 03/21] draft --- docs/samples/sample_consumer/__init__.py | 0 .../sample_consumer/__init__.py | 0 .../sample_consumer/sample_consumer/config.py | 30 +++ .../sample_consumer/consumer.py | 77 ++++++ docs/samples/sample_consumer/vendor.py | 14 + packages/control/consumer/consumer.py | 254 ++++++++++++++++++ packages/control/consumer/consumer_all.py | 53 ++++ packages/control/consumer/consumer_data.py | 155 +++++++++++ packages/control/consumer/usage.py | 8 + packages/control/load_protocol.py | 54 ++++ .../modules/common/configurable_consumer.py | 94 +++++++ packages/modules/common/consumer_setup.py | 23 ++ packages/modules/common/store/_consumer.py | 62 +++++ packages/modules/consumers/__init__.py | 0 packages/modules/consumers/askoma/__init__.py | 0 .../consumers/askoma/askoheat/__init__.py | 0 .../consumers/askoma/askoheat/config.py | 30 +++ .../consumers/askoma/askoheat/consumer.py | 48 ++++ packages/modules/consumers/askoma/vendor.py | 14 + packages/modules/consumers/avm/__init__.py | 0 .../modules/consumers/avm/avm/__init__.py | 0 packages/modules/consumers/avm/avm/config.py | 36 +++ .../modules/consumers/avm/avm/consumer.py | 134 +++++++++ packages/modules/consumers/avm/vendor.py | 14 + .../modules/consumers/generic/__init__.py | 0 .../consumers/generic/counter/__init__.py | 0 .../consumers/generic/counter/config.py | 24 ++ .../consumers/generic/counter/consumer.py | 11 + .../modules/consumers/generic/dac/__init__.py | 0 .../modules/consumers/generic/dac/config.py | 32 +++ .../modules/consumers/generic/dac/consumer.py | 54 ++++ .../modules/consumers/generic/dac/model.py | 8 + .../consumers/generic/http/__init__.py | 0 .../modules/consumers/generic/http/config.py | 45 ++++ .../consumers/generic/http/consumer.py | 90 +++++++ .../consumers/generic/json/__init__.py | 0 .../modules/consumers/generic/json/config.py | 45 ++++ .../consumers/generic/json/consumer.py | 104 +++++++ .../consumers/generic/mqtt/__init__.py | 0 .../modules/consumers/generic/mqtt/config.py | 27 ++ .../consumers/generic/mqtt/consumer.py | 59 ++++ packages/modules/consumers/generic/vendor.py | 14 + packages/modules/consumers/idm/__init__.py | 0 .../modules/consumers/idm/idm/__init__.py | 0 packages/modules/consumers/idm/idm/config.py | 32 +++ .../modules/consumers/idm/idm/consumer.py | 67 +++++ packages/modules/consumers/idm/vendor.py | 14 + .../modules/consumers/lambda_/__init__.py | 0 .../consumers/lambda_/lambda_/__init__.py | 0 .../consumers/lambda_/lambda_/config.py | 32 +++ .../consumers/lambda_/lambda_/consumer.py | 51 ++++ packages/modules/consumers/lambda_/vendor.py | 14 + packages/modules/consumers/my_pv/__init__.py | 0 .../consumers/my_pv/acthor/__init__.py | 0 .../modules/consumers/my_pv/acthor/config.py | 34 +++ .../consumers/my_pv/acthor/consumer.py | 76 ++++++ .../consumers/my_pv/elwa_e/__init__.py | 0 .../modules/consumers/my_pv/elwa_e/config.py | 30 +++ .../consumers/my_pv/elwa_e/consumer.py | 91 +++++++ packages/modules/consumers/my_pv/vendor.py | 14 + .../modules/consumers/mystrom/__init__.py | 0 .../consumers/mystrom/mystrom/__init__.py | 0 .../consumers/mystrom/mystrom/config.py | 25 ++ .../consumers/mystrom/mystrom/consumer.py | 58 ++++ packages/modules/consumers/mystrom/vendor.py | 14 + packages/modules/consumers/nibe/__init__.py | 0 .../consumers/nibe/nibe_s_series/__init__.py | 0 .../consumers/nibe/nibe_s_series/config.py | 27 ++ .../consumers/nibe/nibe_s_series/consumer.py | 43 +++ packages/modules/consumers/nibe/vendor.py | 14 + .../modules/consumers/ratiotherm/__init__.py | 0 .../ratiotherm/ratiotherm/__init__.py | 0 .../consumers/ratiotherm/ratiotherm/config.py | 29 ++ .../ratiotherm/ratiotherm/consumer.py | 36 +++ .../modules/consumers/ratiotherm/vendor.py | 14 + packages/modules/consumers/shelly/__init__.py | 0 .../consumers/shelly/shelly_em/__init__.py | 0 .../consumers/shelly/shelly_em/config.py | 30 +++ .../consumers/shelly/shelly_em/consumer.py | 35 +++ .../consumers/shelly/shelly_pm/__init__.py | 0 .../consumers/shelly/shelly_pm/config.py | 27 ++ .../consumers/shelly/shelly_pm/consumer.py | 33 +++ packages/modules/consumers/shelly/vendor.py | 14 + packages/modules/consumers/solvis/__init__.py | 0 .../consumers/solvis/solvis/__init__.py | 0 .../modules/consumers/solvis/solvis/config.py | 26 ++ .../consumers/solvis/solvis/consumer.py | 39 +++ packages/modules/consumers/solvis/vendor.py | 14 + .../modules/consumers/stiebel/__init__.py | 0 .../consumers/stiebel/stiebel/__init__.py | 0 .../consumers/stiebel/stiebel/config.py | 29 ++ .../consumers/stiebel/stiebel/consumer.py | 41 +++ packages/modules/consumers/stiebel/vendor.py | 14 + packages/modules/consumers/vendors.py | 5 + .../modules/consumers/viessmann/__init__.py | 0 .../modules/consumers/viessmann/vendor.py | 14 + .../viessmann/viessmann_heat_pump/__init__.py | 0 .../viessmann/viessmann_heat_pump/config.py | 26 ++ .../viessmann/viessmann_heat_pump/consumer.py | 33 +++ 99 files changed, 2708 insertions(+) create mode 100644 docs/samples/sample_consumer/__init__.py create mode 100644 docs/samples/sample_consumer/sample_consumer/__init__.py create mode 100644 docs/samples/sample_consumer/sample_consumer/config.py create mode 100644 docs/samples/sample_consumer/sample_consumer/consumer.py create mode 100644 docs/samples/sample_consumer/vendor.py create mode 100644 packages/control/consumer/consumer.py create mode 100644 packages/control/consumer/consumer_all.py create mode 100644 packages/control/consumer/consumer_data.py create mode 100644 packages/control/consumer/usage.py create mode 100644 packages/control/load_protocol.py create mode 100644 packages/modules/common/configurable_consumer.py create mode 100644 packages/modules/common/consumer_setup.py create mode 100644 packages/modules/common/store/_consumer.py create mode 100644 packages/modules/consumers/__init__.py create mode 100644 packages/modules/consumers/askoma/__init__.py create mode 100644 packages/modules/consumers/askoma/askoheat/__init__.py create mode 100644 packages/modules/consumers/askoma/askoheat/config.py create mode 100644 packages/modules/consumers/askoma/askoheat/consumer.py create mode 100644 packages/modules/consumers/askoma/vendor.py create mode 100644 packages/modules/consumers/avm/__init__.py create mode 100644 packages/modules/consumers/avm/avm/__init__.py create mode 100644 packages/modules/consumers/avm/avm/config.py create mode 100644 packages/modules/consumers/avm/avm/consumer.py create mode 100644 packages/modules/consumers/avm/vendor.py create mode 100644 packages/modules/consumers/generic/__init__.py create mode 100644 packages/modules/consumers/generic/counter/__init__.py create mode 100644 packages/modules/consumers/generic/counter/config.py create mode 100644 packages/modules/consumers/generic/counter/consumer.py create mode 100644 packages/modules/consumers/generic/dac/__init__.py create mode 100644 packages/modules/consumers/generic/dac/config.py create mode 100644 packages/modules/consumers/generic/dac/consumer.py create mode 100644 packages/modules/consumers/generic/dac/model.py create mode 100644 packages/modules/consumers/generic/http/__init__.py create mode 100644 packages/modules/consumers/generic/http/config.py create mode 100644 packages/modules/consumers/generic/http/consumer.py create mode 100644 packages/modules/consumers/generic/json/__init__.py create mode 100644 packages/modules/consumers/generic/json/config.py create mode 100644 packages/modules/consumers/generic/json/consumer.py create mode 100644 packages/modules/consumers/generic/mqtt/__init__.py create mode 100644 packages/modules/consumers/generic/mqtt/config.py create mode 100644 packages/modules/consumers/generic/mqtt/consumer.py create mode 100644 packages/modules/consumers/generic/vendor.py create mode 100644 packages/modules/consumers/idm/__init__.py create mode 100644 packages/modules/consumers/idm/idm/__init__.py create mode 100644 packages/modules/consumers/idm/idm/config.py create mode 100644 packages/modules/consumers/idm/idm/consumer.py create mode 100644 packages/modules/consumers/idm/vendor.py create mode 100644 packages/modules/consumers/lambda_/__init__.py create mode 100644 packages/modules/consumers/lambda_/lambda_/__init__.py create mode 100644 packages/modules/consumers/lambda_/lambda_/config.py create mode 100644 packages/modules/consumers/lambda_/lambda_/consumer.py create mode 100644 packages/modules/consumers/lambda_/vendor.py create mode 100644 packages/modules/consumers/my_pv/__init__.py create mode 100644 packages/modules/consumers/my_pv/acthor/__init__.py create mode 100644 packages/modules/consumers/my_pv/acthor/config.py create mode 100644 packages/modules/consumers/my_pv/acthor/consumer.py create mode 100644 packages/modules/consumers/my_pv/elwa_e/__init__.py create mode 100644 packages/modules/consumers/my_pv/elwa_e/config.py create mode 100644 packages/modules/consumers/my_pv/elwa_e/consumer.py create mode 100644 packages/modules/consumers/my_pv/vendor.py create mode 100644 packages/modules/consumers/mystrom/__init__.py create mode 100644 packages/modules/consumers/mystrom/mystrom/__init__.py create mode 100644 packages/modules/consumers/mystrom/mystrom/config.py create mode 100644 packages/modules/consumers/mystrom/mystrom/consumer.py create mode 100644 packages/modules/consumers/mystrom/vendor.py create mode 100644 packages/modules/consumers/nibe/__init__.py create mode 100644 packages/modules/consumers/nibe/nibe_s_series/__init__.py create mode 100644 packages/modules/consumers/nibe/nibe_s_series/config.py create mode 100644 packages/modules/consumers/nibe/nibe_s_series/consumer.py create mode 100644 packages/modules/consumers/nibe/vendor.py create mode 100644 packages/modules/consumers/ratiotherm/__init__.py create mode 100644 packages/modules/consumers/ratiotherm/ratiotherm/__init__.py create mode 100644 packages/modules/consumers/ratiotherm/ratiotherm/config.py create mode 100644 packages/modules/consumers/ratiotherm/ratiotherm/consumer.py create mode 100644 packages/modules/consumers/ratiotherm/vendor.py create mode 100644 packages/modules/consumers/shelly/__init__.py create mode 100644 packages/modules/consumers/shelly/shelly_em/__init__.py create mode 100644 packages/modules/consumers/shelly/shelly_em/config.py create mode 100644 packages/modules/consumers/shelly/shelly_em/consumer.py create mode 100644 packages/modules/consumers/shelly/shelly_pm/__init__.py create mode 100644 packages/modules/consumers/shelly/shelly_pm/config.py create mode 100644 packages/modules/consumers/shelly/shelly_pm/consumer.py create mode 100644 packages/modules/consumers/shelly/vendor.py create mode 100644 packages/modules/consumers/solvis/__init__.py create mode 100644 packages/modules/consumers/solvis/solvis/__init__.py create mode 100644 packages/modules/consumers/solvis/solvis/config.py create mode 100644 packages/modules/consumers/solvis/solvis/consumer.py create mode 100644 packages/modules/consumers/solvis/vendor.py create mode 100644 packages/modules/consumers/stiebel/__init__.py create mode 100644 packages/modules/consumers/stiebel/stiebel/__init__.py create mode 100644 packages/modules/consumers/stiebel/stiebel/config.py create mode 100644 packages/modules/consumers/stiebel/stiebel/consumer.py create mode 100644 packages/modules/consumers/stiebel/vendor.py create mode 100644 packages/modules/consumers/vendors.py create mode 100644 packages/modules/consumers/viessmann/__init__.py create mode 100644 packages/modules/consumers/viessmann/vendor.py create mode 100644 packages/modules/consumers/viessmann/viessmann_heat_pump/__init__.py create mode 100644 packages/modules/consumers/viessmann/viessmann_heat_pump/config.py create mode 100644 packages/modules/consumers/viessmann/viessmann_heat_pump/consumer.py diff --git a/docs/samples/sample_consumer/__init__.py b/docs/samples/sample_consumer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/samples/sample_consumer/sample_consumer/__init__.py b/docs/samples/sample_consumer/sample_consumer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/samples/sample_consumer/sample_consumer/config.py b/docs/samples/sample_consumer/sample_consumer/config.py new file mode 100644 index 0000000000..e39d7c6533 --- /dev/null +++ b/docs/samples/sample_consumer/sample_consumer/config.py @@ -0,0 +1,30 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class SampleConsumerConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class SampleConsumer(ConsumerSetup[SampleConsumerConfiguration]): + def __init__(self, + name: str = "Sample Consumer", + type: str = "sample_consumer", + id: int = 0, + configuration: SampleConsumerConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or SampleConsumerConfiguration(), usage=usage) diff --git a/docs/samples/sample_consumer/sample_consumer/consumer.py b/docs/samples/sample_consumer/sample_consumer/consumer.py new file mode 100644 index 0000000000..a5a9cbd4e1 --- /dev/null +++ b/docs/samples/sample_consumer/sample_consumer/consumer.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +from enum import IntEnum +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.askoma.askoheat.config import SampleConsumer + +log = logging.getLogger(__name__) + + +class Register(IntEnum): + POWER = 1000 + TEMP0 = 1001 + TEMP1 = 1030 + TEMP2 = 1031 + STATUS = 1003 + + +REG_MAPPING = ( + (Register.POWER, [ModbusDataType.INT_16]), + (Register.TEMP0, [ModbusDataType.INT_16]), + (Register.TEMP1, [ModbusDataType.INT_16]), + (Register.TEMP2, [ModbusDataType.INT_16]), + (Register.STATUS, ModbusDataType.INT_16), +) + + +def create_consumer(config: SampleConsumer): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + resp = client.read_holding_registers_bulk( + Register.POWER, 35, mapping=REG_MAPPING, unit=config.configuration.modbus_id) + imported, exported = sim_counter.sim_count(resp[Register.POWER]) + return ConsumerState( + power=resp[Register.POWER], + imported=imported, + exported=exported, + temperatures=[resp[Register.TEMP0]/10, resp[Register.TEMP1]/10, resp[Register.TEMP2]/10] + ) + + def set_limit(power_limit: float) -> None: + nonlocal client + client.write_registers(1000, power_limit, unit=config.configuration.modbus_id) + + # ODER + + def switch_on() -> None: + nonlocal client + client.write_registers(1000, 1, unit=config.configuration.modbus_id) + + def switch_off() -> None: + nonlocal client + client.write_registers(1000, 0, unit=config.configuration.modbus_id) + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=SampleConsumer) diff --git a/docs/samples/sample_consumer/vendor.py b/docs/samples/sample_consumer/vendor.py new file mode 100644 index 0000000000..9741383ef5 --- /dev/null +++ b/docs/samples/sample_consumer/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Sample" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py new file mode 100644 index 0000000000..17e3c055b8 --- /dev/null +++ b/packages/control/consumer/consumer.py @@ -0,0 +1,254 @@ +from enum import Enum +import logging +from typing import Callable, Optional, Tuple +from control import data +from control.algorithm.utils import get_medium_charging_current +from control.chargemode import Chargemode +from control.chargepoint.chargepoint_state import CHARGING_STATES +from control.consumer.consumer_data import ConsumerData, ConsumerUsage, MeterOnlyConfig +from control.load_protocol import Load +from helpermodules import timecheck +from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase +from modules.common.configurable_consumer import ConfigurableConsumer + +log = logging.getLogger(__name__) + + +class WaitForStartStates(Enum): + START_TEST_RUN = "start_test_run" + TEST_RUNNIG = "test_running" + START_SIGNAL_RECEIVED = "start_singal_received" + WAIT_FOR_NEXT_TEST_RUN = "wait_for_next_test_run" + + +class Consumer(Load): + def __init__(self, index: int): + self.num = index + self.data: ConsumerData = ConsumerData() + self.module: ConfigurableConsumer = None + self.chargemode_changed: bool = False + self.submode_changed: bool = False + + def set_state_and_log(self, message: str) -> None: + if message: + log.info(f"Verbraucher {self.num}: {message}") + if self.data.get.state_str is None: + self.data.get.state_str = message + elif message not in self.data.get.state_str: + self.data.get.state_str += f" {message}" + + def update(self): + if isinstance(self.data.usage, MeterOnlyConfig): + return + else: + if self.data.get.voltages is None: + self.data.get.voltages = [230 for i in range(0, self.data.config.connected_phases)] + if self.data.get.currents is None: + self.data.get.currents = [0]*3 + for i in range(0, self.data.config.connected_phases): + self.data.get.currents[i] = (self.data.get.power / + self.data.config.connected_phases / + self.data.get.voltages[i]) + self.data.get.phases_in_use = self.data.config.connected_phases + self.data.set.phases_to_use = self.data.config.connected_phases + self.data.get.charge_state = True if self.data.get.power > 0 else False + min_current, required_current, message, mode, submode = self.get_parameter() + self.set_control_parameter(min_current, required_current, self.data.config.connected_phases, submode, mode) + self.set_state_and_log(message) + self.set_mode_changed(submode, mode) + log.debug( + f"Verbraucher {self.num}: Sollstrom {required_current}, min. Ist-Strom {max(self.data.get.currents)}," + f" Modus {mode}, Submodus {submode}, {message}") + + PRICE_LIMIT_EXCEEDED = "Preislimit für Verbraucher aktiv, aktueller Preis zu hoch." + PRICE_LIMIT_EXCEEDED_CONTINOUS_STILL_RUNNING = ("Preislimit für Verbraucher aktiv, aktueller Preis zu hoch. " + "Verbraucher läuft weiter, da der Verbraucher nicht abgeschaltet " + "werden darf.") + BELOW_PRICE_LIMIT = "Preislimit für Verbraucher aktiv, aktueller Preis in Ordnung." + SURPLUS_CONTINOUS_STILL_RUNNING = ("Verbraucher läuft ggf auch ohne ausreichend Überschuss weiter, da der " + "Verbraucher nicht abgeschaltet werden darf.") + WAIT_FOR_START_SIGNAL = "Warte auf Startsignal für kontinuierlichen Verbraucher. Nächster Testlauf in {}" + WAIT_FOR_START_SIGNAL_TEST_RUN = "Verbraucher eingeschaltet, um zu testen, ob ein Startsignal empfangen wird." + WAIT_FOR_START_SIGNAL_RECEIVED = "Startsignal für kontinuierlichen Verbraucher empfangen." + + def get_parameter(self) -> Tuple[int, int, Optional[str], Chargemode, Chargemode]: + if timecheck.create_timestamp() < self.data.set.timestamp_last_current_set + self.data.usage.min_intervall: + log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") + return (self.data.control_parameter.min_current, + self.data.control_parameter.required_current, + None, + self.data.control_parameter.chargemode, + self.data.control_parameter.submode) + + min_current = self.data.usage.min_current + if self.data.usage.chargemode == Chargemode.SCHEDULED_CHARGING: + required_current, message, mode, submode = self.wait_for_start_handler(self.scheduled_charging) + elif self.data.usage.chargemode == Chargemode.TIME_CHARGING: + required_current, message, mode, submode = self.time_charging() + elif self.data.usage.chargemode == Chargemode.INSTANT_CHARGING: + required_current, message, mode, submode = self.instant_charging() + elif self.data.usage.chargemode == Chargemode.ECO_CHARGING: + required_current, message, mode, submode = self.wait_for_start_handler(self.eco_charging) + elif self.data.usage.chargemode == Chargemode.PV_CHARGING: + required_current, message, mode, submode = self.wait_for_start_handler(self.pv_charging) + elif self.data.usage.chargemode == Chargemode.STOP: + required_current = 0 + message = None + mode = Chargemode.STOP + submode = Chargemode.STOP + else: + raise ValueError(f"Ungültiger Lademodus {self.data.usage.chargemode} für Verbraucher {self.num}") + return min_current, required_current, message, mode, submode + + def scheduled_charging(self) -> Tuple[int, str, Optional[str], int]: + required_current = 0 + message = None + mode = None + submode = None + return required_current, message, mode, submode + + TIME_CHARGING_MIN_BAT_SOC_REACHED = ("Betrieb mit Zeitladen nach Speicher-SoC nicht möglich, da der SoC des" + " Speichers unter dem minimalen SoC liegt.") + TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL = ( + "Betrieb mit Zeitladen nicht möglich, da eine aktive Batteriesteuerung einen Konflikt verursacht.") + TIME_CHARGING_NO_PLAN_ACTIVE = "Betrieb mit Zeitladen nicht möglich, da kein Plan aktiv ist." + TIME_CHARGING_NO_PLAN_CONFIGURED = "Betrieb mit Zeitladen nicht möglich, da kein Plan konfiguriert ist." + + def time_charging(self) -> Tuple[int, str, Optional[str], int]: + required_current = 0 + message = None + submode = Chargemode.STOP + if self.data.usage.time_charging.plans: + plan = timecheck.check_plans_timeframe(self.data.usage.time_charging.plans) + if plan is not None: + if plan.min_bat_soc is not None and data.data.bat_all_data.data.config.configured: + if data.data.bat_all_data.time_charging_min_bat_soc_allowed(): + if data.data.bat_all_data.data.get.soc < plan.min_bat_soc: + message = self.TIME_CHARGING_MIN_BAT_SOC_REACHED + else: + log.debug( + "Zeitladen: minimaler Speicher-SoC überschritten, Laden mit Zeitladen möglich.") + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + submode = Chargemode.TIME_CHARGING + else: + message = self.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL + else: + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + submode = Chargemode.TIME_CHARGING + else: + message = self.TIME_CHARGING_NO_PLAN_ACTIVE + else: + message = self.TIME_CHARGING_NO_PLAN_CONFIGURED + return required_current, message, None, submode + + def instant_charging(self) -> Tuple[int, str, Optional[str], int]: + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + message = None + mode = Chargemode.INSTANT_CHARGING + submode = Chargemode.INSTANT_CHARGING + return required_current, message, mode, submode + + CHARGING_PRICE_EXCEEDED = "Der aktuelle Strompreis liegt über dem maximalen Strompreis. " + CHARGING_PRICE_LOW = "Betrieb, da der aktuelle Strompreis unter dem maximalen Strompreis liegt." + + def eco_charging(self) -> Tuple[int, str, Optional[str], int]: + mode = Chargemode.ECO_CHARGING + if data.data.optional_data.data.electricity_pricing.configured: + if data.data.optional_data.ep_is_charging_allowed_price_threshold(self.data.usage.eco_charging.price_limit): + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + message = self.CHARGING_PRICE_LOW + submode = Chargemode.INSTANT_CHARGING + else: + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + message = self.CHARGING_PRICE_EXCEEDED + if self.data.control_parameter.state in CHARGING_STATES: + message += "Lädt mit Überschuss. " + submode = Chargemode.PV_CHARGING + else: + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + submode = Chargemode.PV_CHARGING + return required_current, message, mode, submode + + def pv_charging(self) -> Tuple[int, str, Optional[str], int]: + required_current = get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + message = None + mode = Chargemode.PV_CHARGING + submode = Chargemode.PV_CHARGING + return required_current, message, mode, submode + + def wait_for_start_handler( + self, func: Callable[[], Tuple[int, str, Optional[str], int]]) -> Tuple[int, str, Optional[str], int]: + if self.data.usage.wait_for_start.active: + timestamp_next_test = self.data.usage.wait_for_start.last_test_timestamp + 60*60 + wait_for_start_state = self._wait_for_start_singal(timestamp_next_test) + if wait_for_start_state == WaitForStartStates.START_SIGNAL_RECEIVED: + required_current, message, mode, submode = func() + elif (wait_for_start_state == WaitForStartStates.START_TEST_RUN or + wait_for_start_state == WaitForStartStates.TEST_RUNNIG): + required_current = self.data.usage.min_current + message = self.WAIT_FOR_START_SIGNAL_TEST_RUN + mode = Chargemode.INSTANT_CHARGING + submode = Chargemode.INSTANT_CHARGING + elif wait_for_start_state == WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN: + required_current = 0 + message = self.WAIT_FOR_START_SIGNAL.format( + timecheck.convert_timestamp_delta_to_time_string(timestamp_next_test, 0)) + mode = Chargemode.STOP + submode = Chargemode.STOP + else: + required_current, message, mode, submode = func() + return required_current, message, mode, submode + + def _wait_for_start_singal(self, timestamp_next_test: float) -> WaitForStartStates: + if self.data.usage.wait_for_start.signal_received: + return WaitForStartStates.START_SIGNAL_RECEIVED + else: + if self.data.usage.wait_for_start.test_running: + if self.data.get.charge_state: + self.data.usage.wait_for_start.signal_received = True + self.data.usage.wait_for_start.test_running = False + return WaitForStartStates.START_SIGNAL_RECEIVED + elif timecheck.create_timestamp() < self.data.usage.wait_for_start.last_test_timestamp + 60: + return WaitForStartStates.START_TEST_RUN + else: + self.data.usage.wait_for_start.test_running = False + log.debug("Testlauf für kontinuierlichen Verbraucher beendet, kein Startsignal empfangen.") + return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN + elif timecheck.create_timestamp() > timestamp_next_test: + self.data.usage.wait_for_start.last_test_timestamp = timecheck.create_timestamp() + return WaitForStartStates.TEST_RUNNIG + else: + return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN + + def set_control_parameter(self, min_current, required_current, phases, submode, mode): + self.data.control_parameter.min_current = min_current + self.data.control_parameter.required_current = required_current + self.data.control_parameter.phases = phases + self.data.control_parameter.submode = submode + self.data.control_parameter.chargemode = mode + control_parameter = self.data.control_parameter + try: + for i in range(0, phases): + evu_phase = convert_single_evu_phase_to_cp_phase(self.data.config.phase_1, i) + control_parameter.required_currents[evu_phase] = required_current + except KeyError: + control_parameter.required_currents = [required_current]*3 + self.set_state_and_log("Bitte in den Verbraucher-Einstellungen die Einstellung 'Phase 1 des Ladekabels'" + + " angeben. Andernfalls wird der benötigte Strom auf allen 3 Phasen vorgehalten, " + + "was ggf eine unnötige Reduktion der Ladeleistung zur Folge hat.") + self.data.set.required_power = sum( + [c * v for c, v in zip(control_parameter.required_currents, self.data.get.voltages)]) + + def is_charging_stop_allowed(self) -> bool: + return self.data.usage.type != ConsumerUsage.CONTINUOUS + + def set_mode_changed(self, submode: Chargemode, mode: Chargemode) -> None: + self.submode_changed = (submode != self.data.control_parameter.submode) + self.chargemode_changed = (mode != self.data.control_parameter.chargemode) diff --git a/packages/control/consumer/consumer_all.py b/packages/control/consumer/consumer_all.py new file mode 100644 index 0000000000..f280d73100 --- /dev/null +++ b/packages/control/consumer/consumer_all.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass, field +import logging + +from control import data + + +log = logging.getLogger(__name__) + + +@dataclass +class AllGet: + daily_imported: float = field(default=0, metadata={"topic": "get/daily_imported"}) + daily_exported: float = field(default=0, metadata={"topic": "get/daily_exported"}) + power: float = field(default=0, metadata={"topic": "get/power"}) + imported: float = field(default=0, metadata={"topic": "get/imported"}) + exported: float = field(default=0, metadata={"topic": "get/exported"}) + + +def all_get_factory() -> AllGet: + return AllGet() + + +@dataclass +class AllConsumerData: + get: AllGet = field(default_factory=all_get_factory) + + +def all_Consumer_data_factory() -> AllConsumerData: + return AllConsumerData() + + +@dataclass +class AllConsumers: + data: AllConsumerData = field(default_factory=all_Consumer_data_factory) + + def get_consumer_sum(self): + imported, exported, power = 0, 0, 0 + try: + for consumer in data.data.consumer_data.values(): + try: + imported = imported + consumer.data.get.imported + exported = exported + consumer.data.get.exported + except Exception: + log.exception("Fehler in der allgemeinen Verbaucher-Klasse für Verbaucher "+consumer) + try: + power = power + consumer.data.get.power + except Exception: + log.exception("Fehler in der allgemeinen Verbaucher-Klasse für Verbaucher "+consumer) + self.data.get.power = power + self.data.get.imported = imported + self.data.get.exported = exported + except Exception: + log.exception("Fehler in der allgemeinen Verbaucher-Klasse") diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py new file mode 100644 index 0000000000..aa389e8a32 --- /dev/null +++ b/packages/control/consumer/consumer_data.py @@ -0,0 +1,155 @@ +from dataclasses import dataclass, field +from typing import Callable, Dict, List, Optional, Union +from control.chargemode import Chargemode +from control.chargepoint.control_parameter import ControlParameter, control_parameter_factory +from control.consumer.usage import ConsumerUsage +from dataclass_utils.factories import empty_list_factory +from helpermodules.abstract_plans import (ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer, + TimeChargingPlanConsumer) +from helpermodules.constants import NO_ERROR +from modules.common.consumer_setup import ConsumerSetup + + +@dataclass +class EcoCharging: + price_limit: float = 0.07 + + +@dataclass +class ScheduledContinuousCharging: + plans: List[ContinuousScheduledPlanConsumer] = field(default_factory=empty_list_factory) + + +@dataclass +class ScheduledSuspendableCharging: + plans: List[SuspendableScheduledPlanConsumer] = field(default_factory=empty_list_factory) + + +@dataclass +class TimeCharging: + active: bool = False + plans: List[TimeChargingPlanConsumer] = field(default_factory=empty_list_factory) + + +@dataclass +class MeterOnlyConfig: + type: ConsumerUsage = ConsumerUsage.METER_ONLY + + +@dataclass +class SuspendableTunableDeviceConfig: + chargemode: Chargemode = Chargemode.INSTANT_CHARGING + min_current: float = 0.5 + min_intervall: int = 60 + eco_charging: EcoCharging = field(default_factory=lambda: EcoCharging()) + scheduled_charging: ScheduledSuspendableCharging = field(default_factory=lambda: ScheduledSuspendableCharging()) + time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) + type: ConsumerUsage = ConsumerUsage.SUSPENDABLE_TUNABLE + wait_for_start_active: bool = True + + +@dataclass +class SuspendableOnOffDeviceConfig: + chargemode: Chargemode = Chargemode.INSTANT_CHARGING + min_current: float = 0.5 + min_intervall: int = 60 + eco_charging: EcoCharging = field(default_factory=lambda: EcoCharging()) + scheduled_charging: ScheduledSuspendableCharging = field(default_factory=lambda: ScheduledSuspendableCharging()) + time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) + type: ConsumerUsage = ConsumerUsage.SUSPENDABLE_ONOFF + wait_for_start_active: bool = True + + +@dataclass +class ContinuousDeviceConfig: + chargemode: Chargemode = Chargemode.INSTANT_CHARGING + min_current: float = 0.5 + min_intervall: int = 2*60*60 + eco_charging: EcoCharging = field(default_factory=lambda: EcoCharging()) + scheduled_charging: ScheduledContinuousCharging = field(default_factory=lambda: ScheduledContinuousCharging()) + time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) + type: ConsumerUsage = ConsumerUsage.CONTINUOUS + wait_for_start_active: bool = True + + +GET_DEFAULTS_BY_USAGE: Dict[ConsumerUsage, Union[MeterOnlyConfig, + SuspendableTunableDeviceConfig, + SuspendableOnOffDeviceConfig, + ContinuousDeviceConfig]] = { + ConsumerUsage.METER_ONLY: MeterOnlyConfig(), + ConsumerUsage.SUSPENDABLE_TUNABLE: SuspendableTunableDeviceConfig(), + ConsumerUsage.SUSPENDABLE_ONOFF: SuspendableOnOffDeviceConfig(), + ConsumerUsage.CONTINUOUS: ContinuousDeviceConfig(), +} + +GET_CLASS_BY_USAGE: Dict[ConsumerUsage, Callable] = { + ConsumerUsage.METER_ONLY: MeterOnlyConfig, + ConsumerUsage.SUSPENDABLE_TUNABLE: SuspendableTunableDeviceConfig, + ConsumerUsage.SUSPENDABLE_ONOFF: SuspendableOnOffDeviceConfig, + ConsumerUsage.CONTINUOUS: ContinuousDeviceConfig, +} + +get_plan_class_for_usage: Dict[ConsumerUsage, Callable] = { + ConsumerUsage.SUSPENDABLE_TUNABLE: SuspendableScheduledPlanConsumer, + ConsumerUsage.SUSPENDABLE_ONOFF: SuspendableScheduledPlanConsumer, + ConsumerUsage.CONTINUOUS: ContinuousScheduledPlanConsumer, +} + + +@dataclass +class ConsumerConfig: + connected_phases: int = 1 + phase_1: int = 1 + max_power: float = 5000 + + +@dataclass +class Get: + charge_state: bool = False + currents: Optional[List[Optional[float]]] = None + daily_imported: float = field(default=0, metadata={"topic": "get/daily_imported"}) + error_timestamp: int = 0 + exported: float = 0 + fault_str: str = NO_ERROR + fault_state: int = 0 + imported: float = 0 + phases_in_use: int = 0 + power: float = 0 + powers: Optional[List[Optional[float]]] = None + set_power: Optional[float] = None + state: Optional[bool] = False + state_str: Optional[str] = field(default=None, metadata={"topic": "get/state_str"}) + voltages: Optional[List[Optional[float]]] = None + + +@dataclass +class Set: + current: float = 0 + loadmanagement_available: bool = False + phases_to_use: int = 0 + plug_time: Optional[float] = 0 + required_power: float = 0 + current_prev: float = 0 + target_current: float = 0 + charge_state_prev: bool = False + power: Optional[float] = None + timestamp_last_current_set: float = field(default=0, metadata={"topic": "set/timestamp_last_current_set"}) + wait_for_start_signal_received: bool = field( + default=False, metadata={"topic": "set/wait_for_start_signal_received"}) + wait_for_start_test_running: bool = field(default=False, metadata={"topic": "set/wait_for_start_test_running"}) + wait_for_start_last_test_timestamp: float = field( + default=0, metadata={"topic": "set/wait_for_start_last_test_timestamp"}) + + +@dataclass +class ConsumerData: + module: ConsumerSetup = None + config: ConsumerConfig = field(default_factory=lambda: ConsumerConfig()) + extra_meter: Optional[int] = None + usage: Union[MeterOnlyConfig, + SuspendableTunableDeviceConfig, + SuspendableOnOffDeviceConfig, + ContinuousDeviceConfig] = field(default_factory=lambda: MeterOnlyConfig()) + control_parameter: ControlParameter = field(default_factory=control_parameter_factory) + get: Get = field(default_factory=lambda: Get()) + set: Set = field(default_factory=lambda: Set()) diff --git a/packages/control/consumer/usage.py b/packages/control/consumer/usage.py new file mode 100644 index 0000000000..167215a9e7 --- /dev/null +++ b/packages/control/consumer/usage.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class ConsumerUsage(Enum): + METER_ONLY = "meter_only" + SUSPENDABLE_TUNABLE = "suspendable_tunable" + SUSPENDABLE_ONOFF = "suspendable_onoff" + CONTINUOUS = "continuous" diff --git a/packages/control/load_protocol.py b/packages/control/load_protocol.py new file mode 100644 index 0000000000..856e7072fb --- /dev/null +++ b/packages/control/load_protocol.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass +from typing import List, Optional, Protocol + +from control.chargepoint.control_parameter import ControlParameter + + +@dataclass +class LoadGet: + charge_state: bool + currents: List[float] + daily_imported: float + daily_exported: float + error_timestamp: int + exported: float + fault_str: str + fault_state: int + imported: float + phases_in_use: int + power: float + powers: List[float] + state_str: Optional[str] + voltages: List[float] + + +@dataclass +class LoadSet: + current: float + loadmanagement_available: bool + phases_to_use: int + plug_time: Optional[float] + required_power: float + current_prev: float + target_current: float + charge_state_prev: bool + + +@dataclass +class LoadData: + control_parameter: ControlParameter + get: LoadGet + set: LoadSet + + +class Load(Protocol): + num: int + chargemode_changed: bool + submode_changed: bool + data: LoadData + + def is_charging_stop_allowed(self) -> bool: + ... + + def set_state_and_log(self, message: str) -> None: + ... diff --git a/packages/modules/common/configurable_consumer.py b/packages/modules/common/configurable_consumer.py new file mode 100644 index 0000000000..76d8b61c81 --- /dev/null +++ b/packages/modules/common/configurable_consumer.py @@ -0,0 +1,94 @@ +import logging +from typing import Callable, Optional, TypeVar, Generic, Any + +from helpermodules import timecheck +from helpermodules.pub import Pub +from modules.common.abstract_device import AbstractCounter +from modules.common.component_context import SingleComponentUpdateContext +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.common.store._factory import get_component_value_store +from modules.common.utils.peak_filter import PeakFilter + +T_CONSUMER = TypeVar("T_CONSUMER") + + +log = logging.getLogger(__name__) + + +class ConfigurableConsumer(Generic[T_CONSUMER]): + def __init__(self, + consumer_config: T_CONSUMER, + initializer: Optional[Callable] = lambda: None, + error_handler: Optional[Callable] = lambda: None, + update: Optional[Callable[[], ConsumerState]] = lambda: None, + set_power_limit: Optional[Callable] = lambda: None, + switch_on: Optional[Callable] = lambda: None, + switch_off: Optional[Callable] = lambda: None,) -> None: + self.config = consumer_config + self.module_initializer = initializer + self.module_error_handler = error_handler + self.module_updater = update + self.module_set_power_limit = set_power_limit + self.module_switch_on = switch_on + self.module_switch_off = switch_off + + self.error_timestamp = None + self.sim_counter = SimCounterConsumer(self.config.id, ComponentType.CONSUMER) + self.store = get_component_value_store(ComponentType.CONSUMER.value, self.config.id) + self.fault_state = FaultState(ComponentInfo(self.config.id, self.config.name, ComponentType.CONSUMER.value)) + self.peak_filter = PeakFilter(ComponentType.CONSUMER, self.config.id, self.fault_state) + try: + self.module_initializer() + except Exception: + log.exception(f"Initialisierung von Gerät {self.config.name} fehlgeschlagen") + + def error_handler(self) -> None: + error_timestamp_topic = f"openWB/set/consumer/{self.config.id}/get/error_timestamp" + if self.error_timestamp is None: + self.error_timestamp = timecheck.create_timestamp() + Pub().pub(error_timestamp_topic, self.error_timestamp) + log.debug(f"Fehler bei Gerät {self.config.name} aufgetreten, " + f"Fehlerzeitstempel: {self.error_timestamp}") + if timecheck.check_timestamp(self.error_timestamp, 60) is False: + try: + self.module_error_handler() + except Exception: + log.exception(f"Fehlerbehandlung für Gerät {self.config.name} fehlgeschlagen") + else: + log.debug(f"Fehlerbehandlung für Gerät {self.config.name} wurde durchgeführt.") + + self.error_timestamp = None + Pub().pub(error_timestamp_topic, self.error_timestamp) + + def update(self): + with SingleComponentUpdateContext(self.fault_state): + if self.module_updater is not None: + consumer_state = self.module_updater() + imported, exported = self.peak_filter.check_values( + consumer_state.power, consumer_state.imported, consumer_state.exported) + if imported != consumer_state.imported or exported != consumer_state.exported: + consumer_state.imported = imported + consumer_state.exported = exported + self.store.set(consumer_state) + + def set_power_limit(self, power_limit: Any) -> None: + with SingleComponentUpdateContext(self.fault_state): + self.module_set_power_limit(power_limit) + + def switch_on(self) -> None: + with SingleComponentUpdateContext(self.fault_state): + self.module_switch_on() + + def switch_off(self) -> None: + with SingleComponentUpdateContext(self.fault_state): + self.module_switch_off() + + +def dependency_injection_devices_components(component: AbstractCounter): + component.sim_counter.component_type = ComponentType.CONSUMER.value + component.store = get_component_value_store(ComponentType.CONSUMER.value, component.component_config.id) + component.fault_state.component_info.type = ComponentType.CONSUMER.value + return component diff --git a/packages/modules/common/consumer_setup.py b/packages/modules/common/consumer_setup.py new file mode 100644 index 0000000000..153c906e27 --- /dev/null +++ b/packages/modules/common/consumer_setup.py @@ -0,0 +1,23 @@ +from typing import Generic, List, TypeVar + +from control.consumer.usage import ConsumerUsage + + +T = TypeVar("T") + + +class ConsumerSetup(Generic[T]): + def __init__(self, + name: str, + type: str, + id: int, + vendor: str, + configuration: T, + usage: List[ConsumerUsage]) -> None: + self.name = name + self.info = {"manufacturer": None, "model": None} + self.type = type + self.id = id + self.configuration = configuration + self.vendor = vendor + self.usage = usage diff --git a/packages/modules/common/store/_consumer.py b/packages/modules/common/store/_consumer.py new file mode 100644 index 0000000000..b2120d9f63 --- /dev/null +++ b/packages/modules/common/store/_consumer.py @@ -0,0 +1,62 @@ +from typing import Union +from control import data +from modules.common.component_state import ConsumerState, CounterState +from modules.common.store import ValueStore +from modules.common.store._api import LoggingValueStore +from modules.common.store._broker import pub_to_broker +from modules.common.utils.component_parser import get_component_obj_by_id + + +class ConsumerValueStoreBroker(ValueStore[ConsumerState]): + def __init__(self, component_num: int) -> None: + self.num = component_num + + def set(self, consumer_state: Union[ConsumerState, CounterState]): + self.state = consumer_state + + def update(self): + if self.state.currents is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/currents", self.state.currents, 2) + if self.state.powers is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/powers", self.state.powers, 2) + if self.state.power is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/power", self.state.power, 2) + if isinstance(self.state, ConsumerState): + if self.state.set_power is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/set_power", self.state.set_power, 2) + if self.state.state is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/state", self.state.state, 2) + if self.state.voltages is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/voltages", self.state.voltages, 2) + if self.state.imported is not None and self.state.exported is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/imported", self.state.imported, 2) + pub_to_broker(f"openWB/set/consumer/{self.num}/get/exported", self.state.exported, 2) + if self.state.temperatures is not None: + pub_to_broker(f"openWB/set/consumer/{self.num}/get/temperatures", self.state.temperatures) + + +class PurgeConsumerState: + def __init__(self, delegate: LoggingValueStore) -> None: + self.delegate = delegate + + def set(self, state: ConsumerState) -> None: + self.delegate.set(state) + + def update(self) -> None: + extra_meter_id = data.data.consumer_data[f"consumer{self.delegate.delegate.num}"].data.extra_meter + if extra_meter_id is not None: + component = get_component_obj_by_id(extra_meter_id) + component_state = component.store.delegate.state + self.set(ConsumerState( + power=component_state.power, + imported=component_state.imported, + exported=component_state.exported, + voltages=component_state.voltages, + currents=component_state.currents, + powers=component_state.powers, + )) + self.delegate.update() + + +def get_consumer_value_store(component_num: int) -> ValueStore[ConsumerState]: + return PurgeConsumerState(LoggingValueStore(ConsumerValueStoreBroker(component_num))) diff --git a/packages/modules/consumers/__init__.py b/packages/modules/consumers/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/askoma/__init__.py b/packages/modules/consumers/askoma/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/askoma/askoheat/__init__.py b/packages/modules/consumers/askoma/askoheat/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/askoma/askoheat/config.py b/packages/modules/consumers/askoma/askoheat/config.py new file mode 100644 index 0000000000..5669b2b194 --- /dev/null +++ b/packages/modules/consumers/askoma/askoheat/config.py @@ -0,0 +1,30 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class AskoheatConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Askoheat(ConsumerSetup[AskoheatConfiguration]): + def __init__(self, + name: str = "Askoheat", + type: str = "askoheat", + id: int = 0, + configuration: AskoheatConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or AskoheatConfiguration(), usage=usage) diff --git a/packages/modules/consumers/askoma/askoheat/consumer.py b/packages/modules/consumers/askoma/askoheat/consumer.py new file mode 100644 index 0000000000..dc906c8a5a --- /dev/null +++ b/packages/modules/consumers/askoma/askoheat/consumer.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.askoma.askoheat.config import Askoheat + +log = logging.getLogger(__name__) + + +def create_consumer(config: Askoheat): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + power = client.read_input_registers(110, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported, + temperatures=[client.read_input_registers(638, ModbusDataType.INT_16, unit=config.configuration.modbus_id)] + ) + + def set_limit(power_limit: float) -> None: + nonlocal client + client.write_registers(201, power_limit, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Askoheat) diff --git a/packages/modules/consumers/askoma/vendor.py b/packages/modules/consumers/askoma/vendor.py new file mode 100644 index 0000000000..b1861e388d --- /dev/null +++ b/packages/modules/consumers/askoma/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "ASKOMA" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/avm/__init__.py b/packages/modules/consumers/avm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/avm/avm/__init__.py b/packages/modules/consumers/avm/avm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/avm/avm/config.py b/packages/modules/consumers/avm/avm/config.py new file mode 100644 index 0000000000..c8d5407d83 --- /dev/null +++ b/packages/modules/consumers/avm/avm/config.py @@ -0,0 +1,36 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class AvmConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + session_id: Optional[str] = None, + session_mtime: Optional[str] = None, + name: Optional[str] = None) -> None: + self.ip_address = ip_address + self.username = username + self.password = password + self.session_id = session_id # don't show in UI + self.session_mtime = session_mtime # don't show in UI + self.name = name + + +@auto_str +class Avm(ConsumerSetup[AvmConfiguration]): + def __init__(self, + name: str = "AVM Fritz!Box", + type: str = "avm", + id: int = 0, + configuration: AvmConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or AvmConfiguration(), usage=usage) diff --git a/packages/modules/consumers/avm/avm/consumer.py b/packages/modules/consumers/avm/avm/consumer.py new file mode 100644 index 0000000000..18c2d57823 --- /dev/null +++ b/packages/modules/consumers/avm/avm/consumer.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +import hashlib +import logging +import time +import xml.etree.ElementTree as ET + +from dataclass_utils._dataclass_asdict import asdict +from helpermodules.pub import Pub +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.consumers.avm.avm.config import Avm + +log = logging.getLogger(__name__) + +INVALID_SESSIONID = "0000000000000000" + + +def create_consumer(config: Avm): + ain = None # Actuator Identification Number + + def update() -> ConsumerState: + nonlocal ain + ensure_valid_session_id() + response = req.get_http_session().get( + f"http://{config.configuration.ip_address}/webservices/homeautoswitch.lua?sid=" + f"{config.configuration.session_id}&switchcmd=getdevicelistinfos") + deviceListElementTree = ET.fromstring(response.text.strip()) + + for device in deviceListElementTree: + name = device.find("name").text + if name == config.configuration.name: + presentText = device.find("present").text + if presentText != '1': + continue + + ain = device.attrib["identifier"] + powermeterBlock = device.find("powermeter") + if powermeterBlock is not None: + # AVM returns mW, convert to W here + power = float(powermeterBlock.find("power").text)/1000 + # AVM returns mV, convert to V here + voltageInfo = powermeterBlock.find("voltage") + if voltageInfo is not None: + voltages = [float(voltageInfo.text)/1000, 0, 0] + # AVM returns Wh + imported = float(powermeterBlock.find("energy").text) + temperatureBlock = device.find("temperature") + if temperatureBlock is not None: + # AVM returns tenths of degrees Celsius + temperature = float(temperatureBlock.find("celsius").text)/10.0 + switchBlock = device.find("switch") + if switchBlock is not None: + state = (int(switchBlock.find("state").text) == 1) + + return ConsumerState( + power=power, + imported=imported, + temperatures=[temperature], + state=state, + voltages=voltages + ) + + def ensure_valid_session_id(): + if check_valid_session_id() is False: + config.configuration.session_id = get_session_id() + config.configuration.session_mtime = time.time() + Pub().pub(f"openWB/set/system/device/{config.id}/config", asdict(config)) + + def check_valid_session_id() -> bool: + return (config.configuration.session_id is None or + config.configuration.session_mtime is None or + time.time() - config.configuration.session_mtime > 300) + + def get_session_id(): + # checking existing sessionID + data = { + 'content-type': 'application/text' + } + response = req.get_http_session().post( + f"http://{config.configuration.ip_address}/login_sid.lua", data=data, timeout=5) + challengeResponse = ET.fromstring(response.content) + session_id = challengeResponse.find('SID').text + if session_id != INVALID_SESSIONID: + return + blockTimeXML = challengeResponse.find('BlockTime') + if blockTimeXML is not None and int(blockTimeXML.text) > 0: + raise Exception("Durch Anmeldefehler in der Vergangenheit ist der Zugang zur FRITZ!Box " + f"noch für {blockTimeXML.text} Sekunden gesperrt.") + + # last sessionID was invalid, performing new challenge-response authentication + challenge = challengeResponse.find('Challenge').text + m = hashlib.md5() + m.update((f"{challenge}-{config.configuration.password}").encode('utf-16le')) + hashedPassword = m.hexdigest() + + data = { + 'username': config.configuration.username, + 'response': challenge + "-" + hashedPassword + } + try: + response = req.get_http_session().post( + f"http://{config.configuration.ip_address}/login_sid.lua", data=data, timeout=5) + session_info = ET.fromstring(response.content) + session_id = session_info.find('SID').text + except Exception: + session_id = None + raise Exception("Anmeldung fehlgeschlagen, bitte Benutzername und Passwort überprüfen. Anmeldung für " + f"die nächsten {session_info.find('BlockTime').text} Sekunden durch FRITZ!Box-Webinterface " + "gesperrt.") + return session_id + + def switch_on() -> None: + nonlocal ain + ensure_valid_session_id() + req.get_http_session().get( + f"http://{config.configuration.ip_address}/webservices/homeautoswitch.lua?sid=" + f"{config.configuration.session_id}&switchcmd=setswitchon&ain={ain}") + + def switch_off() -> None: + nonlocal ain + ensure_valid_session_id() + req.get_http_session().get( + f"http://{config.configuration.ip_address}/webservices/homeautoswitch.lua?sid=" + f"{config.configuration.session_id}&switchcmd=setswitchoff&ain={ain}") + + return ConfigurableConsumer(consumer_config=config, + update=update, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Avm) diff --git a/packages/modules/consumers/avm/vendor.py b/packages/modules/consumers/avm/vendor.py new file mode 100644 index 0000000000..de4025faae --- /dev/null +++ b/packages/modules/consumers/avm/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "AVM Fritz!Box" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/generic/__init__.py b/packages/modules/consumers/generic/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/counter/__init__.py b/packages/modules/consumers/generic/counter/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/counter/config.py b/packages/modules/consumers/generic/counter/config.py new file mode 100644 index 0000000000..aa183310de --- /dev/null +++ b/packages/modules/consumers/generic/counter/config.py @@ -0,0 +1,24 @@ +from typing import List + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class CounterConfiguration: + def __init__(self): + pass + + +@auto_str +class Counter(ConsumerSetup[CounterConfiguration]): + def __init__(self, + name: str = "openWB-kompatibler Zähler", + type: str = "counter", + id: int = 0, + configuration: CounterConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or CounterConfiguration(), usage=usage) diff --git a/packages/modules/consumers/generic/counter/consumer.py b/packages/modules/consumers/generic/counter/consumer.py new file mode 100644 index 0000000000..88abfc4f51 --- /dev/null +++ b/packages/modules/consumers/generic/counter/consumer.py @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.consumers.generic.counter.config import Counter + + +def create_consumer(config: Counter): + return ConfigurableConsumer(consumer_config=config) + + +device_descriptor = DeviceDescriptor(configuration_factory=Counter) diff --git a/packages/modules/consumers/generic/dac/__init__.py b/packages/modules/consumers/generic/dac/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/dac/config.py b/packages/modules/consumers/generic/dac/config.py new file mode 100644 index 0000000000..7ab286282a --- /dev/null +++ b/packages/modules/consumers/generic/dac/config.py @@ -0,0 +1,32 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from modules.consumers.generic.dac.model import Model +from ..vendor import vendor_descriptor + + +@auto_str +class DacConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1, + model: Model = Model.N4Dac02): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + self.model = model + + +@auto_str +class Dac(ConsumerSetup[DacConfiguration]): + def __init__(self, + name: str = "Digital-Analog-Konverter (DAC) 0.01V bis 10.0V", + type: str = "dac", + id: int = 0, + configuration: DacConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or DacConfiguration(), usage=usage) diff --git a/packages/modules/consumers/generic/dac/consumer.py b/packages/modules/consumers/generic/dac/consumer.py new file mode 100644 index 0000000000..88f34476c6 --- /dev/null +++ b/packages/modules/consumers/generic/dac/consumer.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.generic.dac.config import Dac +from modules.consumers.generic.dac.model import Model + +log = logging.getLogger(__name__) + + +def create_consumer(config: Dac): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def set_limit(power_limit: float) -> None: + nonlocal client + if config.configuration.model == Model.N4Dac02 or config.configuration.model == Model.M120T: + # pwoer_limit -= power + pass + + # power_limit = max(power_limit, 0) + # if config.configuration.model == Model.N4Dac02: + # client.write_register(1, power_limit * 1000 / max_power, ModbusDataType.INT_16, + # unit=config.configuration.modbus_id) + # elif config.configuration.model == Model.DA02: + # client.write_register(0x01f4, power_limit * 4000 / max_power, + # ModbusDataType.INT_16, unit=config.configuration.modbus_id) + # elif config.configuration.model == Model.M120T: + # # ausgabe nicht kleiner 0,9V sonst Leistungsregelung der WP aus + # power_limit = max(power_limit * 4095 / max_power, 370) + # client.write_register(0x01f4, power_limit, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + # elif config.configuration.model == Model.AA02B: + # power_limit = max((power_limit * (4095-820) / max_power)+820, 820) + # # ausgabe nicht kleiner 4ma sonst Leistungsregelung der WP aus + # client.write_register(0x01f4, power_limit, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Dac) diff --git a/packages/modules/consumers/generic/dac/model.py b/packages/modules/consumers/generic/dac/model.py new file mode 100644 index 0000000000..364c712555 --- /dev/null +++ b/packages/modules/consumers/generic/dac/model.py @@ -0,0 +1,8 @@ +from enum import Enum + + +class Model(Enum): + N4Dac02 = "N4Dac02" + DA02 = "DA02" + M120T = "M120T" + AA02B = "AA02B" diff --git a/packages/modules/consumers/generic/http/__init__.py b/packages/modules/consumers/generic/http/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/http/config.py b/packages/modules/consumers/generic/http/config.py new file mode 100644 index 0000000000..921c3b4e1e --- /dev/null +++ b/packages/modules/consumers/generic/http/config.py @@ -0,0 +1,45 @@ +from typing import List + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class HttpConfiguration: + def __init__(self, + url: str = None, + current_l1_path: str = None, + current_l2_path: str = None, + current_l3_path: str = None, + power_path: str = None, + temperatures_path: str = None, + imported_path: str = None, + exported_path: str = None, + switch_on_path: str = None, + switch_off_path: str = None, + set_power_limit_path: str = None): + self.url = url + self.current_l1_path = current_l1_path + self.current_l2_path = current_l2_path + self.current_l3_path = current_l3_path + self.power_path = power_path + self.temperatures_path = temperatures_path + self.imported_path = imported_path + self.exported_path = exported_path + self.switch_on_path = switch_on_path + self.switch_off_path = switch_off_path + self.set_power_limit_path = set_power_limit_path + + +@auto_str +class Http(ConsumerSetup[HttpConfiguration]): + def __init__(self, + name: str = "HTTP", + type: str = "http", + id: int = 0, + configuration: HttpConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or HttpConfiguration(), usage=usage) diff --git a/packages/modules/consumers/generic/http/consumer.py b/packages/modules/consumers/generic/http/consumer.py new file mode 100644 index 0000000000..a7815ef6e3 --- /dev/null +++ b/packages/modules/consumers/generic/http/consumer.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +import logging + +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.generic.http.config import Http +from modules.devices.generic.http.api import (create_post_function, create_request_function, + create_request_function_array) +log = logging.getLogger(__name__) + + +def create_consumer(config: Http): + session = None + sim_counter = None + get_power = None + get_imported = None + get_exported = None + get_currents = None + get_temperatures = None + post_set_power_limit = None + post_switch_on = None + post_switch_off = None + + def initializer(): + nonlocal session, sim_counter + nonlocal get_power, get_imported, get_exported, get_currents, get_temperatures + nonlocal post_set_power_limit, post_switch_on, post_switch_off + if not config.configuration.url.startswith('https://'): + raise ValueError("Only HTTPS URLs allowed for security") + session = req.get_http_session() + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + get_power = create_request_function(config.configuration.url, config.configuration.power_path) + get_imported = create_request_function(config.configuration.url, config.configuration.imported_path) + get_exported = create_request_function(config.configuration.url, config.configuration.exported_path) + get_currents = create_request_function_array(config.configuration.url, [ + config.configuration.current_l1_path, + config.configuration.current_l2_path, + config.configuration.current_l3_path, + ]) + get_temperatures = create_request_function(config.configuration.url, config.configuration.temperatures_path) + post_set_power_limit = create_post_function(config.configuration.url, config.configuration.set_power_limit_path) + post_switch_on = create_post_function(config.configuration.url, config.configuration.switch_on_path) + post_switch_off = create_post_function(config.configuration.url, config.configuration.switch_off_path) + + def update() -> None: + nonlocal session, sim_counter, get_power, get_imported, get_exported, get_currents, get_temperatures + power = get_power(session) + exported = get_exported(session) + imported = get_imported(session) + currents = get_currents(session) + temperatures = get_temperatures(session) + if imported is None or exported is None: + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + currents=currents, + imported=imported, + exported=exported, + temperatures=temperatures if isinstance(temperatures, list) else [temperatures], + ) + + def switch_on(): + nonlocal session, post_switch_on + # Authorization? + post_switch_on(session, params={"state": True}) + + def switch_off(): + nonlocal session, post_switch_off + # Authorization? + post_switch_off(session, params={"state": False}) + + def set_power_limit(power_limit: int): + nonlocal session, post_set_power_limit + # Authorization? + post_set_power_limit(session, params={"power_limit": power_limit}) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + set_power_limit=set_power_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Http) diff --git a/packages/modules/consumers/generic/json/__init__.py b/packages/modules/consumers/generic/json/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/json/config.py b/packages/modules/consumers/generic/json/config.py new file mode 100644 index 0000000000..2e5f18ab5c --- /dev/null +++ b/packages/modules/consumers/generic/json/config.py @@ -0,0 +1,45 @@ +from typing import List + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class JsonConfiguration: + def __init__(self, + url: str = None, + jq_current_l1: str = None, + jq_current_l2: str = None, + jq_current_l3: str = None, + jq_power: str = None, + jq_temperatures: str = None, + jq_imported: str = None, + jq_exported: str = None, + jq_switch_on: str = None, + jq_switch_off: str = None, + jq_set_power_limit: str = None): + self.url = url + self.jq_current_l1 = jq_current_l1 + self.jq_current_l2 = jq_current_l2 + self.jq_current_l3 = jq_current_l3 + self.jq_power = jq_power + self.jq_temperatures = jq_temperatures + self.jq_imported = jq_imported + self.jq_exported = jq_exported + self.jq_switch_on = jq_switch_on + self.jq_switch_off = jq_switch_off + self.jq_set_power_limit = jq_set_power_limit + + +@auto_str +class Json(ConsumerSetup[JsonConfiguration]): + def __init__(self, + name: str = "JSON", + type: str = "json", + id: int = 0, + configuration: JsonConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or JsonConfiguration(), usage=usage) diff --git a/packages/modules/consumers/generic/json/consumer.py b/packages/modules/consumers/generic/json/consumer.py new file mode 100644 index 0000000000..93cfe5cc50 --- /dev/null +++ b/packages/modules/consumers/generic/json/consumer.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +import logging +from typing import Callable, Optional +import jq + +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.generic.json.config import Json + +log = logging.getLogger(__name__) + + +def create_consumer(config: Json): + session = None + sim_counter = None + jq_power = None + jq_imported = None + jq_exported = None + jq_currents = None + jq_temperatures = None + jq_set_power_limit = None + jq_switch_on = None + jq_switch_off = None + + def _compile_jq_filters() -> None: + nonlocal jq_power, jq_imported, jq_exported, jq_currents, jq_temperatures + jq_power = jq.compile(config.configuration.jq_power) + jq_currents = [jq.compile(c) for c in config.configuration.jq_currents] if all( + config.configuration.jq_currents) else None + jq_imported = jq.compile(config.configuration.jq_imported) if config.configuration.jq_imported else None + jq_exported = jq.compile(config.configuration.jq_exported) if config.configuration.jq_exported else None + jq_temperatures = jq.compile( + config.configuration.jq_temperatures) if config.configuration.jq_temperatures else None + + def create_post_function(path: Optional[str]) -> Callable[[dict], None]: + nonlocal session + if path is None: + return lambda _: None + else: + def post_function(params: dict): + session.post(config.configuration.url + path, json=params, timeout=5) + return post_function + + def initializer(): + nonlocal session, sim_counter + nonlocal jq_set_power_limit, jq_switch_on, jq_switch_off + if not config.configuration.url.startswith('https://'): + raise ValueError("Only HTTPS URLs allowed for security") + session = req.get_http_session() + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + _compile_jq_filters() + jq_set_power_limit = create_post_function(config.configuration.jq_set_power_limit) + jq_switch_on = create_post_function(config.configuration.jq_switch_on) + jq_switch_off = create_post_function(config.configuration.jq_switch_off) + + def update() -> None: + nonlocal session, sim_counter, jq_power, jq_imported, jq_exported, jq_currents, jq_temperatures + response = req.get_http_session().get(config.configuration.url, timeout=5).json() + power = float(jq_power.input(response).first()) + temperatures = float(jq_temperatures.input(response).first()) if jq_temperatures is not None else None + currents = ([float(j.input(response).first()) for j in jq_currents] if jq_currents is not None else None) + if jq_imported is None or jq_exported is None: + imported, exported = sim_counter.sim_count(power) + else: + imported = float(jq_imported.input(response).first()) + exported = float(jq_exported.input(response).first()) + + return ConsumerState( + power=power, + currents=currents, + imported=imported, + exported=exported, + temperatures=temperatures if isinstance(temperatures, list) else [temperatures], + ) + + def switch_on(): + nonlocal session, jq_switch_on + # Authorization? + jq_switch_on(session, params={"state": True}) + + def switch_off(): + nonlocal session, jq_switch_off + # Authorization? + jq_switch_off(session, params={"state": False}) + + def set_power_limit(power_limit: int): + nonlocal session, jq_set_power_limit + # Authorization? + jq_set_power_limit(session, params={"power_limit": power_limit}) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + update=update, + set_power_limit=set_power_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Json) diff --git a/packages/modules/consumers/generic/mqtt/__init__.py b/packages/modules/consumers/generic/mqtt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/generic/mqtt/config.py b/packages/modules/consumers/generic/mqtt/config.py new file mode 100644 index 0000000000..7a4bebee6d --- /dev/null +++ b/packages/modules/consumers/generic/mqtt/config.py @@ -0,0 +1,27 @@ +from typing import List + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class MqttConfiguration: + def __init__(self): + pass + + +@auto_str +class Mqtt(ConsumerSetup[MqttConfiguration]): + def __init__(self, + name: str = "Mqtt", + type: str = "mqtt", + id: int = 0, + configuration: MqttConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY, + ConsumerUsage.CONTINUOUS, + ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.SUSPENDABLE_TUNABLE]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or MqttConfiguration(), usage=usage) diff --git a/packages/modules/consumers/generic/mqtt/consumer.py b/packages/modules/consumers/generic/mqtt/consumer.py new file mode 100644 index 0000000000..7d1eb44d73 --- /dev/null +++ b/packages/modules/consumers/generic/mqtt/consumer.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +import logging + +from helpermodules.broker import BrokerClient +from helpermodules.pub import Pub +from helpermodules.utils._get_default import get_default +from helpermodules.utils.topic_parser import decode_payload +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.consumers.generic.mqtt.config import Mqtt + +log = logging.getLogger(__name__) + + +def create_consumer(config: Mqtt): + def update() -> None: + def parse_received_topics(value: str): + return received_topics.get(f"{topic_prefix}{value}", get_default(ConsumerState, value)) + + def on_connect(client, userdata, flags, rc): + client.subscribe(f"openWB/mqtt/consumer/{config.id}/get/#") + + def on_message(client, userdata, message): + received_topics.update({message.topic: decode_payload(message.payload)}) + + received_topics = {} + BrokerClient(f"subscribeMqttConsumer{config.id}", + on_connect, on_message).start_finite_loop() + + log.debug(f"Empfange MQTT Daten für Verbraucher {config.id}: {received_topics}") + topic_prefix = f"openWB/mqtt/consumer/{config.id}/get/" + try: + return ConsumerState( + power=received_topics[f"{topic_prefix}power"], + imported=received_topics[f"{topic_prefix}imported"], + powers=parse_received_topics("powers"), + voltages=parse_received_topics("voltages"), + currents=received_topics[f"{topic_prefix}currents"], + ) + except KeyError: + raise KeyError("Es wurden nicht alle notwendigen Daten empfangen.") + + def set_power_limit(power_limit: float) -> None: + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/power", power_limit) + + def switch_on() -> None: + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/switch", True) + + def switch_off() -> None: + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/switch", False) + return ConfigurableConsumer(consumer_config=config, + update=update, + set_power_limit=set_power_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Mqtt) diff --git a/packages/modules/consumers/generic/vendor.py b/packages/modules/consumers/generic/vendor.py new file mode 100644 index 0000000000..04d4a0c59f --- /dev/null +++ b/packages/modules/consumers/generic/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Universell" + self.group = VendorGroup.GENERIC.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/idm/__init__.py b/packages/modules/consumers/idm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/idm/idm/__init__.py b/packages/modules/consumers/idm/idm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/idm/idm/config.py b/packages/modules/consumers/idm/idm/config.py new file mode 100644 index 0000000000..2d10083600 --- /dev/null +++ b/packages/modules/consumers/idm/idm/config.py @@ -0,0 +1,32 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class IdmConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1, + version: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + self.version = version + + +@auto_str +class Idm(ConsumerSetup[IdmConfiguration]): + def __init__(self, + name: str = "Wärmepumpe der Firma IDM mit Navigatorregelung 1.7/2.0", + type: str = "idm", + id: int = 0, + configuration: IdmConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or IdmConfiguration(), usage=usage) diff --git a/packages/modules/consumers/idm/idm/consumer.py b/packages/modules/consumers/idm/idm/consumer.py new file mode 100644 index 0000000000..0ac9fbf973 --- /dev/null +++ b/packages/modules/consumers/idm/idm/consumer.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +from pymodbus.constants import Endian +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.idm.idm.config import Idm + +log = logging.getLogger(__name__) + + +def create_consumer(config: Idm): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + if config.configuration.version == 1: + power = client.read_holding_registers(4122, ModbusDataType.FLOAT_32, + unit=config.configuration.modbus_id) + else: + power = client.read_input_registers(4122, ModbusDataType.FLOAT_32, + unit=config.configuration.modbus_id) + power *= 100 + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_limit(power_limit: float) -> None: + # braucht man Reg 78? der Wärmepumpe kann es doch egal sein, wie viel PV-Leistung vom Dach kommt, + # vlt soll die anderweitig genutzt werden + # + # + # muss man unterscheiden, ob Bezug als negative Zahl oder null übertragen werden soll. Die Wärmepumpe kann + # den Bezug ja nicht kompensieren. Bei 0W oder 500W Bezug soll das Verhalten gleich sein (Wärmepumpe nur + # nach Bedarf einschalten, Eigensteuerung) + nonlocal client + # if config.configuration.send_import: + # power_limit = min(power_limit, MAX_VALUE_UINT32) + # power_limit = max(power_limit, 0) + # else: + # power_limit = min(power_limit, MAX_VALUE_INT16) + # power_limit = max(power_limit, MIN_VALUE_INT16) + + client.write_register(74, max(power_limit, 0), wordorder=Endian.Little, unit=config.configuration.modbus_id) + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Idm) diff --git a/packages/modules/consumers/idm/vendor.py b/packages/modules/consumers/idm/vendor.py new file mode 100644 index 0000000000..e2d6437048 --- /dev/null +++ b/packages/modules/consumers/idm/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "IDM" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/lambda_/__init__.py b/packages/modules/consumers/lambda_/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/lambda_/lambda_/__init__.py b/packages/modules/consumers/lambda_/lambda_/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/lambda_/lambda_/config.py b/packages/modules/consumers/lambda_/lambda_/config.py new file mode 100644 index 0000000000..476639d569 --- /dev/null +++ b/packages/modules/consumers/lambda_/lambda_/config.py @@ -0,0 +1,32 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class LambdaConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1, + sign: int = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + self.sign = sign + + +@auto_str +class Lambda(ConsumerSetup[LambdaConfiguration]): + def __init__(self, + name: str = "Lambda Wärmepumpe", + type: str = "lambda", + id: int = 0, + configuration: LambdaConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or LambdaConfiguration(), usage=usage) diff --git a/packages/modules/consumers/lambda_/lambda_/consumer.py b/packages/modules/consumers/lambda_/lambda_/consumer.py new file mode 100644 index 0000000000..0b3da7745f --- /dev/null +++ b/packages/modules/consumers/lambda_/lambda_/consumer.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +from pymodbus.constants import Endian +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.lambda_.lambda_.config import Lambda + +log = logging.getLogger(__name__) + + +def create_consumer(config: Lambda): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + power = client.read_holding_registers(103, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_limit(power_limit: float) -> None: + nonlocal client + client.write_register(102, + max(power_limit * config.configuration.sign, 0), + wordorder=Endian.Little, + unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Lambda) diff --git a/packages/modules/consumers/lambda_/vendor.py b/packages/modules/consumers/lambda_/vendor.py new file mode 100644 index 0000000000..4cef2bafab --- /dev/null +++ b/packages/modules/consumers/lambda_/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Lambda" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/my_pv/__init__.py b/packages/modules/consumers/my_pv/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/my_pv/acthor/__init__.py b/packages/modules/consumers/my_pv/acthor/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/my_pv/acthor/config.py b/packages/modules/consumers/my_pv/acthor/config.py new file mode 100644 index 0000000000..2429eccc4c --- /dev/null +++ b/packages/modules/consumers/my_pv/acthor/config.py @@ -0,0 +1,34 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class ActhorConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1, + model: Optional[str] = "9s45", + max_power: Optional[int] = 1000): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + self.model = model + self.max_power = max_power + + +@auto_str +class Acthor(ConsumerSetup[ActhorConfiguration]): + def __init__(self, + name: str = "my-PV Acthor oder Elwa2 Heizstab", + type: str = "acthor", + id: int = 0, + configuration: ActhorConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ActhorConfiguration(), usage=usage) diff --git a/packages/modules/consumers/my_pv/acthor/consumer.py b/packages/modules/consumers/my_pv/acthor/consumer.py new file mode 100644 index 0000000000..455f5b0f0b --- /dev/null +++ b/packages/modules/consumers/my_pv/acthor/consumer.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +from enum import IntEnum +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.my_pv.acthor.config import Acthor + +log = logging.getLogger(__name__) + + +class Register(IntEnum): + POWER = 1000 + TEMP0 = 1001 + TEMP1 = 1030 + TEMP2 = 1031 + STATUS = 1003 + + +FACTORS = {"9s45": 45000, + "9s27": 27000, + "9s18": 18000, + "9s": 9000, + "M3": 6000, + "E2M1": 3500, + "E2M3": 6500, + "M1": 3000} +REG_MAPPING = ( + (Register.POWER, [ModbusDataType.INT_16]), + (Register.TEMP0, [ModbusDataType.INT_16]), + (Register.TEMP1, [ModbusDataType.INT_16]), + (Register.TEMP2, [ModbusDataType.INT_16]), + (Register.STATUS, ModbusDataType.INT_16), +) + + +def create_consumer(config: Acthor): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + resp = client.read_holding_registers_bulk( + Register.POWER, 35, mapping=REG_MAPPING, unit=config.configuration.modbus_id) + power = resp[Register.POWER] + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported, + temperatures=[resp[Register.TEMP0]/10, resp[Register.TEMP1]/10, resp[Register.TEMP2]/10] + ) + + def set_limit(power_limit: float) -> None: + nonlocal client + power_limit = power_limit * FACTORS.get(config.configuration.model, 9000)/config.configuration.max_power + client.write_registers(1000, power_limit, unit=config.configuration.modbus_id) + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Acthor) diff --git a/packages/modules/consumers/my_pv/elwa_e/__init__.py b/packages/modules/consumers/my_pv/elwa_e/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/my_pv/elwa_e/config.py b/packages/modules/consumers/my_pv/elwa_e/config.py new file mode 100644 index 0000000000..c5f1e7e1fc --- /dev/null +++ b/packages/modules/consumers/my_pv/elwa_e/config.py @@ -0,0 +1,30 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class ElwaConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Elwa(ConsumerSetup[ElwaConfiguration]): + def __init__(self, + name: str = "my-PV Elwa-E Heizstab", + type: str = "elwa_e", + id: int = 0, + configuration: ElwaConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ElwaConfiguration(), usage=usage) diff --git a/packages/modules/consumers/my_pv/elwa_e/consumer.py b/packages/modules/consumers/my_pv/elwa_e/consumer.py new file mode 100644 index 0000000000..ad28612b4d --- /dev/null +++ b/packages/modules/consumers/my_pv/elwa_e/consumer.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +from enum import IntEnum +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.my_pv.elwa_e.config import Elwa + +log = logging.getLogger(__name__) + + +class Register(IntEnum): + POWER = 1000 + TEMP0 = 1001 + STATUS = 1003 + + +REG_MAPPING = ( + (Register.POWER, [ModbusDataType.INT_16]), + (Register.TEMP0, [ModbusDataType.INT_16]), + (Register.STATUS, ModbusDataType.INT_16), +) + +STATUS = { + 2: "Heat", + 3: "Standby", + 4: "Boost heat", + 5: "Heat finished", + 9: "Setup", + 201: "Error Overtemp Fuse blown", + 202: "Error Overtemp measured", + 203: "Error Overtemp Electronics", + 204: "Error Hardware Fault", + 205: "Error Temp Sensor" +} + + +def create_consumer(config: Elwa): + client = None + fuse = 1 + power = 0 + sim_counter = None + status = None + + def initializer(): + nonlocal client, fuse, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + fuse = client.read_input_registers(1014, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter, status + resp = client.read_holding_registers_bulk( + Register.POWER, 4, mapping=REG_MAPPING, unit=config.configuration.modbus_id) + power = resp[Register.POWER] + status = resp[Register.STATUS] + if status > 200: + raise Exception(f"Elwa-E meldet einen Fehler-Status: {STATUS.get(status, 'Unknown status')}") + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported, + temperatures=[resp[Register.TEMP0]/10] + ) + + def set_limit(power_limit: float) -> None: + nonlocal client, fuse, power, status + if status == 4: + log.debug("Elwa-E im Boost-Heat Modus, keine Leistungsvorgabe möglich") + return + if power_limit < power: + power_limit = power + (power - power_limit) * fuse + power_limit = min(power_limit, 4000) + power_limit = min(power_limit, 0) + + client.write_registers(1000, power_limit, unit=config.configuration.modbus_id) + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Elwa) diff --git a/packages/modules/consumers/my_pv/vendor.py b/packages/modules/consumers/my_pv/vendor.py new file mode 100644 index 0000000000..8cae7ed9b8 --- /dev/null +++ b/packages/modules/consumers/my_pv/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "my-PV" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/mystrom/__init__.py b/packages/modules/consumers/mystrom/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/mystrom/mystrom/__init__.py b/packages/modules/consumers/mystrom/mystrom/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/mystrom/mystrom/config.py b/packages/modules/consumers/mystrom/mystrom/config.py new file mode 100644 index 0000000000..642b7d8b55 --- /dev/null +++ b/packages/modules/consumers/mystrom/mystrom/config.py @@ -0,0 +1,25 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class MyStromConfiguration: + def __init__(self, ip_address: Optional[str] = None): + self.ip_address = ip_address + + +@auto_str +class MyStrom(ConsumerSetup[MyStromConfiguration]): + def __init__(self, + name: str = "MyStrom", + type: str = "mystrom", + id: int = 0, + configuration: MyStromConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or MyStromConfiguration(), usage=usage) diff --git a/packages/modules/consumers/mystrom/mystrom/consumer.py b/packages/modules/consumers/mystrom/mystrom/consumer.py new file mode 100644 index 0000000000..11844537f8 --- /dev/null +++ b/packages/modules/consumers/mystrom/mystrom/consumer.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import logging +from modules.common import req +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.mystrom.mystrom.config import MyStrom + +log = logging.getLogger(__name__) + + +def create_consumer(config: MyStrom): + session = None + sim_counter = None + + def initializer(): + nonlocal session, sim_counter + session = req.get_http_session() + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal session, sim_counter + resp = session.get(f"http://{config.configuration.ip_address}/report", timeout=3).json() + power = resp["power"] + relais = resp["relay"] + temp = float(resp["temperature"]) + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported, + temperatures=[temp], + state=relais + ) + + def switch_on() -> None: + nonlocal session + session.get(f"http://{config.configuration.ip_address}/relay?state=1", timeout=3) + + def switch_off() -> None: + nonlocal session + session.get(f"http://{config.configuration.ip_address}/relay?state=0", timeout=3) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + switch_on=switch_on, + switch_off=switch_off, + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=MyStrom) diff --git a/packages/modules/consumers/mystrom/vendor.py b/packages/modules/consumers/mystrom/vendor.py new file mode 100644 index 0000000000..9e36f5507c --- /dev/null +++ b/packages/modules/consumers/mystrom/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "MyStrom" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/nibe/__init__.py b/packages/modules/consumers/nibe/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/nibe/nibe_s_series/__init__.py b/packages/modules/consumers/nibe/nibe_s_series/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/nibe/nibe_s_series/config.py b/packages/modules/consumers/nibe/nibe_s_series/config.py new file mode 100644 index 0000000000..dc1da37b8f --- /dev/null +++ b/packages/modules/consumers/nibe/nibe_s_series/config.py @@ -0,0 +1,27 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class NibeConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.modbus_id = modbus_id + + +@auto_str +class Nibe(ConsumerSetup[NibeConfiguration]): + def __init__(self, + name: str = "Nibe S-Series Wärmepumpe", + type: str = "nibe_s_series", + id: int = 0, + configuration: NibeConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or NibeConfiguration(), usage=usage) diff --git a/packages/modules/consumers/nibe/nibe_s_series/consumer.py b/packages/modules/consumers/nibe/nibe_s_series/consumer.py new file mode 100644 index 0000000000..4958de5d59 --- /dev/null +++ b/packages/modules/consumers/nibe/nibe_s_series/consumer.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.nibe.nibe_s_series.config import Nibe + +log = logging.getLogger(__name__) + + +def create_consumer(config: Nibe): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, 502) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + power = client.read_input_registers(2166, ModbusDataType.UINT_32, + unit=config.configuration.modbus_id) # / 10 lt Doku? + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update) + + +device_descriptor = DeviceDescriptor(configuration_factory=Nibe) diff --git a/packages/modules/consumers/nibe/vendor.py b/packages/modules/consumers/nibe/vendor.py new file mode 100644 index 0000000000..48ccad8553 --- /dev/null +++ b/packages/modules/consumers/nibe/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Nibe" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/ratiotherm/__init__.py b/packages/modules/consumers/ratiotherm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ratiotherm/ratiotherm/__init__.py b/packages/modules/consumers/ratiotherm/ratiotherm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ratiotherm/ratiotherm/config.py b/packages/modules/consumers/ratiotherm/ratiotherm/config.py new file mode 100644 index 0000000000..6167b5d300 --- /dev/null +++ b/packages/modules/consumers/ratiotherm/ratiotherm/config.py @@ -0,0 +1,29 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class RatiothermConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Ratiotherm(ConsumerSetup[RatiothermConfiguration]): + def __init__(self, + name: str = "Ratiotherm Wärmepumpe", + type: str = "ratiotherm", + id: int = 0, + configuration: RatiothermConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_TUNABLE]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or RatiothermConfiguration(), usage=usage) diff --git a/packages/modules/consumers/ratiotherm/ratiotherm/consumer.py b/packages/modules/consumers/ratiotherm/ratiotherm/consumer.py new file mode 100644 index 0000000000..8b6d0e10a9 --- /dev/null +++ b/packages/modules/consumers/ratiotherm/ratiotherm/consumer.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.ratiotherm.ratiotherm.config import Ratiotherm + +log = logging.getLogger(__name__) + + +def create_consumer(config: Ratiotherm): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def set_limit(power_limit: float) -> None: + nonlocal client + # Absturz bei negativen Zahlen + client.write_register(100, max(power_limit, 0), ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + set_power_limit=set_limit,) + + +device_descriptor = DeviceDescriptor(configuration_factory=Ratiotherm) diff --git a/packages/modules/consumers/ratiotherm/vendor.py b/packages/modules/consumers/ratiotherm/vendor.py new file mode 100644 index 0000000000..7e6f5af780 --- /dev/null +++ b/packages/modules/consumers/ratiotherm/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Ratiotherm" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/shelly/__init__.py b/packages/modules/consumers/shelly/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/shelly/shelly_em/__init__.py b/packages/modules/consumers/shelly/shelly_em/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/shelly/shelly_em/config.py b/packages/modules/consumers/shelly/shelly_em/config.py new file mode 100644 index 0000000000..877991a84a --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_em/config.py @@ -0,0 +1,30 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class ShellyEMConfiguration: + def __init__(self, ip_address: Optional[str] = None, username: Optional[str] = None, + password: Optional[str] = None, channel: int = 0, phase: int = 1, factor: float = 1.0) -> None: + self.ip_address = ip_address + self.username = username + self.password = password + self.channel = channel # Channel 0: alle Meter summiert + self.phase = phase + self.factor = factor + + +@auto_str +class ShellyEM(ConsumerSetup[ShellyEMConfiguration]): + def __init__(self, + name: str = "Shelly EM (Messen)", + type: str = "shelly_em", + id: int = 0, + configuration: ShellyEMConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ShellyEMConfiguration(), usage=usage) diff --git a/packages/modules/consumers/shelly/shelly_em/consumer.py b/packages/modules/consumers/shelly/shelly_em/consumer.py new file mode 100644 index 0000000000..54bcc9a080 --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_em/consumer.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.devices.shelly.shelly.device import create_device +from modules.consumers.shelly.shelly_em.config import ShellyEM +from modules.devices.shelly.shelly.config import (ShellyConfiguration as ShellyDeviceConfiguration, + Shelly as ShellyDevice, + ShellyCounterSetup as ShellyDeviceCounterSetup) + + +def create_consumer(config: ShellyEM): + device = None + + def initializer(): + nonlocal device + device = create_device(device_config=ShellyDevice( + configuration=ShellyDeviceConfiguration( + ip_address=config.configuration.ip_address, + factor=config.configuration.factor, + phase=config.configuration.phase),), + id=config.id) + device.create_counter_component(component_config=ShellyDeviceCounterSetup(type="consumer_counter")) + + def error_handler() -> None: + initializer() + + def update() -> None: + device.update() + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update,) + + +device_descriptor = DeviceDescriptor(configuration_factory=ShellyEM) diff --git a/packages/modules/consumers/shelly/shelly_pm/__init__.py b/packages/modules/consumers/shelly/shelly_pm/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/shelly/shelly_pm/config.py b/packages/modules/consumers/shelly/shelly_pm/config.py new file mode 100644 index 0000000000..4e9398e004 --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_pm/config.py @@ -0,0 +1,27 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class ShellyPMConfiguration: + def __init__(self, ip_address: Optional[str] = None, port: Optional[int] = 502, modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class ShellyPM(ConsumerSetup[ShellyPMConfiguration]): + def __init__(self, + name: str = "Shelly PM (Messen & Schalten)", + type: str = "shelly_pm", + id: int = 0, + configuration: ShellyPMConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY, + ConsumerUsage.SUSPENDABLE_ONOFF]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ShellyPMConfiguration(), usage=usage) diff --git a/packages/modules/consumers/shelly/shelly_pm/consumer.py b/packages/modules/consumers/shelly/shelly_pm/consumer.py new file mode 100644 index 0000000000..adb68ea3bb --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_pm/consumer.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusTcpClient_ +from modules.consumers.shelly.shelly_pm.config import ShellyPM + + +def create_consumer(config: ShellyPM): + client = None + + def initializer(): + nonlocal client + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + + def error_handler() -> None: + initializer() + + def switch_on() -> None: + nonlocal client + client.write_coil(16, True, unit=config.configuration.modbus_id) + + def switch_off() -> None: + nonlocal client + client.write_coil(16, False, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + switch_on=switch_on, + switch_off=switch_off,) + + +device_descriptor = DeviceDescriptor(configuration_factory=ShellyPM) diff --git a/packages/modules/consumers/shelly/vendor.py b/packages/modules/consumers/shelly/vendor.py new file mode 100644 index 0000000000..f4c9e8c5b2 --- /dev/null +++ b/packages/modules/consumers/shelly/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Shelly" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/solvis/__init__.py b/packages/modules/consumers/solvis/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/solvis/solvis/__init__.py b/packages/modules/consumers/solvis/solvis/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/solvis/solvis/config.py b/packages/modules/consumers/solvis/solvis/config.py new file mode 100644 index 0000000000..e51fd7156e --- /dev/null +++ b/packages/modules/consumers/solvis/solvis/config.py @@ -0,0 +1,26 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class SolvisConfiguration: + def __init__(self, ip_address: Optional[str] = None, port: Optional[int] = 502, modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class SolvisHeatPump(ConsumerSetup[SolvisConfiguration]): + def __init__(self, + name: str = "Solvis Wärmepumpe", + type: str = "solvis", + id: int = 0, + configuration: SolvisConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.METER_ONLY]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or SolvisConfiguration(), usage=usage) diff --git a/packages/modules/consumers/solvis/solvis/consumer.py b/packages/modules/consumers/solvis/solvis/consumer.py new file mode 100644 index 0000000000..d6244a7b8f --- /dev/null +++ b/packages/modules/consumers/solvis/solvis/consumer.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.solvis.solvis.config import SolvisHeatPump + + +def create_consumer(config: SolvisHeatPump): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + nonlocal client, sim_counter + power = client.read_input_registers(33545, ModbusDataType.INT_16, unit=config.configuration.modbus_id) * 100 + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update) + + +device_descriptor = DeviceDescriptor(configuration_factory=SolvisHeatPump) diff --git a/packages/modules/consumers/solvis/vendor.py b/packages/modules/consumers/solvis/vendor.py new file mode 100644 index 0000000000..2adee7c9c1 --- /dev/null +++ b/packages/modules/consumers/solvis/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Solvis" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/stiebel/__init__.py b/packages/modules/consumers/stiebel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/stiebel/stiebel/__init__.py b/packages/modules/consumers/stiebel/stiebel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/stiebel/stiebel/config.py b/packages/modules/consumers/stiebel/stiebel/config.py new file mode 100644 index 0000000000..17e629b03b --- /dev/null +++ b/packages/modules/consumers/stiebel/stiebel/config.py @@ -0,0 +1,29 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class StiebelConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + port: Optional[int] = 502, + modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class Stiebel(ConsumerSetup[StiebelConfiguration]): + def __init__(self, + name: str = "Stiebel", + type: str = "stiebel", + id: int = 0, + configuration: StiebelConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_ONOFF]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or StiebelConfiguration(), usage=usage) diff --git a/packages/modules/consumers/stiebel/stiebel/consumer.py b/packages/modules/consumers/stiebel/stiebel/consumer.py new file mode 100644 index 0000000000..c3d6d1a0d0 --- /dev/null +++ b/packages/modules/consumers/stiebel/stiebel/consumer.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +import logging +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_type import ComponentType +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.stiebel.stiebel.config import Stiebel + +log = logging.getLogger(__name__) + + +def create_consumer(config: Stiebel): + client = None + sim_counter = None + + def initializer(): + nonlocal client, sim_counter + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + + def error_handler() -> None: + initializer() + + def switch_on() -> None: + nonlocal client + client.write_register(4001, 1, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def switch_off() -> None: + nonlocal client + client.write_register(4001, 0, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + switch_on=switch_on, + switch_off=switch_off, + ) + + +device_descriptor = DeviceDescriptor(configuration_factory=Stiebel) diff --git a/packages/modules/consumers/stiebel/vendor.py b/packages/modules/consumers/stiebel/vendor.py new file mode 100644 index 0000000000..4e20a18158 --- /dev/null +++ b/packages/modules/consumers/stiebel/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Stiebel" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/vendors.py b/packages/modules/consumers/vendors.py new file mode 100644 index 0000000000..9743d0c896 --- /dev/null +++ b/packages/modules/consumers/vendors.py @@ -0,0 +1,5 @@ +from enum import Enum + + +class VendorGroup(Enum): + VENDORS = "vendors" diff --git a/packages/modules/consumers/viessmann/__init__.py b/packages/modules/consumers/viessmann/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/viessmann/vendor.py b/packages/modules/consumers/viessmann/vendor.py new file mode 100644 index 0000000000..31a0aa7195 --- /dev/null +++ b/packages/modules/consumers/viessmann/vendor.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from modules.common.abstract_device import DeviceDescriptor +from modules.devices.vendors import VendorGroup + + +class Vendor: + def __init__(self): + self.type = Path(__file__).parent.name + self.vendor = "Viessmann" + self.group = VendorGroup.VENDORS.value + + +vendor_descriptor = DeviceDescriptor(configuration_factory=Vendor) diff --git a/packages/modules/consumers/viessmann/viessmann_heat_pump/__init__.py b/packages/modules/consumers/viessmann/viessmann_heat_pump/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/viessmann/viessmann_heat_pump/config.py b/packages/modules/consumers/viessmann/viessmann_heat_pump/config.py new file mode 100644 index 0000000000..ddc8be0fd0 --- /dev/null +++ b/packages/modules/consumers/viessmann/viessmann_heat_pump/config.py @@ -0,0 +1,26 @@ +from typing import List, Optional + +from control.consumer.consumer_data import ConsumerUsage +from helpermodules.auto_str import auto_str +from modules.common.consumer_setup import ConsumerSetup +from ..vendor import vendor_descriptor + + +@auto_str +class ViessmannConfiguration: + def __init__(self, ip_address: Optional[str] = None, port: Optional[int] = 502, modbus_id: Optional[int] = 1): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@auto_str +class ViessmannHeatPump(ConsumerSetup[ViessmannConfiguration]): + def __init__(self, + name: str = "Viessmann Wärmepumpe", + type: str = "viessmann_heat_pump", + id: int = 0, + configuration: ViessmannConfiguration = None, + usage: List[ConsumerUsage] = [ConsumerUsage.SUSPENDABLE_ONOFF]) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ViessmannConfiguration(), usage=usage) diff --git a/packages/modules/consumers/viessmann/viessmann_heat_pump/consumer.py b/packages/modules/consumers/viessmann/viessmann_heat_pump/consumer.py new file mode 100644 index 0000000000..e09b65ca5d --- /dev/null +++ b/packages/modules/consumers/viessmann/viessmann_heat_pump/consumer.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +from modules.common.abstract_device import DeviceDescriptor +from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.modbus import ModbusTcpClient_ +from modules.consumers.viessmann.viessmann_heat_pump.config import ViessmannHeatPump + + +def create_consumer(config: ViessmannHeatPump): + client = None + + def initializer(): + nonlocal client + client = ModbusTcpClient_(config.configuration.ip_address, config.configuration.port) + + def error_handler() -> None: + initializer() + + def switch_on() -> None: + nonlocal client + client.write_single_coil(16, True, unit=config.configuration.modbus_id) + + def switch_off() -> None: + nonlocal client + client.write_single_coil(16, False, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + switch_on=switch_on, + switch_off=switch_off,) + + +device_descriptor = DeviceDescriptor(configuration_factory=ViessmannHeatPump) From 6942575752d44324cb7e4c801287eadc04ca78a9 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Tue, 16 Jun 2026 15:57:04 +0200 Subject: [PATCH 04/21] draft --- data/config/mosquitto/local/openwb_local.conf | 8 +- .../public/default-dynamic-security.json | 98 ++++++++ .../mosquitto/public/role-templates.json | 62 +++++ .../control/algorithm/additional_current.py | 64 +++--- packages/control/algorithm/bidi_charging.py | 55 ++--- packages/control/algorithm/chargemodes.py | 44 ++-- packages/control/algorithm/common.py | 132 ++++++----- packages/control/algorithm/common_test.py | 2 +- .../control/algorithm/filter_chargepoints.py | 199 ++++++++-------- .../algorithm/filter_chargepoints_test.py | 204 ++++++----------- .../integration_test/instant_charging_test.py | 73 +++--- .../integration_test/pv_charging_test.py | 5 +- packages/control/algorithm/min_current.py | 58 ++--- packages/control/algorithm/no_current.py | 10 +- .../control/algorithm/surplus_controlled.py | 215 ++++++++++-------- .../algorithm/surplus_controlled_test.py | 22 +- packages/control/bat_all.py | 11 +- packages/control/bat_all_test.py | 11 +- packages/control/chargepoint/chargepoint.py | 9 +- .../control/chargepoint/control_parameter.py | 1 - packages/control/counter.py | 200 +++++++++------- .../control/counter_all/counter_all_data.py | 6 +- packages/control/counter_all/hierarchy.py | 16 +- .../control/counter_all/hierarchy_test.py | 14 +- .../counter_all/loadmanagement_prio.py | 29 +-- .../counter_all/loadmanagement_prio_test.py | 28 ++- packages/control/counter_test.py | 6 +- packages/control/data.py | 37 +++ packages/control/general.py | 12 + packages/control/loadmanagement.py | 39 ++-- packages/control/prepare.py | 3 + packages/control/process.py | 41 +++- packages/helpermodules/abstract_plans.py | 20 ++ packages/helpermodules/auto_str.py | 7 + .../helpermodules/changed_values_handler.py | 3 + packages/helpermodules/command.py | 144 +++++++++--- packages/helpermodules/setdata.py | 116 +++++++--- packages/helpermodules/subdata.py | 53 +++++ packages/helpermodules/timecheck.py | 4 +- packages/helpermodules/update_config.py | 31 +++ packages/modules/common/component_state.py | 33 +++ packages/modules/common/component_type.py | 6 + .../modules/common/simcount/_simcounter.py | 14 ++ packages/modules/common/store/_factory.py | 3 + packages/modules/common/utils/peak_filter.py | 3 + packages/modules/configuration.py | 99 +++++++- packages/modules/loadvars.py | 15 ++ 47 files changed, 1467 insertions(+), 798 deletions(-) diff --git a/data/config/mosquitto/local/openwb_local.conf b/data/config/mosquitto/local/openwb_local.conf index 8d37590972..ab42e95798 100644 --- a/data/config/mosquitto/local/openwb_local.conf +++ b/data/config/mosquitto/local/openwb_local.conf @@ -1,4 +1,4 @@ -# openwb-version:20 +# openwb-version:21 listener 1886 localhost allow_anonymous true @@ -28,6 +28,12 @@ topic openWB/chargepoint/+/get/# out 2 topic openWB/chargepoint/+/config/# out 2 topic openWB/chargepoint/template/# out 2 +topic openWB/consumer/+/module out 2 +topic openWB/consumer/+/config out 2 +topic openWB/consumer/+/extra_meter/# out 2 +topic openWB/consumer/+/usage out 2 +topic openWB/consumer/+/get/# out 2 + topic openWB/internal_chargepoint/# out 2 topic openWB/io/# out 2 diff --git a/data/config/mosquitto/public/default-dynamic-security.json b/data/config/mosquitto/public/default-dynamic-security.json index fadc141c52..e1d2f2ca31 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -1097,6 +1097,24 @@ "priority": 0, "allow": true }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/consumer/config/switch_on_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/consumer/config/switch_off_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/consumer/config/switch_off_threshold", + "priority": 0, + "allow": true + }, { "acltype": "publishClientReceive", "topic": "openWB/system/security/access/SurplusChargeConfiguration", @@ -1156,6 +1174,24 @@ "topic": "openWB/general/chargemode_config/pv_charging/switch_off_delay", "priority": 0, "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/consumer/config/switch_on_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/consumer/config/switch_off_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/consumer/config/switch_off_threshold", + "priority": 0, + "allow": true } ] }, @@ -1727,6 +1763,55 @@ } ] }, + { + "rolename": "consumer-configuration-access", + "textname": "Zugang zu den Verbrauchereinstellungen", + "textdescription": "Erlaubt den Zugang zu den Verbrauchereinstellungen.", + "acls": [ + { + "acltype": "publishClientSend", + "topic": "openWB/consumer/+/config", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/consumer/+/module", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/consumer/+/usage", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer/+/config", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer/+/module", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer/+/usage", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/system/configurable/consumers", + "priority": 0, + "allow": true + } + ] + }, { "rolename": "vehicle-configuration-access", "textname": "Zugang zu den Fahrzeugeinstellungen", @@ -2831,6 +2916,19 @@ } ] }, + { + "rolename": "consumer-sum-access", + "textname": "Leserechte für Verbrauchersummendaten", + "textdescription": "Erlaubt das Empfangen von Daten für die Summe aller Verbraucher.", + "acls": [ + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer/get/+", + "priority": 0, + "allow": true + } + ] + }, { "rolename": "tenant-energy-configuration-access", "textname": "Leserechte für Mieterstromdaten", diff --git a/data/config/mosquitto/public/role-templates.json b/data/config/mosquitto/public/role-templates.json index 06fe366212..f09a94e480 100644 --- a/data/config/mosquitto/public/role-templates.json +++ b/data/config/mosquitto/public/role-templates.json @@ -72,6 +72,68 @@ "allow": true } ] + }, + { + "rolename": "consumer--access", + "textname": "Leserechte für Verbraucher ", + "textdescription": "Erlaubt das Empfangen von Daten für Verbraucher .", + "acls": [ + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer//set/charge_template", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer//set/manual_lock", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer//config/ev", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/consumer//get/rfid", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/consumer//#", + "priority": 0, + "allow": true + } + ] + }, + { + "rolename": "consumer--write-access", + "textname": "Schreibrechte für MQTT-Verbraucher ", + "textdescription": "Erlaubt das Senden von Daten für Verbraucher .", + "acls": [ + { + "acltype": "publishClientSend", + "topic": "openWB/set/mqtt/consumer//get/#", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/set/mqtt/consumer//get/#", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/mqtt/consumer//#", + "priority": 0, + "allow": true + } + ] }, { "rolename": "vehicle--access", diff --git a/packages/control/algorithm/additional_current.py b/packages/control/algorithm/additional_current.py index 87893fa10e..cecaebf573 100644 --- a/packages/control/algorithm/additional_current.py +++ b/packages/control/algorithm/additional_current.py @@ -1,13 +1,12 @@ 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 +from control.load_protocol import Load from control.loadmanagement import Loadmanagement -from control.chargepoint.chargepoint import Chargepoint -from control.algorithm.filter_chargepoints import (get_chargepoints_by_mode_and_counter, - get_preferenced_chargepoint_charging) +from control.algorithm.filter_chargepoints import (filtered_loads_to_str, get_grouped_loads_by_mode_and_counter, + get_preferenced_load_charging) log = logging.getLogger(__name__) @@ -19,44 +18,45 @@ def __init__(self) -> None: def set_additional_current(self) -> None: common.reset_current_by_chargemode(CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT) - for mode_tuple, counter in common.mode_and_counter_generator(CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT): - preferenced_chargepoints, preferenced_cps_without_set_current = get_preferenced_chargepoint_charging( - get_chargepoints_by_mode_and_counter(mode_tuple, f"counter{counter.num}")) - 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): - 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 " + for counter in common.counter_generator(): + preferenced_loads_groups, preferenced_loads_without_set_current = get_preferenced_load_charging( + get_grouped_loads_by_mode_and_counter(CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT, + f"counter{counter.num}")) + for preferenced_loads in preferenced_loads_groups: + common.update_raw_data(preferenced_loads) + log.info(f"Zähler {counter.num}, Verbraucher {filtered_loads_to_str(preferenced_loads)}") + while len(preferenced_loads): + load = preferenced_loads[0] + missing_currents, counts = common.get_missing_currents_left(preferenced_loads) + available_currents, limit = Loadmanagement().get_available_currents(missing_currents, counter, load) + log.debug(f"load {load.num} available currents {available_currents} missing currents " f"{missing_currents} limit {limit.message}") - if limit.limiting_value is not None: - cp.data.control_parameter.limit = limit - available_for_cp = common.available_current_for_cp(cp, counts, available_currents, missing_currents) + load.data.control_parameter.limit = limit + available_for_cp = common.available_current_for_load( + load, counts, available_currents, missing_currents) current = common.get_current_to_set( - cp.data.set.current, available_for_cp, cp.data.set.target_current) - self._set_loadmangement_message(current, limit, cp) + load.data.set.current, available_for_cp, load.data.set.target_current) + self._set_loadmangement_message(current, limit, load) common.set_current_counterdiff( - cp.data.control_parameter.min_current, + load.data.control_parameter.min_current, current, - cp) - if preferenced_cps_without_set_current: - for cp in preferenced_cps_without_set_current: - cp.data.set.current = cp.data.set.target_current + load) + preferenced_loads.pop(0) + for load in preferenced_loads_without_set_current: + load.data.set.current = load.data.set.target_current # tested def _set_loadmangement_message(self, current: float, limit: LoadmanagementLimit, - chargepoint: Chargepoint) -> None: + load: Load) -> None: # Strom muss an diesem Zähler geändert werden log.debug( - f"current {current} target {chargepoint.data.set.target_current} set current {chargepoint.data.set.current}" - f" required currents {chargepoint.data.control_parameter.required_currents}") - if (current != max(chargepoint.data.set.target_current, chargepoint.data.set.current or 0) and + f"current {current} target {load.data.set.target_current} set current {load.data.set.current}" + f" required currents {load.data.control_parameter.required_currents}") + if (current != max(load.data.set.target_current, load.data.set.current or 0) and # Strom erreicht nicht die vorgegebene Stromstärke round(current, 2) != round(max( - chargepoint.data.control_parameter.required_currents), 2)): - chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" - f"{limit.message}") + load.data.control_parameter.required_currents), 2)): + load.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" + f"{limit.message}") diff --git a/packages/control/algorithm/bidi_charging.py b/packages/control/algorithm/bidi_charging.py index 91e4b090fd..40aea05879 100644 --- a/packages/control/algorithm/bidi_charging.py +++ b/packages/control/algorithm/bidi_charging.py @@ -1,7 +1,8 @@ import logging from control import data from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE -from control.algorithm.filter_chargepoints import get_chargepoints_with_required_current_by_chargemode +from control.algorithm.filter_chargepoints import filtered_loads_to_str, get_loads_by_chargemodes +from control.chargepoint.chargepoint import Chargepoint from helpermodules.phase_handling import voltages_mean log = logging.getLogger(__name__) @@ -15,30 +16,30 @@ def set_bidi(self): grid_counter = data.data.counter_all_data.get_evu_counter() log.debug(f"Nullpunktanpassung {grid_counter.data.set.surplus_power_left}W") zero_point_adjustment = grid_counter - for mode_tuple in CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE: - preferenced_cps = get_chargepoints_with_required_current_by_chargemode(mode_tuple) - if preferenced_cps: - log.info( - f"Mode-Tuple {mode_tuple[0]} - {mode_tuple[1]} - {mode_tuple[2]}, Zähler {grid_counter.num}") - 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)] - missing_currents += [0] * (3 - len(missing_currents)) - if zero_point_adjustment > 0: - if cp.data.set.charging_ev_data.charge_template.bidi_charging_allowed( - cp.data.control_parameter.current_plan, cp.data.set.charging_ev_data.data.get.soc): - for index in range(0, 3): - missing_currents[index] = min(cp.data.control_parameter.required_current, - missing_currents[index]) - else: - log.info(f"LP{cp.num}: Nur bidirektional entladen erlaubt, da SoC-Limit erreicht.") - missing_currents = [0, 0, 0] - else: + preferenced_cps = get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE) + if preferenced_cps: + log.info(f"Verbraucher {filtered_loads_to_str(preferenced_cps)}") + while len(preferenced_cps): + cp: Chargepoint = preferenced_cps[0] + zero_point_adjustment = grid_counter.data.set.surplus_power_left / len(preferenced_cps) + 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)] + missing_currents += [0] * (3 - len(missing_currents)) + if zero_point_adjustment > 0: + if cp.data.set.charging_ev_data.charge_template.bidi_charging_allowed( + cp.data.control_parameter.current_plan, cp.data.set.charging_ev_data.data.get.soc): for index in range(0, 3): - missing_currents[index] = cp.check_min_max_current(missing_currents[index], - cp.data.get.phases_in_use) - 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") + missing_currents[index] = min(cp.data.control_parameter.required_current, + missing_currents[index]) + else: + log.info(f"LP{cp.num}: Nur bidirektional entladen erlaubt, da SoC-Limit erreicht.") + missing_currents = [0, 0, 0] + else: + for index in range(0, 3): + missing_currents[index] = cp.check_min_max_current(missing_currents[index], + cp.data.get.phases_in_use) + 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/chargemodes.py b/packages/control/algorithm/chargemodes.py index 0ebfa07cf3..5dbbd23673 100644 --- a/packages/control/algorithm/chargemodes.py +++ b/packages/control/algorithm/chargemodes.py @@ -2,32 +2,22 @@ # Lademodi in absteigender Priorität # Tupel-Inhalt:(eingestellter Modus, tatsächlich genutzter Modus, Priorität) -CHARGEMODES = ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, True), - (Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - (None, Chargemode.TIME_CHARGING, True), - (None, Chargemode.TIME_CHARGING, False), - (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING, True), - (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING, False), - (Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING, True), - (Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING, False), - (Chargemode.PV_CHARGING, Chargemode.INSTANT_CHARGING, True), - (Chargemode.PV_CHARGING, Chargemode.INSTANT_CHARGING, False), - (Chargemode.SCHEDULED_CHARGING, Chargemode.PV_CHARGING, True), - (Chargemode.SCHEDULED_CHARGING, Chargemode.PV_CHARGING, False), - (Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING, True), - (Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING, False), - (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, True), - (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, False), +CHARGEMODES = ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING), + (None, Chargemode.TIME_CHARGING), + (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING), + (Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING), + (Chargemode.PV_CHARGING, Chargemode.INSTANT_CHARGING), + (Chargemode.SCHEDULED_CHARGING, Chargemode.PV_CHARGING), + (Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING), + (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), # niedrigere Priorität soll nachrangig geladen, aber zuerst entladen werden - (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING, False), - (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING, True), - (None, Chargemode.STOP, True), - (None, Chargemode.STOP, False)) + (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING), + (None, Chargemode.STOP),) -CONSIDERED_CHARGE_MODES_SURPLUS = CHARGEMODES[0:2] + CHARGEMODES[6:16] -CONSIDERED_CHARGE_MODES_PV_ONLY = CHARGEMODES[10:16] -CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT = CHARGEMODES[0:10] -CONSIDERED_CHARGE_MODES_MIN_CURRENT = CHARGEMODES[0:-4] -CONSIDERED_CHARGE_MODES_NO_CURRENT = CHARGEMODES[18:20] -CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE = CHARGEMODES[16:18] -CONSIDERED_CHARGE_MODES_CHARGING = CHARGEMODES[0:16] +CONSIDERED_CHARGE_MODES_SURPLUS = (CHARGEMODES[0], *CHARGEMODES[3:8]) +CONSIDERED_CHARGE_MODES_PV_ONLY = CHARGEMODES[5:8] +CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT = CHARGEMODES[0:5] +CONSIDERED_CHARGE_MODES_MIN_CURRENT = CHARGEMODES[0:-2] +CONSIDERED_CHARGE_MODES_NO_CURRENT = (CHARGEMODES[9],) +CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE = (CHARGEMODES[8],) +CONSIDERED_CHARGE_MODES_CHARGING = CHARGEMODES[0:8] diff --git a/packages/control/algorithm/common.py b/packages/control/algorithm/common.py index 60f81c7fe5..ff8a04612b 100644 --- a/packages/control/algorithm/common.py +++ b/packages/control/algorithm/common.py @@ -2,10 +2,11 @@ from typing import Iterable, List, Optional, Tuple from control import data -from control.algorithm.filter_chargepoints import get_chargepoints_with_required_current_by_chargemode +from control.algorithm.filter_chargepoints import get_loads_by_chargemodes from control.algorithm.utils import get_medium_charging_current from control.chargepoint.chargepoint import Chargepoint from control.counter import Counter +from control.load_protocol import Load from helpermodules.phase_handling import voltages_mean from helpermodules.timecheck import check_timestamp from modules.common.component_type import ComponentType @@ -19,40 +20,40 @@ def reset_current(): - for cp in data.data.cp_data.values(): + for load in list(data.data.cp_data.values()) + list(data.data.consumer_data.values()): try: - cp.data.set.current = None - cp.data.set.target_current = 0 + load.data.set.current = None + load.data.set.target_current = 0 except Exception: - log.exception(f"Fehler im Algorithmus-Modul für Ladepunkt{cp.num}") + log.exception(f"Fehler im Algorithmus-Modul für " + f"{'Ladepunkt' if isinstance(load, Chargepoint) else 'Verbraucher'}{load.num}") -def reset_current_by_chargemode(modes: List[Tuple[Optional[str], str, bool]]) -> None: - for cp in get_chargepoints_with_required_current_by_chargemode(modes): - cp.data.set.current = None +def reset_current_by_chargemode(chargemodes: Tuple[Tuple[Optional[str], str]]) -> None: + for load in get_loads_by_chargemodes(chargemodes): + load.data.set.current = None -def mode_and_counter_generator(chargemodes: List) -> Iterable[Tuple[Tuple[Optional[str], str, bool], Counter]]: - for mode_tuple in chargemodes: - levels = data.data.counter_all_data.get_list_of_elements_per_level() - for level in reversed(levels): - for element in level: - if element["type"] == ComponentType.COUNTER.value: - counter = data.data.counter_data[f"counter{element['id']}"] - yield mode_tuple, counter +def counter_generator() -> Iterable[Counter]: + levels = data.data.counter_all_data.get_list_of_elements_per_level() + for level in reversed(levels): + for element in level: + if element["type"] == ComponentType.COUNTER.value: + counter = data.data.counter_data[f"counter{element['id']}"] + yield counter # tested -def get_min_current(chargepoint: Chargepoint) -> Tuple[List[float], List[int]]: +def get_min_current(load: Load) -> Tuple[List[float], List[int]]: min_currents = [0.0]*3 counts = [0]*3 - required_currents = chargepoint.data.control_parameter.required_currents + required_currents = load.data.control_parameter.required_currents for i in range(3): if required_currents[i] != 0: counts[i] += 1 - min_currents[i] = chargepoint.data.control_parameter.min_current + min_currents[i] = load.data.control_parameter.min_current else: min_currents[i] = 0 return min_currents, counts @@ -62,29 +63,33 @@ def get_min_current(chargepoint: Chargepoint) -> Tuple[List[float], List[int]]: def set_current_counterdiff(diff_current: float, current: float, - chargepoint: Chargepoint, + load: Load, surplus: bool = False) -> None: - required_currents = chargepoint.data.control_parameter.required_currents - considered_current = consider_less_charging_chargepoint_in_loadmanagement( - chargepoint, current) + required_currents = load.data.control_parameter.required_currents + if isinstance(load, Chargepoint): + considered_current = consider_less_charging_chargepoint_in_loadmanagement( + load, current) + else: + considered_current = current # gar nicht ladende Autos? diff = max(considered_current - diff_current, 0) diffs = [diff if required_currents[i] != 0 else 0 for i in range(3)] if max(diffs) > 0: - counters = data.data.counter_all_data.get_counters_to_check(chargepoint.num) + counters = data.data.counter_all_data.get_counters_to_check(load.num) for counter in counters: if surplus: data.data.counter_data[counter].update_surplus_values_left( diffs, - voltages_mean(chargepoint.data.get.voltages)) + voltages_mean(load.data.get.voltages)) else: data.data.counter_data[counter].update_values_left( diffs, - voltages_mean(chargepoint.data.get.voltages)) - data.data.io_actions.dimming_set_import_power_left({"type": "cp", "id": chargepoint.num}, sum(diffs)*230) + voltages_mean(load.data.get.voltages)) + data.data.io_actions.dimming_set_import_power_left({"type": "cp" if isinstance( + load, Chargepoint) else "consumer", "id": load.num}, sum(diffs)*230) - chargepoint.data.set.current = current - log.info(f"LP{chargepoint.num}: Stromstärke {current}A") + load.data.set.current = current + log.info(f"{'LP' if isinstance(load, Chargepoint) else 'Verbraucher'} {load.num}: Stromstärke {current}A") # tested @@ -104,58 +109,62 @@ def get_current_to_set(set_current: float, diff: float, prev_current: float) -> # tested -def available_current_for_cp(chargepoint: Chargepoint, - counts: List[int], - available_currents: List[float], - missing_currents: List[float]) -> float: - control_parameter = chargepoint.data.control_parameter +def available_current_for_load(load: Load, + counts: List[int], + available_currents: List[float], + missing_currents: List[float]) -> float: + control_parameter = load.data.control_parameter available_current = float("inf") - missing_current_cp = control_parameter.required_current - chargepoint.data.set.target_current + missing_current_cp = control_parameter.required_current - load.data.set.target_current + for i in range(0, 3): - if (control_parameter.required_currents[i] != 0 and - missing_currents[i] != available_currents[i]): - available_current = min(min(missing_current_cp, available_currents[i]/counts[i]), available_current) + shared_with = counts[i] + if (control_parameter.required_currents[i] != 0 and missing_currents[i] != available_currents[i]): + available_current = min(min(missing_current_cp, available_currents[i]/shared_with), available_current) + if available_current == float("inf"): available_current = missing_current_cp return available_current -def update_raw_data(preferenced_chargepoints: List[Chargepoint], +def update_raw_data(preferenced_loads: List[Load], diff_to_zero: bool = False, surplus: bool = False) -> None: """alle CP, die schon einen Soll-Strom haben, wieder herausrechnen, da dieser neu gesetzt wird und die neue Differenz bei den Zählern eingetragen wird.""" - for chargepoint in preferenced_chargepoints: - required_currents = chargepoint.data.control_parameter.required_currents - max_target_set_current = max(chargepoint.data.set.target_current, chargepoint.data.set.current or 0) - max_target_set_current = consider_less_charging_chargepoint_in_loadmanagement( - chargepoint, max_target_set_current) + for load in preferenced_loads: + required_currents = load.data.control_parameter.required_currents + max_target_set_current = max(load.data.set.target_current, load.data.set.current or 0) + if isinstance(load, Chargepoint): + max_target_set_current = consider_less_charging_chargepoint_in_loadmanagement( + load, max_target_set_current) if diff_to_zero is False: - if chargepoint.data.control_parameter.min_current < max_target_set_current: + if load.data.control_parameter.min_current < max_target_set_current: if surplus: - diffs = [chargepoint.data.set.target_current - + diffs = [load.data.set.target_current - max_target_set_current if required_currents[i] != 0 else 0 for i in range(3)] else: - diffs = [chargepoint.data.control_parameter.min_current - + diffs = [load.data.control_parameter.min_current - max_target_set_current if required_currents[i] != 0 else 0 for i in range(3)] else: continue else: - if chargepoint.data.control_parameter.min_current <= max_target_set_current: - diffs = [-chargepoint.data.control_parameter.min_current if required_currents[i] + if load.data.control_parameter.min_current <= max_target_set_current: + diffs = [-load.data.control_parameter.min_current if required_currents[i] != 0 else 0 for i in range(3)] else: continue - counters = data.data.counter_all_data.get_counters_to_check(chargepoint.num) + counters = data.data.counter_all_data.get_counters_to_check(load.num) for counter in counters: if surplus: data.data.counter_data[counter].update_surplus_values_left( diffs, - voltages_mean(chargepoint.data.get.voltages)) + voltages_mean(load.data.get.voltages)) else: - data.data.counter_data[counter].update_values_left(diffs, voltages_mean(chargepoint.data.get.voltages)) - data.data.io_actions.dimming_set_import_power_left({"type": "cp", "id": chargepoint.num}, sum(diffs)*230) + data.data.counter_data[counter].update_values_left(diffs, voltages_mean(load.data.get.voltages)) + data.data.io_actions.dimming_set_import_power_left({"type": "cp" if isinstance( + load, Chargepoint) else "consumer", "id": load.num}, sum(diffs)*230) def consider_less_charging_chargepoint_in_loadmanagement(cp: Chargepoint, set_current: float) -> bool: @@ -173,18 +182,18 @@ def consider_less_charging_chargepoint_in_loadmanagement(cp: Chargepoint, set_cu # tested -def get_missing_currents_left(preferenced_chargepoints: List[Chargepoint]) -> Tuple[List[float], List[int]]: +def get_missing_currents_left(preferenced_loads: List[Load]) -> Tuple[List[float], List[int]]: missing_currents = [0.0]*3 counts = [0]*3 - for chargepoint in preferenced_chargepoints: - required_currents = chargepoint.data.control_parameter.required_currents + for load in preferenced_loads: + required_currents = load.data.control_parameter.required_currents for i in range(0, 3): if required_currents[i] != 0: counts[i] += 1 try: - missing_currents[i] += required_currents[i] - chargepoint.data.control_parameter.min_current + missing_currents[i] += required_currents[i] - load.data.control_parameter.min_current except KeyError: - missing_currents[i] += max(required_currents) - chargepoint.data.control_parameter.min_current + missing_currents[i] += max(required_currents) - load.data.control_parameter.min_current else: missing_currents[i] += 0 return missing_currents, counts @@ -194,8 +203,9 @@ def reset_current_to_target_current(): """target_current enthält die gesetzte Stromstärke der vorherigen Stufe. Notwendig, um zB bei der Mindeststromstärke erkennen zu können, ob diese ein vom LM begrenzter Strom aus Stufe 2 oder der Mindeststrom aus Stufe 1 ist.""" - for cp in data.data.cp_data.values(): + for load in (list(data.data.cp_data.values()) + list(data.data.consumer_data.values())): try: - cp.data.set.target_current = cp.data.set.current + load.data.set.target_current = load.data.set.current except Exception: - log.exception(f"Fehler im Algorithmus-Modul für Ladepunkt{cp.num}") + log.exception(f"Fehler im Algorithmus-Modul für " + f"{'Ladepunkt' if isinstance(load, Chargepoint) else 'Verbraucher'}{load.num}") diff --git a/packages/control/algorithm/common_test.py b/packages/control/algorithm/common_test.py index df53dbb7c2..347cd87e1f 100644 --- a/packages/control/algorithm/common_test.py +++ b/packages/control/algorithm/common_test.py @@ -123,7 +123,7 @@ def test_available_currents_for_cp(counts: List[int], cp.data.set.target_current = 10 # evaluation - current = common.available_current_for_cp(cp, counts, available_currents, missing_currents) + current = common.available_current_for_load(cp, counts, available_currents, missing_currents) # assertion assert current == expected_current diff --git a/packages/control/algorithm/filter_chargepoints.py b/packages/control/algorithm/filter_chargepoints.py index 041bd337a6..1659aca5bf 100644 --- a/packages/control/algorithm/filter_chargepoints.py +++ b/packages/control/algorithm/filter_chargepoints.py @@ -1,117 +1,100 @@ # tested import logging -from typing import List, Optional, Tuple, Union +import re +from typing import List, Optional, Tuple from control import data +from control.chargemode import Chargemode from control.chargepoint.chargepoint import Chargepoint +from control.load_protocol import Load log = logging.getLogger(__name__) -def get_chargepoints_by_mode_and_counter(mode_tuple: Tuple[Optional[str], str, bool], - counter: str) -> List[Chargepoint]: - cps_to_counter = data.data.counter_all_data.get_chargepoints_of_counter(counter) - cps_to_counter_ids = [int(cp[2:]) for cp in cps_to_counter] - cps_by_mode = get_chargepoints_with_required_current_by_chargemode(mode_tuple) - return list(filter(lambda cp: cp.num in cps_to_counter_ids, cps_by_mode)) - -# tested - - -def get_chargepoints_by_chargemode( - modes: Union[Tuple[Optional[str], str, bool], - Tuple[Tuple[Optional[str], str, bool]]]) -> List[Chargepoint]: - modes = modes if isinstance(modes[0], Tuple) else (modes,) - valid_chargepoints: List[Chargepoint] = [] - - for mode_tuple in modes: - mode = mode_tuple[0] - submode = mode_tuple[1] - prio = mode_tuple[2] - - for cp in data.data.cp_data.values(): - if ((cp.data.control_parameter.prio == prio) and - (cp.data.control_parameter.chargemode == mode or mode is None) and - (cp.data.control_parameter.submode == submode) and - cp not in valid_chargepoints): - valid_chargepoints.append(cp) - return valid_chargepoints - - -def get_chargepoints_with_required_current_by_chargemode( - modes: Union[Tuple[Optional[str], str, bool], - Tuple[Tuple[Optional[str], str, bool]]]) -> List[Chargepoint]: - cps_by_mode = get_chargepoints_by_chargemode(modes) - return list(filter(lambda cp: cp.data.control_parameter.required_current != 0, cps_by_mode)) - - -def get_preferenced_chargepoint_charging( - chargepoints: List[Chargepoint]) -> Tuple[List[Chargepoint], List[Chargepoint]]: - preferenced_chargepoints = get_preferenced_chargepoint(chargepoints) - preferenced_chargepoints_with_set_current = [] - preferenced_chargepoints_without_set_current = [] - for cp in preferenced_chargepoints: - if cp.data.set.target_current == 0: - log.info( - f"LP {cp.num}: Keine Zuteilung des Mindeststroms, daher keine weitere Berücksichtigung") - preferenced_chargepoints_without_set_current.append(cp) - elif cp.data.get.charge_state is False: - log.info( - f"LP {cp.num}: Lädt nicht, daher keine weitere Berücksichtigung") - preferenced_chargepoints_without_set_current.append(cp) - else: - preferenced_chargepoints_with_set_current.append(cp) - return preferenced_chargepoints_with_set_current, preferenced_chargepoints_without_set_current - - -# tested - - -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), - niedrigste Ladepunktnummer. - """ - preferenced_chargepoints = [] - chargepoints = dict.fromkeys(valid_chargepoints) - try: - # Bedingungen in der Reihenfolge, in der sie geprüft werden. - condition_types = ("min_current", "soc", "imported_since_plugged", "plug_in", "num") - # Bedingung, die geprüft wird (entspricht Index von condition_types) - condition = 0 - if chargepoints: - while len(chargepoints) > 0: - # entsprechend der Bedingung die Values im Dictionary füllen - if condition_types[condition] == "min_current": - chargepoints.update( - (cp, cp.data.control_parameter.required_current) - for cp in chargepoints.keys()) - elif condition_types[condition] == "soc": - chargepoints.update( - (cp, cp.data.set.charging_ev_data.data.get.soc or 0) for cp in chargepoints.keys()) - elif condition_types[condition] == "plug_in": - chargepoints.update((cp, cp.data.set.plug_time) - for cp in chargepoints.keys()) - elif condition_types[condition] == "imported_since_plugged": - chargepoints.update((cp, cp.data.set.log.imported_since_plugged) for cp in chargepoints.keys()) - else: - chargepoints.update((cp, cp.num) for cp in chargepoints.keys()) - - # kleinsten Value im Dictionary ermitteln - extreme_value = min(chargepoints.values()) - # dazugehörige Keys ermitteln - extreme_cp = [ - key for key in chargepoints if chargepoints[key] == extreme_value] - if len(extreme_cp) > 1: - # Wenn es mehrere LP gibt, die den gleichen Minimalwert haben, nächste Bedingung prüfen. - condition += 1 - else: - preferenced_chargepoints.append(extreme_cp[0]) - chargepoints.pop(extreme_cp[0]) - if preferenced_chargepoints: - log.debug(f"Geordnete Ladepunkte {[cp.num for cp in preferenced_chargepoints]}") - return preferenced_chargepoints - except Exception: - log.exception("Fehler im Algorithmus-Modul") - return preferenced_chargepoints +def get_grouped_loads_by_mode_and_counter(chargemodes: Tuple[Tuple[Optional[str], str]], + counter: str) -> List[List[Load]]: + loads_to_counter = data.data.counter_all_data.get_loads_of_counter(counter) + # nur die Zahl aus dem String "cp1" und "consumer2" extrahieren + loads_to_counter_ids = [int(re.search(r'\d+', load).group()) for load in loads_to_counter] + + def _is_valid_for_chargemode(entity: Load, + chargemode: Tuple[Optional[str], str], + valid: List[Load]) -> bool: + """Helper function to validate entity against chargemode conditions.""" + return (entity.data.control_parameter.required_current != 0 and + (entity.data.control_parameter.chargemode == chargemode[0] or chargemode[0] is None) and + entity.data.control_parameter.submode == chargemode[1] and + entity not in valid and + entity.num in loads_to_counter_ids) + + return _group_loads_by_chargemode(chargemodes, _is_valid_for_chargemode)[1] + + +def _group_loads_by_chargemode(chargemodes: Tuple[Tuple[Optional[str], str]], + filter_func) -> Tuple[List[Load], List[List[Load]]]: + grouped_loads: List[List[Load]] = [] + flat_loads: List[Load] = [] + for chargemode in chargemodes: + for item in data.data.counter_all_data.data.get.loadmanagement_prios: + if item["type"] == "group": + sub_valid_chargemode: List[Load] = [] + for group_item in item["children"]: + if group_item["type"] == "vehicle": + for cp in data.data.cp_data.values(): + if group_item["id"] == cp.data.config.ev: + if filter_func(cp, chargemode, flat_loads): + sub_valid_chargemode.append(cp) + flat_loads.append(cp) + elif group_item["type"] == "consumer": + consumer = data.data.consumer_data[f"{group_item['type']}{group_item['id']}"] + if filter_func(consumer, chargemode, flat_loads): + sub_valid_chargemode.append(consumer) + flat_loads.append(consumer) + grouped_loads.append(sub_valid_chargemode) + if item["type"] == "vehicle": + for cp in data.data.cp_data.values(): + if item["id"] == cp.data.config.ev: + if filter_func(cp, chargemode, flat_loads): + grouped_loads.append([cp]) + flat_loads.append(cp) + elif item["type"] == "consumer": + consumer = data.data.consumer_data[f"{item['type']}{item['id']}"] + if filter_func(consumer, chargemode, flat_loads): + grouped_loads.append([consumer]) + flat_loads.append(consumer) + return flat_loads, grouped_loads + + +def get_loads_by_chargemodes(chargemodes: Tuple[Tuple[Optional[Chargemode], Chargemode]]) -> List[Load]: + def _is_valid_for_chargemode(entity, chargemode, valid): + """Helper function to validate entity against chargemode conditions.""" + return (entity.data.control_parameter.required_current != 0 and + (entity.data.control_parameter.chargemode == chargemode[0] or chargemode[0] is None) and + entity.data.control_parameter.submode == chargemode[1] and + entity not in valid) + + return _group_loads_by_chargemode(chargemodes, _is_valid_for_chargemode)[0] + + +def get_preferenced_load_charging( + grouped_loads: List[List[Load]]) -> Tuple[List[List[Load]], List[Load]]: + preferenced_loads_without_set_current: List[Load] = [] + for group in grouped_loads: + valid_group: List[Load] = [] + for load in group: + if load.data.set.target_current == 0: + log.info(f"{'LP' if isinstance(load, Chargepoint) else 'Verbraucher'} {load.num}: " + f"Keine Zuteilung des Mindeststroms, daher keine weitere Berücksichtigung") + preferenced_loads_without_set_current.append(load) + elif load.data.get.charge_state is False: + log.info(f"{'LP' if isinstance(load, Chargepoint) else 'Verbraucher'} {load.num}: " + f"Lädt nicht, daher keine weitere Berücksichtigung") + preferenced_loads_without_set_current.append(load) + else: + valid_group.append(load) + group[:] = valid_group + return grouped_loads, preferenced_loads_without_set_current + + +def filtered_loads_to_str(loads: List[Load]) -> str: + return ", ".join([f"{'LP' if isinstance(load, Chargepoint) else 'Verbraucher'}{load.num}" for load in loads]) diff --git a/packages/control/algorithm/filter_chargepoints_test.py b/packages/control/algorithm/filter_chargepoints_test.py index 68078b266b..aabc3cd627 100644 --- a/packages/control/algorithm/filter_chargepoints_test.py +++ b/packages/control/algorithm/filter_chargepoints_test.py @@ -1,5 +1,4 @@ -from dataclasses import dataclass -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, Union from unittest.mock import Mock import pytest @@ -7,29 +6,15 @@ from control import data from control.algorithm import filter_chargepoints from control.chargemode import Chargemode -from control.chargepoint.chargepoint import Chargepoint, ChargepointData -from control.chargepoint.chargepoint_data import Log, Set -from control.chargepoint.control_parameter import ControlParameter +from control.chargepoint.chargepoint import Chargepoint +from control.consumer.consumer import Consumer from control.counter_all.counter_all import CounterAll -from control.ev.ev import Ev, EvData, Get - - -@dataclass -class PreferencedParams: - name: str - expected_sort: List - plug_time_1: str = "10/31/2022, 07:00:00" - imported_since_plugged_1: float = 100 - soc_1: float = 50 - required_current_1: float = 6 - plug_time_2: str = "10/31/2022, 07:00:00" - imported_since_plugged_2: float = 100 - soc_2: float = 50 - required_current_2: float = 6 - plug_time_3: str = "10/31/2022, 07:00:00" - imported_since_plugged_3: float = 100 - soc_3: float = 50 - required_current_3: float = 6 +from control.load_protocol import Load + + +@pytest.fixture(autouse=True) +def mock_data() -> None: + data.data_init(Mock()) @pytest.fixture(autouse=True) @@ -43,123 +28,80 @@ def mock_cp2() -> Chargepoint: @pytest.fixture(autouse=True) -def mock_cp3() -> Chargepoint: - return Chargepoint(3, None) - - -preferenced_cases = [ - PreferencedParams("sort by num", [mock_cp1, mock_cp2, mock_cp3]), - PreferencedParams("sort by imported_since_plugged", [ - mock_cp2, mock_cp3, mock_cp1], imported_since_plugged_2=20, imported_since_plugged_3=60), - PreferencedParams("sort by plug_time", [mock_cp2, mock_cp1, mock_cp3], - plug_time_2="10/31/2022, 06:00:00", plug_time_3="10/31/2022, 12:00:00"), - PreferencedParams("sort by soc", [mock_cp2, mock_cp1, mock_cp3], soc_2=20, soc_3=60), - PreferencedParams("sort by required_current", [mock_cp1, mock_cp2, - mock_cp3], required_current_2=7, required_current_3=8), - PreferencedParams("sort by required_current and soc", [ - mock_cp2, mock_cp1, mock_cp3], required_current_2=7, soc_2=40), -] - - -@pytest.mark.parametrize("params", preferenced_cases, ids=[c.name for c in preferenced_cases]) -def test_get_preferenced_chargepoint(params: PreferencedParams): - # setup - def mock_cp(cp: Chargepoint, num: int): - ev = Ev(0) - ev.data = EvData(get=Get(soc=getattr(params, f"soc_{num}"))) - cp.data = ChargepointData(set=Set(plug_time=getattr(params, f"plug_time_{num}"), log=Log( - imported_since_plugged=getattr(params, f"imported_since_plugged_{num}")), charging_ev_data=ev), - control_parameter=ControlParameter( - required_current=getattr(params, f"required_current_{num}"))) - cp.num = num - return cp - - cp1 = mock_cp(mock_cp1, 1) - cp2 = mock_cp(mock_cp2, 2) - cp3 = mock_cp(mock_cp3, 3) - # execution - preferenced_chargepoints = filter_chargepoints.get_preferenced_chargepoint([cp1, cp2, cp3]) +def mock_consumer3() -> Consumer: + return Consumer(3) - # evaluation - assert preferenced_chargepoints == params.expected_sort - - -preferenced_cases = [ - PreferencedParams("sort by num", [mock_cp1, mock_cp2, mock_cp3]), - - PreferencedParams("sort by soc", [mock_cp2, mock_cp1, mock_cp3], soc_2=20, soc_3=60), - PreferencedParams("sort by required_current", [mock_cp1, mock_cp2, - mock_cp3], required_current_2=7, required_current_3=8), - PreferencedParams("sort by required_current and soc", [ - mock_cp2, mock_cp1, mock_cp3], required_current_2=7, soc_2=40), -] +# flache prio liste +# prio liste mit gruppe und einzel lp und verbraucher +# ein lademodus +# zwei lademodi +# ein fahrzeug für mwhrere ladepunkte +# required current 0 für ein fahrzeug @pytest.mark.parametrize( - "set_mode_tuple, required_current_1, mode_tuple_1, mode_tuple_2, expected_valid_chargepoints", + "required_current_1, ev_1, chargemode_filter, loadmanagement_prios, expected_cp_indices", [ - pytest.param((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - 6, (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, False), - (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, False), - [mock_cp1, mock_cp2], id="fits mode"), - pytest.param((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - 0, (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, False), - (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, False), - [mock_cp2], id="cp1 should not charge"), - pytest.param((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), - 6, (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, False), - (Chargemode.SCHEDULED_CHARGING, - Chargemode.INSTANT_CHARGING, True), - [mock_cp1], id="cp2 is prioritized") + pytest.param(6, 1, ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING),), + [{"type": "vehicle", "id": 1}, + {"type": "vehicle", "id": 2}, + {"type": "consumer", "id": 3}], + [1, 3], id="flat prio list"), + pytest.param(6, 1, ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING),), + [{"type": "vehicle", "id": 1}, + { + "type": "group", + "label": "Gruppe 1", + "children": [ + {"type": "vehicle", "id": 2}, + {"type": "consumer", "id": 3}]}], + [1, 3], id="grouped prio list"), + pytest.param(6, 2, ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING), + (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING)), + [{"type": "vehicle", "id": 1}, + {"type": "consumer", "id": 3}, + {"type": "vehicle", "id": 2}], + [3, 1, 2], id="ev 2 for cp 1 and cp 2"), + pytest.param(0, 1, ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING), + (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING)), + [{"type": "vehicle", "id": 1}, + {"type": "consumer", "id": 3}, + {"type": "vehicle", "id": 2}], + [3, 2], id="required current 0 for cp 1"), ]) -def test_get_chargepoints_by_mode(set_mode_tuple: Tuple[Optional[str], str, bool], - required_current_1: int, - mode_tuple_1: Tuple[str, str, bool], - mode_tuple_2: Tuple[str, str, bool], - expected_valid_chargepoints): +def test_get_loadmanagement_prios( + required_current_1: int, + ev_1: int, + chargemode_filter: Tuple[Tuple[Optional[Chargemode], Chargemode]], + loadmanagement_prios: List[Dict[str, Union[str, int, List[Dict[str, Union[str, int]]]]]], + expected_cp_indices: List[int], + mock_cp1: Chargepoint, + mock_cp2: Chargepoint, + mock_consumer3: Consumer): # setup - def setup_cp(cp: Chargepoint, required_current: float, mode_tuple: Tuple[str, str, bool]) -> Chargepoint: - cp.data.control_parameter.required_current = required_current - cp.data.control_parameter.prio = mode_tuple[2] - cp.data.control_parameter.chargemode = mode_tuple[0] - cp.data.control_parameter.submode = mode_tuple[1] - return cp - data.data.cp_data = {"cp1": setup_cp(mock_cp1, required_current_1, mode_tuple_1), - "cp2": setup_cp(mock_cp2, 6, mode_tuple_2)} + data.data.cp_data = {"cp1": mock_cp1, "cp2": mock_cp2} + data.data.cp_data["cp1"].data.config.ev = ev_1 + data.data.cp_data["cp1"].data.control_parameter.required_current = required_current_1 + data.data.cp_data["cp1"].data.control_parameter.chargemode = Chargemode.SCHEDULED_CHARGING + data.data.cp_data["cp1"].data.control_parameter.submode = Chargemode.INSTANT_CHARGING + + data.data.cp_data["cp2"].data.config.ev = 2 + data.data.cp_data["cp2"].data.control_parameter.required_current = 6 + data.data.cp_data["cp2"].data.control_parameter.chargemode = Chargemode.INSTANT_CHARGING + data.data.cp_data["cp2"].data.control_parameter.submode = Chargemode.INSTANT_CHARGING + + data.data.consumer_data = {"consumer3": mock_consumer3} + data.data.consumer_data["consumer3"].data.control_parameter.required_current = 1 + data.data.consumer_data["consumer3"].data.control_parameter.chargemode = Chargemode.SCHEDULED_CHARGING + data.data.consumer_data["consumer3"].data.control_parameter.submode = Chargemode.INSTANT_CHARGING - # evaluation - valid_chargepoints = filter_chargepoints.get_chargepoints_with_required_current_by_chargemode(set_mode_tuple) - - # assertion - assert valid_chargepoints == expected_valid_chargepoints - - -@pytest.mark.parametrize( - "chargepoints_of_counter, chargepoints_by_mode, expected_chargepoints", - [ - pytest.param(["cp1", "cp2"], [mock_cp1, mock_cp2], [mock_cp1, mock_cp2], id="match all"), - pytest.param(["cp1", "cp2"], [mock_cp1], [mock_cp1], id="match by mode"), - pytest.param(["cp2"], [mock_cp1, mock_cp2], [mock_cp2], id="match by counter"), - pytest.param(["cp1"], [mock_cp2], [], id="match none") - ]) -def test_get_chargepoints_by_mode_and_counter(chargepoints_of_counter: List[str], - chargepoints_by_mode: List[Chargepoint], - expected_chargepoints: List[Chargepoint], - monkeypatch): - # setup - get_chargepoints_of_counter_mock = Mock(return_value=chargepoints_of_counter) - monkeypatch.setattr(CounterAll, "get_chargepoints_of_counter", get_chargepoints_of_counter_mock) - get_chargepoints_by_chargemode_mock = Mock(return_value=chargepoints_by_mode) - monkeypatch.setattr(filter_chargepoints, "get_chargepoints_by_chargemode", get_chargepoints_by_chargemode_mock) data.data.counter_all_data = CounterAll() + data.data.counter_all_data.data.get.loadmanagement_prios = loadmanagement_prios # evaluation - valid_chargepoints = filter_chargepoints.get_chargepoints_by_mode_and_counter(Mock(), "counter6") + valid_loads = filter_chargepoints.get_loads_by_chargemodes(chargemode_filter) # assertion - assert valid_chargepoints == expected_chargepoints + load_mapping: Dict[int, Load] = {1: mock_cp1, 2: mock_cp2, 3: mock_consumer3} + expected_valid_loads = [load_mapping[i] for i in expected_cp_indices] + assert valid_loads == expected_valid_loads diff --git a/packages/control/algorithm/integration_test/instant_charging_test.py b/packages/control/algorithm/integration_test/instant_charging_test.py index afc9b23551..53214f7bb3 100644 --- a/packages/control/algorithm/integration_test/instant_charging_test.py +++ b/packages/control/algorithm/integration_test/instant_charging_test.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import List +from typing import Dict, List from unittest.mock import Mock import pytest @@ -7,8 +7,9 @@ from control.chargemode import Chargemode from control import data, loadmanagement from control.algorithm.algorithm import Algorithm +from control.ev.ev import Ev from control.limiting_value import LimitingValue -from dataclass_utils.factories import currents_list_factory +from dataclass_utils.factories import currents_list_factory, empty_list_factory @pytest.fixture() @@ -43,6 +44,10 @@ def all_cp_instant_charging_3p(): control_parameter.chargemode = Chargemode.INSTANT_CHARGING data.data.cp_data[f"cp{i}"].data.get.charge_state = True data.data.cp_data[f"cp{i}"].data.get.currents = [16]*3 + data.data.cp_data[f"cp{i}"].data.set.charging_ev_data = Ev(i) + data.data.cp_data[f"cp{i}"].data.config.ev = i + data.data.counter_all_data.data.get.loadmanagement_prios = [ + {"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 4}, {"type": "vehicle", "id": 5}] @dataclass @@ -58,13 +63,16 @@ def assert_counter_set(params: ParamsExpectedCounterSet): assert data.data.counter_data["counter6"].data.set.raw_currents_left == params.expected_raw_currents_left_counter6 -def test_start_instant_charging(all_cp_instant_charging_1p, all_cp_not_charging, monkeypatch): +def test_start_instant_charging(all_cp_instant_charging_1p, all_cp_not_charging, monkeypatch: pytest.MonkeyPatch): # alle 3 im Sofortladen, keine Ladung aktiv -> dürfen nur Mindeststrom zugeteilt kriegen # setup data.data.counter_data["counter0"].data.set.raw_power_left = 21310 data.data.counter_data["counter0"].data.set.raw_currents_left = [32, 30, 31] data.data.counter_data["counter6"].data.set.raw_currents_left = [16, 12, 14] + mock_get_component_name_by_id = Mock(return_value="Garage") + monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mock_get_component_name_by_id) + # execution Algorithm().calc_current() @@ -111,6 +119,7 @@ class ParamsLimit(ParamsExpectedSetCurrent, ParamsExpectedCounterSet): expected_raw_power_left=0, expected_raw_currents_left_counter0=[21.53846153846154, 25.23076923076923, 25.23076923076923], expected_raw_currents_left_counter6=[16, 9.23076923076923, 9.23076923076923]), + # limit by unbalanced load ] @@ -137,19 +146,23 @@ def test_instant_charging_limit(params: ParamsLimit, all_cp_instant_charging_1p, @dataclass class ParamsControlParameter(ParamsExpectedSetCurrent, ParamsExpectedCounterSet): name: str = "" - prio_cp3: bool = False + loadmanagement_prios: List[Dict] = field(default_factory=empty_list_factory) submode_cp3: Chargemode = Chargemode.INSTANT_CHARGING - prio_cp4: bool = False submode_cp4: Chargemode = Chargemode.INSTANT_CHARGING - prio_cp5: bool = False submode_cp5: Chargemode = Chargemode.INSTANT_CHARGING cases_control_parameter = [ ParamsControlParameter(name="lift prio cp3", - prio_cp3=True, - prio_cp4=False, - prio_cp5=False, + loadmanagement_prios=[{"type": "vehicle", "id": 3}, { + "type": "group", + "label": "Fahrzeuge", + "children": [ + + {"type": "vehicle", "id": 4}, + {"type": "vehicle", "id": 5} + ] + }], expected_current_cp3=16, expected_current_cp4=8, expected_current_cp5=8, @@ -157,44 +170,44 @@ class ParamsControlParameter(ParamsExpectedSetCurrent, ParamsExpectedCounterSet) expected_raw_currents_left_counter0=[0]*3, expected_raw_currents_left_counter6=[0]*3), ParamsControlParameter(name="drop prio cp4", - prio_cp3=True, - prio_cp4=False, - prio_cp5=True, + loadmanagement_prios=[{ + "type": "group", + "label": "Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 5} + ] + }, {"type": "vehicle", "id": 4}], expected_current_cp3=15, expected_current_cp4=6, expected_current_cp5=10, expected_raw_power_left=690, - expected_raw_currents_left_counter0=[1]*3, + expected_raw_currents_left_counter0=[1.0]*3, expected_raw_currents_left_counter6=[0]*3), - ParamsControlParameter(name="lift submode cp4", + ParamsControlParameter(name="change submode cp4", + loadmanagement_prios=[{"type": "vehicle", "id": 3}, { + "type": "vehicle", "id": 4}, {"type": "vehicle", "id": 5}], submode_cp4=Chargemode.TIME_CHARGING, - expected_current_cp3=13, + expected_current_cp3=16, expected_current_cp4=10, expected_current_cp5=6, - expected_raw_power_left=2070, - expected_raw_currents_left_counter0=[3]*3, + expected_raw_power_left=0, + expected_raw_currents_left_counter0=[0]*3, expected_raw_currents_left_counter6=[0]*3), - # ParamsControlParameter(name="drop submode cp4", - # niedrigster instant modus erreicht - # submode_cp4=Chargemode.PV_CHARGING, - # expected_current_cp3=16, - # expected_current_cp4=6, - # expected_current_cp5=10, - # expected_raw_power_left=0, - # expected_raw_currents_left_counter0=[0]*3, - # expected_raw_currents_left_counter6=[0]*3) ] @pytest.mark.parametrize("params", cases_control_parameter, ids=[c.name for c in cases_control_parameter]) -def test_control_parameter_instant_charging(params: ParamsControlParameter, all_cp_instant_charging_3p, monkeypatch): +def test_control_parameter_instant_charging(params: ParamsControlParameter, + all_cp_instant_charging_3p, + monkeypatch: pytest.MonkeyPatch): # setup - data.data.cp_data["cp3"].data.control_parameter.prio = params.prio_cp3 data.data.cp_data["cp3"].data.control_parameter.submode = params.submode_cp3 - data.data.cp_data["cp4"].data.control_parameter.prio = params.prio_cp4 + data.data.cp_data["cp3"].data.config.ev = 3 data.data.cp_data["cp4"].data.control_parameter.submode = params.submode_cp4 - data.data.cp_data["cp5"].data.control_parameter.prio = params.prio_cp5 + data.data.cp_data["cp4"].data.config.ev = 4 data.data.cp_data["cp5"].data.control_parameter.submode = params.submode_cp5 + data.data.cp_data["cp5"].data.config.ev = 5 + data.data.counter_all_data.data.get.loadmanagement_prios = params.loadmanagement_prios data.data.counter_data["counter0"].data.set.raw_power_left = 22080 data.data.counter_data["counter0"].data.set.raw_currents_left = [32]*3 data.data.counter_data["counter6"].data.set.raw_currents_left = [16]*3 diff --git a/packages/control/algorithm/integration_test/pv_charging_test.py b/packages/control/algorithm/integration_test/pv_charging_test.py index 8ee3aa0e7d..1f14fa610c 100644 --- a/packages/control/algorithm/integration_test/pv_charging_test.py +++ b/packages/control/algorithm/integration_test/pv_charging_test.py @@ -289,7 +289,7 @@ def test_phase_switch(all_cp_pv_charging_3p, all_cp_charging_3p, monkeypatch): assert_counter_set(cases_phase_switch[0]) -def test_phase_switch_1p_3p(all_cp_pv_charging_1p, monkeypatch): +def test_phase_switch_1p_3p(all_cp_pv_charging_1p, monkeypatch: pytest.MonkeyPatch): # setup data.data.counter_data["counter0"].data.get.power = -3000 data.data.counter_data["counter0"].data.set.raw_power_left = cases_phase_switch[1].raw_power_left @@ -306,6 +306,9 @@ def test_phase_switch_1p_3p(all_cp_pv_charging_1p, monkeypatch): for i in range(3, 6): data.data.cp_data[f"cp{i}"].data.control_parameter.template_phases = 0 + mock_get_component_name_by_id = Mock(return_value="Garage") + monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mock_get_component_name_by_id) + # execution Algorithm().calc_current() diff --git a/packages/control/algorithm/min_current.py b/packages/control/algorithm/min_current.py index 634404d7db..822d21056d 100644 --- a/packages/control/algorithm/min_current.py +++ b/packages/control/algorithm/min_current.py @@ -3,9 +3,10 @@ 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 +from control.algorithm.filter_chargepoints import filtered_loads_to_str, get_grouped_loads_by_mode_and_counter log = logging.getLogger(__name__) @@ -16,39 +17,42 @@ def __init__(self) -> None: pass def set_min_current(self) -> None: - for mode_tuple, counter in common.mode_and_counter_generator(CONSIDERED_CHARGE_MODES_MIN_CURRENT): - preferenced_chargepoints = get_chargepoints_by_mode_and_counter(mode_tuple, f"counter{counter.num}") - 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): - missing_currents, counts = common.get_min_current(cp) + for counter in common.counter_generator(): + preferenced_loads_groups = get_grouped_loads_by_mode_and_counter(CONSIDERED_CHARGE_MODES_MIN_CURRENT, + f"counter{counter.num}") + for preferenced_loads in preferenced_loads_groups: + log.info(f"Zähler {counter.num}, Verbraucher {filtered_loads_to_str(preferenced_loads)}") + common.update_raw_data(preferenced_loads, diff_to_zero=True) + while len(preferenced_loads): + load = preferenced_loads[0] + missing_currents, counts = common.get_min_current(load) if max(missing_currents) > 0: available_currents, limit = Loadmanagement().get_available_currents( - missing_currents, counter, cp) + missing_currents, counter, load) if limit.limiting_value is not None: - cp.data.control_parameter.limit = limit - available_for_cp = common.available_current_for_cp( - cp, counts, available_currents, missing_currents) + load.data.control_parameter.limit = limit + available_for_load = common.available_current_for_load( + load, counts, available_currents, missing_currents) current = common.get_current_to_set( - cp.data.set.current, available_for_cp, cp.data.set.target_current) - if current < cp.data.control_parameter.min_current: - common.set_current_counterdiff(-(cp.data.set.current or 0), 0, cp) - if limit: - cp.set_state_and_log( + load.data.set.current, available_for_load, load.data.set.target_current) + if isinstance(load, Chargepoint) and current < load.data.control_parameter.min_current: + common.set_current_counterdiff(-(load.data.set.current or 0), 0, load) + if limit.limiting_value is not None: + load.set_state_and_log( f"Ladung kann nicht gestartet werden{limit.message}") else: common.set_current_counterdiff( - cp.data.set.target_current, - cp.data.control_parameter.min_current, - cp) + load.data.set.target_current, + load.data.control_parameter.min_current, + load) else: - if mode_tuple in CONSIDERED_CHARGE_MODES_PV_ONLY: + if (load.data.control_parameter.chargemode, + load.data.control_parameter.submode) in CONSIDERED_CHARGE_MODES_PV_ONLY: try: - if (cp.data.control_parameter.state == ChargepointState.NO_CHARGING_ALLOWED or - cp.data.control_parameter.state == ChargepointState.SWITCH_ON_DELAY): - data.data.counter_all_data.get_evu_counter().switch_on_threshold_reached(cp) + if (load.data.control_parameter.state == ChargepointState.NO_CHARGING_ALLOWED or + load.data.control_parameter.state == ChargepointState.SWITCH_ON_DELAY): + data.data.counter_all_data.get_evu_counter().switch_on_threshold_reached(load) except Exception: - log.exception(f"Fehler in der PV-gesteuerten Ladung bei {cp.num}") - cp.data.set.current = 0 + log.exception(f"Fehler in der PV-gesteuerten Ladung bei {load.num}") + load.data.set.current = 0 + preferenced_loads.pop(0) diff --git a/packages/control/algorithm/no_current.py b/packages/control/algorithm/no_current.py index 7306837565..8d0cf49a45 100644 --- a/packages/control/algorithm/no_current.py +++ b/packages/control/algorithm/no_current.py @@ -2,8 +2,8 @@ from control import data from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_NO_CURRENT -from control.algorithm.filter_chargepoints import get_chargepoints_by_chargemode from control.chargepoint.chargepoint_state import ChargepointState +from control.algorithm.filter_chargepoints import get_loads_by_chargemodes log = logging.getLogger(__name__) @@ -13,12 +13,12 @@ def __init__(self) -> None: pass def set_no_current(self) -> None: - chargepoints = get_chargepoints_by_chargemode(CONSIDERED_CHARGE_MODES_NO_CURRENT) + chargepoints = get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_NO_CURRENT) for cp in chargepoints: cp.data.set.current = 0 cp.data.control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED def set_none_current(self) -> None: - for cp in data.data.cp_data.values(): - if cp.data.set.current is None: - cp.data.set.current = 0 + for load in list(data.data.cp_data.values()) + list(data.data.consumer_data.values()): + if load.data.set.current is None: + load.data.set.current = 0 diff --git a/packages/control/algorithm/surplus_controlled.py b/packages/control/algorithm/surplus_controlled.py index c750fa2b10..0bca57a7c9 100644 --- a/packages/control/algorithm/surplus_controlled.py +++ b/packages/control/algorithm/surplus_controlled.py @@ -1,19 +1,21 @@ import logging -from typing import List, Optional, Tuple, Final +from typing import List, Tuple, Final, Union from control import data from control.algorithm import common from control.algorithm.chargemodes import (CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE, CONSIDERED_CHARGE_MODES_PV_ONLY, CONSIDERED_CHARGE_MODES_SURPLUS) -from control.algorithm.filter_chargepoints import (get_chargepoints_with_required_current_by_chargemode, - get_chargepoints_by_mode_and_counter, - get_preferenced_chargepoint_charging) +from control.algorithm.filter_chargepoints import (filtered_loads_to_str, get_loads_by_chargemodes, + get_grouped_loads_by_mode_and_counter, + get_preferenced_load_charging) from control.algorithm.utils import get_medium_charging_current from control.chargepoint.charging_type import ChargingType from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state import ChargepointState, CHARGING_STATES +from control.consumer.consumer import Consumer from control.counter import ControlRangeState, Counter from control.limiting_value import LoadmanagementLimit +from control.load_protocol import Load from control.loadmanagement import LimitingValue, Loadmanagement from helpermodules.phase_handling import voltages_mean @@ -30,71 +32,91 @@ def __init__(self) -> None: def set_surplus_current(self) -> None: common.reset_current_by_chargemode(CONSIDERED_CHARGE_MODES_SURPLUS) - for mode_tuple, counter in common.mode_and_counter_generator(CONSIDERED_CHARGE_MODES_SURPLUS): - preferenced_chargepoints, preferenced_cps_without_set_current = get_preferenced_chargepoint_charging( - get_chargepoints_by_mode_and_counter(mode_tuple, f"counter{counter.num}")) - self._set(preferenced_chargepoints, mode_tuple, counter) - if preferenced_cps_without_set_current: - for cp in preferenced_cps_without_set_current: + for counter in common.counter_generator(): + preferenced_loads_groups, preferenced_loads_without_set_current = get_preferenced_load_charging( + get_grouped_loads_by_mode_and_counter(CONSIDERED_CHARGE_MODES_SURPLUS, f"counter{counter.num}")) + if data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit: + feed_in_yield = data.data.general_data.data.chargemode_config.feed_in_yield + else: + feed_in_yield = 0 + self._set(preferenced_loads_groups, feed_in_yield, counter) + if preferenced_loads_without_set_current: + for cp in preferenced_loads_without_set_current: cp.data.set.current = cp.data.set.target_current - for cp in get_chargepoints_with_required_current_by_chargemode(CONSIDERED_CHARGE_MODES_SURPLUS): - if cp.data.control_parameter.state in CHARGING_STATES: - self._fix_deviating_evse_current(cp) + for load in get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_SURPLUS): + if isinstance(load, Chargepoint) and load.data.control_parameter.state in CHARGING_STATES: + self._fix_deviating_evse_current(load) def _set(self, - chargepoints: List[Chargepoint], - mode_tuple: Tuple[Optional[str], str, bool], + preferenced_loads_groups: List[List[Load]], + feed_in_yield: int, 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) - 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), - counter, - cp - ) - # im PV-Laden wird der Strom immer durch die Leistung begrenzt - if limit.limiting_value is not None and limit.limiting_value != LimitingValue.POWER: - cp.data.control_parameter.limit = limit - available_for_cp = common.available_current_for_cp(cp, counts, available_currents, missing_currents) - if counter.get_control_range_state() == ControlRangeState.MIDDLE: - pv_charging = data.data.general_data.data.chargemode_config.pv_charging - dif_to_old_current = available_for_cp + cp.data.set.target_current - cp.data.set.current_prev - # Wenn die Differenz zwischen altem und neuem Soll-Strom größer als der Regelbereich ist, trotzdem - # nachregeln, auch wenn der Regelbereich eingehalten wird. Sonst würde zB nicht berücksichtigt werden, - # wenn noch ein Fahrzeug dazu kommt. - if ((pv_charging.control_range[1] - pv_charging.control_range[0]) / - (sum(counter.data.get.voltages) / len(counter.data.get.voltages)) < abs(dif_to_old_current)): - current = available_for_cp + for loads in preferenced_loads_groups: + log.info(f"Zähler {counter.num}, Verbraucher {filtered_loads_to_str(loads)}") + common.update_raw_data(loads, surplus=True) + while len(loads): + load = loads[0] + missing_currents, counts = common.get_missing_currents_left(loads) + available_currents, limit = Loadmanagement().get_available_currents_surplus( + missing_currents, + voltages_mean(load.data.get.voltages), + counter, + load, + feed_in=feed_in_yield + ) + # im PV-Laden wird der Strom immer durch die Leistung begrenzt + if limit.limiting_value is not None and limit.limiting_value != LimitingValue.POWER: + load.data.control_parameter.limit = limit + available_for_cp = common.available_current_for_load(load, counts, available_currents, missing_currents) + if counter.get_control_range_state() == ControlRangeState.MIDDLE: + pv_charging = data.data.general_data.data.chargemode_config.pv_charging + dif_to_old_current = available_for_cp + load.data.set.target_current - load.data.set.current_prev + # Wenn die Differenz zwischen altem und neuem Soll-Strom größer als der Regelbereich ist, trotzdem + # nachregeln, auch wenn der Regelbereich eingehalten wird. Sonst würde zB nicht berücksichtigt + # werden,wenn noch ein Fahrzeug dazu kommt. + if ((pv_charging.control_range[1] - pv_charging.control_range[0]) / + (sum(counter.data.get.voltages) / + len(counter.data.get.voltages)) < abs(dif_to_old_current)): + current = available_for_cp + else: + # Nicht mehr freigeben, wie das Lastmanagement vorgibt + current = min(load.data.set.current_prev - load.data.set.target_current, available_for_cp) else: - # Nicht mehr freigeben, wie das Lastmanagement vorgibt - current = min(cp.data.set.current_prev - cp.data.set.target_current, available_for_cp) - else: - current = available_for_cp + current = available_for_cp - current = common.get_current_to_set(cp.data.set.current, current, cp.data.set.target_current) - self._set_loadmangement_message(current, limit, cp) - limited_current = limit_adjust_current(cp, current) - common.set_current_counterdiff( - cp.data.control_parameter.min_current, - limited_current, - cp, - surplus=True) + current = common.get_current_to_set(load.data.set.current, current, load.data.set.target_current) + self._set_loadmangement_message(current, limit, load) + limited_current = limit_adjust_current(load, current) + common.set_current_counterdiff( + load.data.control_parameter.min_current, + limited_current, + load, + surplus=True) + loads.pop(0) def _set_loadmangement_message(self, current: float, limit: LoadmanagementLimit, - chargepoint: Chargepoint) -> None: + load: Load) -> None: # Strom muss an diesem Zähler geändert werden - if (current != chargepoint.data.set.current and + if (current != load.data.set.current and # Strom erreicht nicht die vorgegebene Stromstärke - current != max(chargepoint.data.control_parameter.required_currents) and + current != max(load.data.control_parameter.required_currents) and # im PV-Laden wird der Strom immer durch die Leistung begrenzt limit.limiting_value != LimitingValue.POWER): - chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" - f"{limit.message}") + load.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" + f"{limit.message}") + + # tested + def filter_by_feed_in_limit(self, loads: List[Load]) -> Tuple[List[Union[Chargepoint, Load]], + List[Union[Chargepoint, Load]]]: + cp_with_feed_in = list(filter(lambda cp: isinstance(cp, Chargepoint) + and data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit is True, + loads)) + cp_without_feed_in = list(filter(lambda cp: isinstance(cp, Consumer) + or data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit is False, + loads)) + return cp_with_feed_in, cp_without_feed_in def _fix_deviating_evse_current(self, chargepoint: Chargepoint) -> float: """Wenn Autos nicht die volle Ladeleistung nutzen, wird unnötig eingespeist. Dann kann um den noch nicht @@ -115,18 +137,18 @@ def _fix_deviating_evse_current(self, chargepoint: Chargepoint) -> float: def check_submode_pv_charging(self) -> None: evu_counter = data.data.counter_all_data.get_evu_counter() - for cp in get_chargepoints_with_required_current_by_chargemode(CONSIDERED_CHARGE_MODES_PV_ONLY): + for load in get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_PV_ONLY): try: def phase_switch_necessary() -> bool: - return (cp.cp_state_hw_support_phase_switch() and - cp.data.get.phases_in_use != 1 and - cp.data.control_parameter.template_phases == 0) - control_parameter = cp.data.control_parameter - if cp.chargemode_changed or cp.submode_changed: + return isinstance(load, Chargepoint) and (load.cp_state_hw_support_phase_switch() and + load.data.get.phases_in_use != 1 and + load.data.control_parameter.template_phases == 0) + control_parameter = load.data.control_parameter + if load.chargemode_changed or load.submode_changed: if (control_parameter.state in CHARGING_STATES): - if cp.data.set.charging_ev_data.ev_template.data.prevent_charge_stop is False: - threshold = evu_counter.calc_switch_off_threshold(cp) - if evu_counter.calc_raw_surplus() - cp.data.set.required_power < threshold: + if load.is_charging_stop_allowed(): + threshold = evu_counter.calc_switch_off_threshold(load) + if evu_counter.calc_raw_surplus() - load.data.set.required_power < threshold: control_parameter.required_currents = [0]*3 control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED else: @@ -135,68 +157,75 @@ def phase_switch_necessary() -> bool: if ((control_parameter.state == ChargepointState.CHARGING_ALLOWED or control_parameter.state == ChargepointState.SWITCH_OFF_DELAY) and phase_switch_necessary() is False): - evu_counter.switch_off_check_threshold(cp) + evu_counter.switch_off_check_threshold(load) if control_parameter.state == ChargepointState.SWITCH_OFF_DELAY: - evu_counter.switch_off_check_timer(cp) + evu_counter.switch_off_check_timer(load) if control_parameter.state == ChargepointState.SWITCH_ON_DELAY: # Wenn charge_state False und set_current > 0, will Auto nicht laden - evu_counter.switch_on_timer_expired(cp) + evu_counter.switch_on_timer_expired(load) if control_parameter.state not in CHARGING_STATES: control_parameter.required_currents = [0]*3 except Exception: - log.exception(f"Fehler in der PV-gesteuerten Ladung bei {cp.num}") + log.exception(f"Fehler in der PV-gesteuerten Ladung bei {load.num}") def set_required_current_to_max(self) -> None: - for cp in get_chargepoints_with_required_current_by_chargemode(CONSIDERED_CHARGE_MODES_SURPLUS + - CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE): + for load in get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_SURPLUS + + CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE): try: - charging_ev_data = cp.data.set.charging_ev_data - required_currents = cp.data.control_parameter.required_currents - control_parameter = cp.data.control_parameter + control_parameter = load.data.control_parameter + required_currents = control_parameter.required_currents + if isinstance(load, Chargepoint): + charging_ev_data = load.data.set.charging_ev_data - if control_parameter.phases == 1: - max_current = charging_ev_data.ev_template.data.max_current_single_phase - else: - max_current = charging_ev_data.ev_template.data.max_current_multi_phases - - if cp.template.data.charging_type == ChargingType.AC.value: if control_parameter.phases == 1: max_current = charging_ev_data.ev_template.data.max_current_single_phase else: max_current = charging_ev_data.ev_template.data.max_current_multi_phases - else: - max_current = charging_ev_data.ev_template.data.dc_max_current + + if load.template.data.charging_type == ChargingType.AC.value: + if control_parameter.phases == 1: + max_current = charging_ev_data.ev_template.data.max_current_single_phase + else: + max_current = charging_ev_data.ev_template.data.max_current_multi_phases + else: + max_current = charging_ev_data.ev_template.data.dc_max_current + elif isinstance(load, Consumer): + max_current = load.data.config.max_power / \ + (sum(load.data.get.voltages) / len(load.data.get.voltages)) / load.data.config.connected_phases control_parameter.required_currents = [ max_current if required_currents[i] != 0 else 0 for i in range(3)] control_parameter.required_current = max_current except Exception: - log.exception(f"Fehler in der PV-gesteuerten Ladung bei {cp.num}") + log.exception(f"Fehler in der PV-gesteuerten Ladung bei {load.num}") # tested -def limit_adjust_current(chargepoint: Chargepoint, new_current: float) -> float: - if chargepoint.template.data.charging_type == ChargingType.AC.value: +def limit_adjust_current(load: Load, new_current: float) -> float: + if isinstance(load, Consumer) or load.template.data.charging_type == ChargingType.AC.value: MAX_CURRENT = 5 else: MAX_CURRENT = 30 msg = None - nominal_difference = chargepoint.data.set.charging_ev_data.ev_template.data.nominal_difference - if chargepoint.chargemode_changed or chargepoint.data.get.charge_state is False: + if isinstance(load, Chargepoint): + nominal_difference = load.data.set.charging_ev_data.ev_template.data.nominal_difference + else: + nominal_difference = 0 + if load.chargemode_changed or load.data.get.charge_state is False: return new_current else: # Um max. +/- 5A pro Zyklus regeln if (-MAX_CURRENT-nominal_difference - < new_current - get_medium_charging_current(chargepoint.data.get.currents) + < new_current - get_medium_charging_current(load.data.get.currents) < MAX_CURRENT+nominal_difference): current = new_current else: - if new_current < get_medium_charging_current(chargepoint.data.get.currents): - current = get_medium_charging_current(chargepoint.data.get.currents) - MAX_CURRENT + if new_current < get_medium_charging_current(load.data.get.currents): + current = get_medium_charging_current(load.data.get.currents) - MAX_CURRENT else: - current = get_medium_charging_current(chargepoint.data.get.currents) + MAX_CURRENT - chargepoint.set_state_and_log(msg) + current = get_medium_charging_current(load.data.get.currents) + MAX_CURRENT + load.set_state_and_log(msg) return max(current, - chargepoint.data.control_parameter.min_current, - chargepoint.data.set.target_current) + load.data.control_parameter.min_current, + load.data.set.target_current) diff --git a/packages/control/algorithm/surplus_controlled_test.py b/packages/control/algorithm/surplus_controlled_test.py index b9d75339b2..ad9960cb63 100644 --- a/packages/control/algorithm/surplus_controlled_test.py +++ b/packages/control/algorithm/surplus_controlled_test.py @@ -4,7 +4,7 @@ from control import data from control.algorithm import surplus_controlled -from control.algorithm.filter_chargepoints import get_chargepoints_with_required_current_by_chargemode +from control.algorithm.filter_chargepoints import get_loads_by_chargemodes from control.algorithm.surplus_controlled import (CONSIDERED_CHARGE_MODES_PV_ONLY, SurplusControlled, limit_adjust_current) from control.chargemode import Chargemode @@ -12,6 +12,7 @@ from control.chargepoint.chargepoint_data import Get, Set from control.chargepoint.chargepoint_template import CpTemplate from control.chargepoint.control_parameter import ControlParameter +from control.counter_all.counter_all import CounterAll from control.ev.ev import Ev @@ -30,6 +31,12 @@ def mock_cp3() -> Chargepoint: return Chargepoint(3, None) +@pytest.fixture(autouse=True) +def mock_data() -> None: + data.data_init(Mock()) + data.data.counter_all_data = CounterAll() + + @pytest.mark.parametrize("new_current, expected_current", [ pytest.param(7, 10), @@ -58,7 +65,8 @@ def test_limit_adjust_current(new_current: float, expected_current: float, monke def test_set_required_current_to_max(phases: int, required_currents: List[float], expected_currents: List[int], - monkeypatch): + monkeypatch, + mock_cp1: Chargepoint): # setup ev = Ev(0) mock_cp1.data = ChargepointData(set=Set(charging_ev_data=ev), @@ -66,7 +74,7 @@ def test_set_required_current_to_max(phases: int, required_currents=required_currents)) mock_cp1.template = CpTemplate() mock_get_chargepoints_surplus_controlled = Mock(return_value=[mock_cp1]) - monkeypatch.setattr(surplus_controlled, "get_chargepoints_with_required_current_by_chargemode", + monkeypatch.setattr(surplus_controlled, "get_loads_by_chargemodes", mock_get_chargepoints_surplus_controlled) # execution @@ -127,16 +135,20 @@ def test_get_chargepoints_submode_pv_charging(submode_1: Chargemode, expected_chargepoints = [cp_mapping[i] for i in expected_cp_indices] def setup_cp(cp: Chargepoint, submode: str) -> Chargepoint: - cp.data.set.charging_ev_data = Ev(0) + cp.data.set.charging_ev_data = Ev(cp.num) + cp.data.config.ev = cp.num cp.data.control_parameter.chargemode = Chargemode.PV_CHARGING cp.data.control_parameter.submode = submode cp.data.control_parameter.required_current = 6 return cp data.Data.cp_data = {"cp1": setup_cp(mock_cp1, submode_1), "cp2": setup_cp(mock_cp2, submode_2)} + data.data.counter_all_data = CounterAll() + data.data.counter_all_data.data.get.loadmanagement_prios = [{"type": "vehicle", "id": 1}, + {"type": "vehicle", "id": 2}] # evaluation - chargepoints = get_chargepoints_with_required_current_by_chargemode(CONSIDERED_CHARGE_MODES_PV_ONLY) + chargepoints = get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_PV_ONLY) # assertion assert chargepoints == expected_chargepoints diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index 8b327d60c1..8c45771f95 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -23,8 +23,6 @@ from typing import List, Optional, Tuple from control import data -from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_CHARGING -from control.algorithm.filter_chargepoints import get_chargepoints_with_required_current_by_chargemode from control.pv import Pv from helpermodules.constants import NO_ERROR from modules.common.abstract_device import AbstractDevice @@ -492,11 +490,8 @@ def set_power_limit_controllable_state(self): data.data.bat_data[f"bat{bat.component_config.id}"].data.get.power_limit_controllable = False def get_charge_mode_vehicle_charge(self): - chargepoint_by_chargemodes = get_chargepoints_with_required_current_by_chargemode( - CONSIDERED_CHARGE_MODES_CHARGING) # Fahrzeuge laden - vehicle_charging = (len(chargepoint_by_chargemodes) > 0 and - data.data.cp_all_data.data.get.power > 100) + vehicle_charging = data.data.cp_all_data.data.get.power > 100 # Speicher entlädt oder Speicher lädt bei gewollter PV-Ladung bat_power_valid = (self.data.get.power <= 0 or (self.data.get.power > 0 and @@ -519,9 +514,7 @@ def get_charge_mode_vehicle_charge(self): control_range_low = data.data.general_data.data.chargemode_config.pv_charging.control_range[0] control_range_high = data.data.general_data.data.chargemode_config.pv_charging.control_range[1] control_range_center = control_range_high - (control_range_high - control_range_low) / 2 - if len(chargepoint_by_chargemodes) == 0: - log.debug("Speicher-Leistung nicht begrenzen, da keine Ladepunkte in einem aktiven Lademodus sind.") - elif data.data.cp_all_data.data.get.power <= 100: + if data.data.cp_all_data.data.get.power <= 100: log.debug("Speicher-Leistung nicht begrenzen, da kein Ladepunkt lädt.") elif self.data.get.power > 0: log.debug("Speicher-Leistung nicht begrenzen, da kein Speicher entladen wird.") diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index c9787d71d8..d99ef003e1 100644 --- a/packages/control/bat_all_test.py +++ b/packages/control/bat_all_test.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import List, Optional from unittest.mock import MagicMock, Mock import pytest @@ -162,7 +162,6 @@ class BatControlParams: power_limit_mode: str = BatPowerLimitMode.MODE_NO_DISCHARGE.value power_limit_condition: str = BatPowerLimitCondition.VEHICLE_CHARGING.value bat_manual_mode: str = ManualMode.MANUAL_DISABLE.value - cps: List[Chargepoint] = field(default_factory=default_chargepoint_factory) power_limit_controllable: bool = True bat_power: float = -10 bat_soc: float = 50.0 @@ -204,8 +203,6 @@ class BatControlParams: power_limit_condition=BatPowerLimitCondition.MANUAL.value, bat_manual_mode=ManualMode.MANUAL_CHARGE.value), # Wenn Fahrzeuge Laden - BatControlParams("Fahrzeuge laden, Begrenzung immer, keine LP im Sofortladen", None, cps=[], - power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE.value), BatControlParams("Fahrzeuge laden, Begrenzung immer, Speicher lädt", None, bat_power=100, power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE.value), BatControlParams("Fahrzeuge laden, Begrenzung immer,Einspeisung", None, evu_power=-110, @@ -246,9 +243,6 @@ def test_active_bat_control(params: BatControlParams, data_, monkeypatch): data.data.counter_data["counter0"].data.get.power = params.evu_power data.data.bat_all_data = b_all - get_chargepoints_with_required_current_by_chargemode_mock = Mock(return_value=params.cps) - monkeypatch.setattr(bat_all, "get_chargepoints_with_required_current_by_chargemode", - get_chargepoints_with_required_current_by_chargemode_mock) get_evu_counter_mock = Mock(return_value=data.data.counter_data["counter0"]) monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", get_evu_counter_mock) get_bat_components_by_controllability_mock = Mock(return_value=([MqttBat(MqttBatSetup(id=2), device_id=0)], [])) @@ -328,9 +322,6 @@ def test_control_price_limit(params: BatControlParams, data_, monkeypatch): data.data.counter_data["counter0"].data.get.power = params.evu_power data.data.bat_all_data = b_all - get_chargepoints_with_required_current_by_chargemode_mock = Mock(return_value=params.cps) - monkeypatch.setattr(bat_all, "get_chargepoints_with_required_current_by_chargemode", - get_chargepoints_with_required_current_by_chargemode_mock) get_evu_counter_mock = Mock(return_value=data.data.counter_data["counter0"]) monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", get_evu_counter_mock) get_bat_components_by_controllability_mock = Mock(return_value=([MqttBat(MqttBatSetup(id=2), device_id=0)], [])) diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index a3eaf78d22..9e7b8bdddf 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -20,6 +20,7 @@ from control import phase_switch from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState from control.limiting_value import loadmanagement_limit_factory +from control.load_protocol import Load from control.text import BidiState from helpermodules.constants import DEFAULT_COLORS from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase @@ -52,7 +53,7 @@ def get_chargepoint_get_default() -> Dict: log = logging.getLogger(__name__) -class Chargepoint(ChargepointRfidMixin): +class Chargepoint(ChargepointRfidMixin, Load): """ geht alle Ladepunkte durch, prüft, ob geladen werden darf und ruft die Funktion des angesteckten Autos auf. """ MAX_FAILED_PHASE_SWITCHES = 2 @@ -233,7 +234,6 @@ def set_control_parameter(self, submode: str): else: self.data.control_parameter.chargemode = Chargemode( self.data.set.charge_template.data.chargemode.selected) - self.data.control_parameter.prio = self.data.set.charge_template.data.prio if self.template.data.charging_type == ChargingType.AC.value: self.data.control_parameter.min_current = self.data.set.charging_ev_data.ev_template.data.min_current else: @@ -678,7 +678,6 @@ def update(self, ev_list: Dict[str, Ev]) -> None: f"{self.data.set.charge_template.data.chargemode.selected}, Submodus: " f"{self.data.control_parameter.submode}, Phasen: " f"{self.data.control_parameter.phases}" - f", Priorität: {self.data.control_parameter.prio}" f", mittlerer Ist-Strom: {get_medium_charging_current(self.data.get.currents)}") except Exception: log.exception("Fehler im Prepare-Modul für Ladepunkt "+str(self.num)) @@ -798,7 +797,6 @@ def _pub_connected_vehicle(self, vehicle: Ev): charge_template=self.data.set.charge_template.data.id, ev_template=vehicle.ev_template.data.id, chargemode=self.data.set.charge_template.data.chargemode.selected, - priority=self.data.set.charge_template.data.prio, current_plan=current_plan, average_consumption=vehicle.ev_template.data.average_consump, time_charging_in_use=True if (self.data.control_parameter.submode == @@ -835,3 +833,6 @@ def failed_phase_switches_reached(self) -> bool: return True else: return False + + def is_charging_stop_allowed(self) -> bool: + return self.data.set.charging_ev_data.ev_template.data.prevent_charge_stop is False diff --git a/packages/control/chargepoint/control_parameter.py b/packages/control/chargepoint/control_parameter.py index ee671f705b..112ba4d043 100644 --- a/packages/control/chargepoint/control_parameter.py +++ b/packages/control/chargepoint/control_parameter.py @@ -17,7 +17,6 @@ class ControlParameter: "topic": "control_parameter/limit"}) min_current: int = field(default=6, metadata={"topic": "control_parameter/min_current"}) phases: int = field(default=0, metadata={"topic": "control_parameter/phases"}) - prio: bool = field(default=False, metadata={"topic": "control_parameter/prio"}) required_current: float = field(default=0, metadata={"topic": "control_parameter/required_current"}) required_currents: List[float] = field(default_factory=currents_list_factory) state: ChargepointState = field(default=ChargepointState.NO_CHARGING_ALLOWED, diff --git a/packages/control/counter.py b/packages/control/counter.py index 6253c0a28d..103c61546c 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -1,5 +1,18 @@ """Zähler-Logik """ +from modules.common.utils.component_parser import get_component_name_by_id +from modules.common.fault_state import FaultStateLevel +from helpermodules.phase_handling import convert_cp_currents_to_evu_currents +from helpermodules.constants import NO_ERROR +from helpermodules import timecheck +from dataclass_utils.factories import currents_list_factory, voltages_list_factory +from control.load_protocol import Load +from control.consumer.consumer import Consumer +from control.chargepoint.chargepoint_state import ChargepointState +from control.chargepoint.chargepoint import Chargepoint +from control.chargemode import Chargemode +from control.algorithm.utils import get_medium_charging_current +from control.algorithm.filter_chargepoints import get_loads_by_chargemodes from dataclasses import dataclass, field from enum import Enum import logging @@ -8,17 +21,6 @@ from control import data from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE -from control.algorithm.filter_chargepoints import get_chargepoints_with_required_current_by_chargemode -from control.algorithm.utils import get_medium_charging_current -from control.chargemode import Chargemode -from control.chargepoint.chargepoint import Chargepoint -from control.chargepoint.chargepoint_state import ChargepointState -from dataclass_utils.factories import currents_list_factory, voltages_list_factory -from helpermodules import timecheck -from helpermodules.constants import NO_ERROR -from helpermodules.phase_handling import convert_cp_currents_to_evu_currents -from modules.common.fault_state import FaultStateLevel -from modules.common.utils.component_parser import get_component_name_by_id log = logging.getLogger(__name__) @@ -119,7 +121,7 @@ def setup_counter(self): # tested def _get_loadmanagement_state(self) -> None: # Wenn der Zähler keine Werte liefert, darf nicht geladen werden. - connected_cps = data.data.counter_all_data.get_chargepoints_of_counter(f'counter{self.num}') + connected_cps = data.data.counter_all_data.get_loads_of_counter(f'counter{self.num}') if self.data.get.fault_state == FaultStateLevel.ERROR: if self.data.set.error_timer is None: self.data.set.error_timer = timecheck.create_timestamp() @@ -145,15 +147,18 @@ def _get_loadmanagement_state(self) -> None: def _set_current_left(self, loadmanagement_available: bool) -> None: if loadmanagement_available: currents_raw = self.data.get.currents - cp_keys = data.data.counter_all_data.get_chargepoints_of_counter(f"counter{self.num}") - for cp_key in cp_keys: - chargepoint = data.data.cp_data[cp_key] + load_keys = data.data.counter_all_data.get_loads_of_counter(f"counter{self.num}") + for load_key in load_keys: + if "cp" in load_key: + load = data.data.cp_data[load_key] + else: + load = data.data.consumer_data[load_key] try: element_current = convert_cp_currents_to_evu_currents( - chargepoint.data.config.phase_1, - chargepoint.data.get.currents) + load.data.config.phase_1, + load.data.get.currents) except KeyError: - element_current = [get_medium_charging_current(chargepoint.data.get.currents)]*3 + element_current = [get_medium_charging_current(load.data.get.currents)]*3 currents_raw = list(map(operator.sub, currents_raw, element_current)) currents_raw = list(map(operator.sub, self.data.config.max_currents, currents_raw)) if min(currents_raw) < 0: @@ -219,7 +224,7 @@ def calc_surplus(self): surplus = evu_counter.data.get.power - bat_surplus - disengageable_smarthome_power return surplus - def calc_raw_surplus(self): + def calc_raw_surplus(self) -> float: # reservierte Leistung wird nicht berücksichtigt, weil diese noch verwendet werden kann, bis die EV # eingeschaltet werden. Es darf bloß nicht für zu viele zB die Einschaltverzögerung gestartet werden. evu_counter = data.data.counter_all_data.get_evu_counter() @@ -246,7 +251,7 @@ def get_control_range_state(self) -> ControlRangeState: else: return ControlRangeState.MIDDLE - def _control_range_offset(self): + def _control_range_offset(self) -> float: if data.data.bat_all_data.data.set.regulate_up: # Regelmodus ignorieren, denn mit Regelmodus Bezug kann keine Einspeisung für den Speicher erzeugt werden. return 0 @@ -269,27 +274,38 @@ def get_usable_surplus(self, feed_in_yield: float) -> float: SWITCH_ON_EXPIRED = "Einschaltschwelle für die Dauer der Wartezeit überschritten." SWITCH_ON_MAX_PHASES = "Der Überschuss ist ausreichend, um direkt mit {} Phasen zu laden." - def calc_switch_on_power(self, chargepoint: Chargepoint) -> Tuple[float, float]: + def calc_switch_on_power(self, load: Load) -> Tuple[float, float]: surplus = self.calc_raw_surplus() - self.data.set.reserved_surplus - control_parameter = chargepoint.data.control_parameter pv_config = data.data.general_data.data.chargemode_config.pv_charging if data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit: threshold = data.data.general_data.data.chargemode_config.feed_in_yield else: - threshold = pv_config.switch_on_threshold*control_parameter.phases + threshold = pv_config.switch_on_threshold*load.data.control_parameter.phases return surplus, threshold - def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: + def get_pv_config_py_load(self, load: Load) -> Tuple[float, float, bool]: + if isinstance(load, Chargepoint): + switch_on_delay = data.data.general_data.data.chargemode_config.pv_charging.switch_on_delay + switch_on_threshold = data.data.general_data.data.chargemode_config.pv_charging.switch_on_threshold + feed_in_limit = data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit + else: + switch_on_delay = data.data.general_data.data.consumer_config.switch_on_delay + switch_on_threshold = (load.data.control_parameter.required_current * + 230 * + load.data.control_parameter.phases) + feed_in_limit = False + return switch_on_delay, switch_on_threshold, feed_in_limit + + def switch_on_threshold_reached(self, load: Load) -> None: try: message = None - control_parameter = chargepoint.data.control_parameter - feed_in_limit = data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit - pv_config = data.data.general_data.data.chargemode_config.pv_charging + control_parameter = load.data.control_parameter timestamp_switch_on_off = control_parameter.timestamp_switch_on_off + switch_on_delay, switch_on_threshold, feed_in_limit = self.get_pv_config_py_load(load) - surplus, threshold = self.calc_switch_on_power(chargepoint) - power_to_reserve = pv_config.switch_on_threshold*control_parameter.phases + surplus, threshold = self.calc_switch_on_power(load) + power_to_reserve = switch_on_threshold*control_parameter.phases if control_parameter.state == ChargepointState.SWITCH_ON_DELAY: # Wurde die Einschaltschwelle erreicht? Reservierte Leistung aus all_surplus herausrechnen, # da diese Leistung ja schon reserviert wurde, als die Einschaltschwelle erreicht wurde. @@ -297,7 +313,7 @@ def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: # Einschaltschwelle wurde unterschritten, Timer zurücksetzen timestamp_switch_on_off = None self.data.set.reserved_surplus -= power_to_reserve - message = self.SWITCH_ON_FALLEN_BELOW.format(pv_config.switch_on_threshold) + message = self.SWITCH_ON_FALLEN_BELOW.format(switch_on_threshold) control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED else: # Timer starten @@ -306,21 +322,21 @@ def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: timestamp_switch_on_off = timecheck.create_timestamp() self.data.set.reserved_surplus += power_to_reserve message = self.SWITCH_ON_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - timestamp_switch_on_off, pv_config.switch_on_delay)) + timestamp_switch_on_off, switch_on_delay)) if feed_in_limit: message += " Die Einspeisegrenze wird berücksichtigt." control_parameter.state = ChargepointState.SWITCH_ON_DELAY else: # Einschaltschwelle nicht erreicht - message = self.SWITCH_ON_NOT_EXCEEDED.format(pv_config.switch_on_threshold) + message = self.SWITCH_ON_NOT_EXCEEDED.format(switch_on_threshold) if timestamp_switch_on_off != control_parameter.timestamp_switch_on_off: control_parameter.timestamp_switch_on_off = timestamp_switch_on_off - chargepoint.set_state_and_log(message) + load.set_state_and_log(message) except Exception: log.exception("Fehler im allgemeinen PV-Modul") - def switch_on_timer_expired(self, chargepoint: Chargepoint) -> None: + def switch_on_timer_expired(self, load: Load) -> None: """ prüft, ob die Einschaltschwelle erreicht wurde, reserviert Leistung und gibt diese frei bzw. stoppt die Freigabe wenn die Ausschaltschwelle und -verzögerung erreicht wurde. @@ -331,34 +347,36 @@ def switch_on_timer_expired(self, chargepoint: Chargepoint) -> None: """ try: msg = None - pv_config = data.data.general_data.data.chargemode_config.pv_charging - control_parameter = chargepoint.data.control_parameter - charging_ev_data = chargepoint.data.set.charging_ev_data + switch_on_delay, switch_on_threshold, _ = self.get_pv_config_py_load(load) + control_parameter = load.data.control_parameter # Timer ist noch nicht abgelaufen if timecheck.check_timestamp(control_parameter.timestamp_switch_on_off, - pv_config.switch_on_delay): + switch_on_delay): msg = self.SWITCH_ON_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - control_parameter.timestamp_switch_on_off, pv_config.switch_on_delay)) + control_parameter.timestamp_switch_on_off, switch_on_delay)) # Timer abgelaufen else: control_parameter.timestamp_switch_on_off = None - self.data.set.reserved_surplus -= pv_config.switch_on_threshold*control_parameter.phases - msg = self.SWITCH_ON_EXPIRED.format(pv_config.switch_on_threshold) + self.data.set.reserved_surplus -= switch_on_threshold*control_parameter.phases + msg = self.SWITCH_ON_EXPIRED.format(switch_on_threshold) control_parameter.state = ChargepointState.WAIT_FOR_USING_PHASES - if pv_config.feed_in_limit: - feed_in_yield = data.data.general_data.data.chargemode_config.feed_in_yield - else: - feed_in_yield = 0 - ev_template = charging_ev_data.ev_template - max_phases_power = ev_template.data.min_current * ev_template.data.max_phases * 230 - if (control_parameter.submode == Chargemode.PV_CHARGING and - chargepoint.data.set.charge_template.data.chargemode.pv_charging.phases_to_use == 0 and - chargepoint.hw_supports_phase_switch() and - self.get_usable_surplus(feed_in_yield) > max_phases_power): - control_parameter.phases = ev_template.data.max_phases - msg += self.SWITCH_ON_MAX_PHASES.format(ev_template.data.max_phases) - chargepoint.set_state_and_log(msg) + if isinstance(load, Chargepoint): + charging_ev_data = load.data.set.charging_ev_data + # bei ausreichend Überschuss direkt mit max. Phasen laden + if data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit: + feed_in_yield = data.data.general_data.data.chargemode_config.feed_in_yield + else: + feed_in_yield = 0 + ev_template = charging_ev_data.ev_template + max_phases_power = ev_template.data.min_current * ev_template.data.max_phases * 230 + if (control_parameter.submode == Chargemode.PV_CHARGING and + load.data.set.charge_template.data.chargemode.pv_charging.phases_to_use == 0 and + load.hw_supports_phase_switch() and + self.get_usable_surplus(feed_in_yield) > max_phases_power): + control_parameter.phases = ev_template.data.max_phases + msg += self.SWITCH_ON_MAX_PHASES.format(ev_template.data.max_phases) + load.set_state_and_log(msg) except Exception: log.exception("Fehler im allgemeinen PV-Modul") @@ -370,78 +388,87 @@ def switch_on_timer_expired(self, chargepoint: Chargepoint) -> None: SWITCH_OFF_NOT_CHARGING = ("Da das EV nicht lädt und die Abschaltschwelle erreicht wird, " "wird die Ladefreigabe sofort entzogen.") - def switch_off_check_timer(self, chargepoint: Chargepoint) -> None: + def _get_switch_off_delay_by_load(self, load: Load) -> float: + if isinstance(load, Chargepoint): + return data.data.general_data.data.chargemode_config.pv_charging.switch_off_delay + else: + return data.data.general_data.data.consumer_config.switch_off_delay + + def switch_off_check_timer(self, load: Load) -> None: try: msg = None - pv_config = data.data.general_data.data.chargemode_config.pv_charging - control_parameter = chargepoint.data.control_parameter + control_parameter = load.data.control_parameter + switch_off_delay = self._get_switch_off_delay_by_load(load) if control_parameter.timestamp_switch_on_off is not None: if not timecheck.check_timestamp( control_parameter.timestamp_switch_on_off, - pv_config.switch_off_delay): + switch_off_delay): control_parameter.timestamp_switch_on_off = None - self.data.set.released_surplus -= chargepoint.data.set.required_power + self.data.set.released_surplus -= load.data.set.required_power msg = self.SWITCH_OFF_STOP control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED else: msg = self.SWITCH_OFF_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - control_parameter.timestamp_switch_on_off, pv_config.switch_off_delay)) - chargepoint.set_state_and_log(msg) + control_parameter.timestamp_switch_on_off, switch_off_delay)) + load.set_state_and_log(msg) except Exception: log.exception("Fehler im allgemeinen PV-Modul") - def calc_switch_off_threshold(self, chargepoint: Chargepoint) -> float: + def calc_switch_off_threshold(self, load: Load) -> float: pv_config = data.data.general_data.data.chargemode_config.pv_charging - control_parameter = chargepoint.data.control_parameter - if pv_config.feed_in_limit: + control_parameter = load.data.control_parameter + if data.data.general_data.data.chargemode_config.pv_charging.feed_in_limit: # Der EVU-Überschuss muss ggf um die Einspeisegrenze bereinigt werden. # Wnn die Leistung nicht Einspeisegrenze + Einschaltschwelle erreicht, darf die Ladung nicht pulsieren. # Abschaltschwelle um Einschaltschwelle reduzieren. threshold = (-data.data.general_data.data.chargemode_config.feed_in_yield + pv_config.switch_on_threshold*control_parameter.phases) else: - threshold = pv_config.switch_off_threshold + threshold = data.data.general_data.data.consumer_config.switch_off_threshold return threshold - def calc_switch_off(self, chargepoint: Chargepoint) -> Tuple[float, float]: + def calc_switch_off(self, load: Load) -> Tuple[float, float]: switch_off_power = self.calc_surplus() - self.data.set.released_surplus - threshold = self.calc_switch_off_threshold(chargepoint) - log.debug(f'LP{chargepoint.num} Switch-Off-Threshold prüfen: {switch_off_power}W, Schwelle: {threshold}W, ' + threshold = self.calc_switch_off_threshold(load) + log.debug(f'LP{load.num} Switch-Off-Threshold prüfen: {switch_off_power}W, Schwelle: {threshold}W, ' f'freigegebener Überschuss {self.data.set.released_surplus}W') return switch_off_power, threshold - def switch_off_check_threshold(self, chargepoint: Chargepoint) -> bool: + def switch_off_check_threshold(self, load: Load) -> bool: """ prüft, ob die Abschaltschwelle erreicht wurde und startet die Abschaltverzögerung. Ist die Abschaltverzögerung bereits aktiv, wird geprüft, ob die Abschaltschwelle wieder unterschritten wurde, sodass die Verzögerung wieder gestoppt wird. """ charge = True msg = None - charging_ev_data = chargepoint.data.set.charging_ev_data - control_parameter = chargepoint.data.control_parameter + if isinstance(load, Chargepoint): + nominal_difference = load.data.set.charging_ev_data.ev_template.data.nominal_difference + else: + nominal_difference = 0 + switch_off_delay = self._get_switch_off_delay_by_load(load) + control_parameter = load.data.control_parameter timestamp_switch_on_off = control_parameter.timestamp_switch_on_off - power_in_use, threshold = self.calc_switch_off(chargepoint) + power_in_use, threshold = self.calc_switch_off(load) if control_parameter.state == ChargepointState.SWITCH_OFF_DELAY: # Wenn automatische Phasenumschaltung aktiv, die Umschaltung abwarten, bevor die Abschaltschwelle # greift. if control_parameter.state == ChargepointState.PHASE_SWITCH_DELAY: timestamp_switch_on_off = None - self.data.set.released_surplus -= chargepoint.data.set.required_power + self.data.set.released_surplus -= load.data.set.required_power log.info("Abschaltverzögerung gestoppt, da die Verzögerung für die Phasenumschaltung aktiv ist. " + "Diese wird abgewartet, bevor die Abschaltverzögerung gestartet wird.") # Wurde die Abschaltschwelle erreicht? # Eigene Leistung aus der freigegebenen Leistung herausrechnen. - if power_in_use + chargepoint.data.set.required_power < threshold: + if power_in_use + load.data.set.required_power < threshold: timestamp_switch_on_off = None - self.data.set.released_surplus -= chargepoint.data.set.required_power + self.data.set.released_surplus -= load.data.set.required_power msg = self.SWITCH_OFF_EXCEEDED control_parameter.state = ChargepointState.CHARGING_ALLOWED else: # Wurde die Abschaltschwelle ggf. durch die Verzögerung anderer LP erreicht? - min_current = (chargepoint.data.control_parameter.min_current - + charging_ev_data.ev_template.data.nominal_difference) + min_current = (load.data.control_parameter.min_current + nominal_difference) switch_off_condition = (power_in_use > threshold or # Wenn der Speicher hochregeln soll, muss auch abgeschaltet werden. (self.calc_raw_surplus() <= 0 and @@ -449,25 +476,25 @@ def switch_off_check_threshold(self, chargepoint: Chargepoint) -> bool: # Einen nach dem anderen abschalten, bis Ladeleistung des Speichers erreicht ist # und wieder eingespeist wird. self.data.set.reserved_surplus == 0)) - if switch_off_condition and get_medium_charging_current(chargepoint.data.get.currents) <= min_current: - if not charging_ev_data.ev_template.data.prevent_charge_stop: + if (switch_off_condition and (isinstance(load, Consumer) or + get_medium_charging_current(load.data.get.currents) <= min_current)): + if load.is_charging_stop_allowed(): # EV, die ohnehin nicht laden, wird direkt die Ladefreigabe entzogen. # Würde man required_power vom released_evu_surplus subtrahieren, würden keine anderen EVs # abgeschaltet werden und nach der Abschaltverzögerung des nicht ladenden EVs wäre die # Abschaltschwelle immer noch überschritten. Würde man die tatsächliche Leistung von # released_evu_surplus subtrahieren, würde released_evu_surplus nach Ablauf der Verzögerung # nicht 0 sein, wenn sich die Ladeleistung zwischendurch verändert hat. - if chargepoint.data.get.charge_state is False: + if load.data.get.charge_state is False: charge = False msg = self.SWITCH_OFF_NOT_CHARGING control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED else: timestamp_switch_on_off = timecheck.create_timestamp() # merken, dass ein LP verzögert wird, damit nicht zu viele LP verzögert werden. - self.data.set.released_surplus += chargepoint.data.set.required_power + self.data.set.released_surplus += load.data.set.required_power msg = self.SWITCH_OFF_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - timestamp_switch_on_off, - data.data.general_data.data.chargemode_config.pv_charging.switch_off_delay)) + timestamp_switch_on_off, switch_off_delay)) control_parameter.state = ChargepointState.SWITCH_OFF_DELAY # Die Abschaltschwelle wird immer noch überschritten und es sollten weitere LP abgeschaltet # werden. @@ -475,7 +502,7 @@ def switch_off_check_threshold(self, chargepoint: Chargepoint) -> bool: msg = self.SWITCH_OFF_NO_STOP if timestamp_switch_on_off != control_parameter.timestamp_switch_on_off: control_parameter.timestamp_switch_on_off = timestamp_switch_on_off - chargepoint.set_state_and_log(msg) + load.set_state_and_log(msg) return charge def reset_switch_on_off(self, chargepoint: Chargepoint): @@ -507,7 +534,7 @@ def reset_pv_data(self): log.exception("Fehler im allgemeinen PV-Modul") -def limit_raw_power_left_to_surplus(surplus) -> None: +def limit_raw_power_left_to_surplus(surplus: float) -> None: for counter in data.data.counter_data.values(): # Zwischenzähler werden nur nach Strömen begrenzt, daher kann hier die Leistung vom EVU-Zähler gesetzt werden counter.data.set.surplus_power_left = surplus @@ -522,8 +549,7 @@ def set_raw_surplus_power_left() -> None: """ grid_counter = data.data.counter_all_data.get_evu_counter() bidi_power = 0 - chargepoint_by_chargemodes = get_chargepoints_with_required_current_by_chargemode( - CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE) + chargepoint_by_chargemodes = get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE) for cp in chargepoint_by_chargemodes: bidi_power += cp.data.get.power grid_counter.data.set.surplus_power_left = grid_counter.data.get.power * -1 + bidi_power diff --git a/packages/control/counter_all/counter_all_data.py b/packages/control/counter_all/counter_all_data.py index c137c5add5..ea9e30e8e0 100644 --- a/packages/control/counter_all/counter_all_data.py +++ b/packages/control/counter_all/counter_all_data.py @@ -62,7 +62,7 @@ class HierarchyProtocol(Protocol): @property def childless(self) -> List: ... @property - def connected_chargepoints(self) -> List: ... + def connected_loads(self) -> List: ... @property def connected_counters(self) -> List: ... @property @@ -99,9 +99,9 @@ def hierarchy_remove_item(self, id_to_find: int, keep_children: bool = True) -> class LoadmanagementPrioProtocol(Protocol): @property def data(self) -> CounterAllData: ... - def add_loadmanagement_prio_item(self, type: str, id: int) -> None: ... + def add_loadmanagement_prio_item(self, type: ComponentType, id: int) -> None: ... def remove_loadmanagement_prio_item(self, id: int) -> None: ... - def _remove_loadmanagement_prio_item(self, id: int, entry: Dict) -> None: ... + def _remove_loadmanagement_prio_item(self, id: int, entry: List[Dict]) -> bool: ... def sort_cps_by_loadmanagement_prios_nested(self, filtered_cps: List[Chargepoint]) -> List[List[Chargepoint]]: ... def generator_cps_by_loadmanagement_prios( diff --git a/packages/control/counter_all/hierarchy.py b/packages/control/counter_all/hierarchy.py index 109e3b0397..70d5ccaedb 100644 --- a/packages/control/counter_all/hierarchy.py +++ b/packages/control/counter_all/hierarchy.py @@ -26,10 +26,10 @@ def _get_all_elements_without_children_recursive(self: HierarchyProtocol, child: except Exception: log.exception("Fehler in der allgemeinen Zähler-Klasse") - def get_chargepoints_of_counter(self: HierarchyProtocol, counter: str) -> List[str]: + def get_loads_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 = [] + self.connected_loads = [] if counter == self.get_evu_counter_str(): counter_object = self.data.get.hierarchy[0] else: @@ -38,23 +38,25 @@ def get_chargepoints_of_counter(self: HierarchyProtocol, counter: str) -> List[s int(counter[7:]), self._get_entry_of_element) try: - self._get_all_cp_connected_to_counter(counter_object) + self._get_all_loads_connected_to_counter(counter_object) except KeyError: # Kein Ladepunkt unter dem Zähler pass - return self.connected_chargepoints + return self.connected_loads - def _get_all_cp_connected_to_counter(self: HierarchyProtocol, child: Dict) -> None: + def _get_all_loads_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']}") + self.connected_loads.append(f"cp{child['id']}") + elif child["type"] == ComponentType.CONSUMER.value: + self.connected_loads.append(f"consumer{child['id']}") # Wenn das Objekt noch Kinder hat, diese ebenfalls untersuchen. elif len(child["children"]) != 0: - self._get_all_cp_connected_to_counter(child) + self._get_all_loads_connected_to_counter(child) except Exception: log.exception("Fehler in der allgemeinen Zähler-Klasse") diff --git a/packages/control/counter_all/hierarchy_test.py b/packages/control/counter_all/hierarchy_test.py index 20805a2309..97354107e1 100644 --- a/packages/control/counter_all/hierarchy_test.py +++ b/packages/control/counter_all/hierarchy_test.py @@ -141,18 +141,18 @@ def test_delete_discard_children(params: ParamsItem): assert params.counter_all.data.get.hierarchy == params.expected_hierarchy -cases_get_chargepoints_of_counter = [ - ParamsItem("get_chargepoints_of_counter", hierarchy_cp(), "counter2", expected_return=["cp3", "cp5", "cp6"]), - ParamsItem("get_chargepoints_of_counter", hierarchy_two_level(), "counter0", expected_return=["cp2"]) +cases_get_loads_of_counter = [ + ParamsItem("get_loads_of_counter", hierarchy_cp(), "counter2", expected_return=["cp3", "cp5", "cp6"]), + ParamsItem("get_loads_of_counter", hierarchy_two_level(), "counter0", expected_return=["cp2"]) ] @pytest.mark.parametrize("params", - cases_get_chargepoints_of_counter, - ids=[c.name for c in cases_get_chargepoints_of_counter]) -def test_get_chargepoints_of_counter(params: ParamsItem): + cases_get_loads_of_counter, + ids=[c.name for c in cases_get_loads_of_counter]) +def test_get_loads_of_counter(params: ParamsItem): # execution - actual = params.counter_all.get_chargepoints_of_counter(params.id) + actual = params.counter_all.get_loads_of_counter(params.id) # evaluation assert actual == params.expected_return diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index 2ac9cbc403..6095210cde 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -1,17 +1,17 @@ import logging from typing import Dict, Generator, 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 +from modules.common.component_type import ComponentType 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 add_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, type: ComponentType, id: int) -> None: + self.data.get.loadmanagement_prios.append({"type": type.value, "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: @@ -54,8 +54,7 @@ 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_grouped_cps = get_preferenced_chargepoint(grouped_cps) - sorted_cps.append(sorted_grouped_cps) + sorted_cps.append(grouped_cps) elif entry["type"] == "group": sorted_grouped_cps = [] for group_entry in entry["children"]: @@ -63,25 +62,7 @@ def sort_cps_by_loadmanagement_prios_nested(self: LoadmanagementPrioProtocol, 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)) + sorted_grouped_cps.extend(grouped_cps) if len(sorted_grouped_cps) > 0: 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 diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index f6a7c01433..fb7d04a32a 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -6,6 +6,7 @@ from control.chargepoint.chargepoint import Chargepoint from control.counter_all.counter_all import CounterAll +from modules.common.component_type import ComponentType @pytest.fixture @@ -32,9 +33,10 @@ def cp3(): @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([], 2, ComponentType.VEHICLE, [{"type": "vehicle", "id": 2}], id="emtpy list"), + pytest.param([{"type": "vehicle", "id": 3}], 2, ComponentType.VEHICLE, + [{"type": "vehicle", "id": 3}, + {"type": "vehicle", "id": 2}], id="flat list"), pytest.param([ { "type": "group", @@ -45,7 +47,7 @@ def cp3(): ] }, {"type": "vehicle", "id": 2}, - ], 4, "vehicle", [ + ], 4, ComponentType.VEHICLE, [ { "type": "group", "label": "Wichtige Fahrzeuge", @@ -59,7 +61,10 @@ def cp3(): ], id="nested list"), ] ) -def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected_loadmanagement_prios: List[Dict]): +def test_add_item(loadmanagement_prios: List[Dict], + id: int, + type: ComponentType, + expected_loadmanagement_prios: List[Dict]): # setup c = CounterAll() c.data.get.loadmanagement_prios = loadmanagement_prios @@ -72,10 +77,10 @@ def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected @pytest.mark.parametrize( - "loadmanagement_prios, id, type, expected_loadmanagement_prios", + "loadmanagement_prios, id, expected_loadmanagement_prios", [ pytest.param([{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 2}], - 2, "vehicle", [{"type": "vehicle", "id": 3}], id="flat list"), + 2, [{"type": "vehicle", "id": 3}], id="flat list"), pytest.param([ { "type": "group", @@ -86,7 +91,7 @@ def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected ] }, {"type": "vehicle", "id": 2}, - ], 2, "vehicle", [ + ], 2, [ { "type": "group", "label": "Wichtige Fahrzeuge", @@ -106,7 +111,7 @@ def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected ] }, {"type": "vehicle", "id": 2}, - ], 0, "vehicle", [ + ], 0, [ { "type": "group", "label": "Wichtige Fahrzeuge", @@ -125,12 +130,11 @@ def test_add_item(loadmanagement_prios: List[Dict], id: int, type: str, expected ] }, {"type": "vehicle", "id": 2}, - ], 0, "vehicle", [{"type": "vehicle", "id": 2}], id="nested list, empty group"), + ], 0, [{"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() @@ -158,7 +162,7 @@ def test_sort_cps_by_loadmanagement_prios_nested_same_vehicle(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! + assert result[0] == [cp1, cp2, cp3] # direkte Objektvergleiche! def test_sort_cps_by_loadmanagement_prios_nested_different_vehicles(cp1, cp2, cp3): diff --git a/packages/control/counter_test.py b/packages/control/counter_test.py index c480715d1c..fa671e29b8 100644 --- a/packages/control/counter_test.py +++ b/packages/control/counter_test.py @@ -29,7 +29,7 @@ def test_set_loadmanagement_state(fault_state: FaultStateLevel, data_): # setup connected_cps_mock = Mock(return_value=["cp3", "cp4"]) - monkeypatch.setattr(data.data.counter_all_data, "get_chargepoints_of_counter", connected_cps_mock) + monkeypatch.setattr(data.data.counter_all_data, "get_loads_of_counter", connected_cps_mock) id_mock = Mock(return_value=0) monkeypatch.setattr(data.data.counter_all_data, "get_id_evu_counter", id_mock) name_mock = Mock(return_value="Test") @@ -79,8 +79,8 @@ def test_set_current_left(loadmanagement_available: bool, monkeypatch, data_): # setup - get_chargepoints_of_counter_mock = Mock(return_value=["cp3", "cp4", "cp5"]) - monkeypatch.setattr(data.data.counter_all_data, "get_chargepoints_of_counter", get_chargepoints_of_counter_mock) + get_loads_of_counter_mock = Mock(return_value=["cp3", "cp4", "cp5"]) + monkeypatch.setattr(data.data.counter_all_data, "get_loads_of_counter", get_loads_of_counter_mock) counter = Counter(0) counter.data.config.max_currents = max_currents counter.data.config.max_total_power = sum(max_currents)*230 diff --git a/packages/control/data.py b/packages/control/data.py index 5d73d59ec1..4cf11a9c27 100644 --- a/packages/control/data.py +++ b/packages/control/data.py @@ -12,6 +12,8 @@ from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_all import AllChargepoints from control.chargepoint.chargepoint_template import CpTemplate +from control.consumer.consumer import Consumer +from control.consumer.consumer_all import AllConsumers from control.pv import Pv from control.pv_all import PvAll @@ -33,6 +35,8 @@ bat_data_lock = Lock() bat_all_data_lock = Lock() graph_data_lock = Lock() +consumer_data_lock = Lock() +consumer_all_data_lock = Lock() counter_data_lock = Lock() counter_all_data_lock = Lock() cp_data_lock = Lock() @@ -69,6 +73,8 @@ def __init__(self, event_module_update_completed: Event): self.event_module_update_completed = event_module_update_completed self._bat_data: Dict[str, Bat] = {} self._bat_all_data = BatAll() + self._consumer_data: Dict[str, Consumer] = {} + self._consumer_all_data: AllConsumers = {} self._counter_data: Dict[str, Counter] = {} self._counter_all_data = CounterAll() self._cp_data: Dict[str, Chargepoint] = {} @@ -121,6 +127,24 @@ def graph_data(self) -> Graph: def graph_data(self, value): self._graph_data = value + @property + def consumer_data(self) -> Dict[str, Consumer]: + return self._consumer_data + + @consumer_data.setter + @locked(consumer_data_lock) + def consumer_data(self, value): + self._consumer_data = value + + @property + def consumer_all_data(self) -> AllConsumers: + return self._consumer_all_data + + @consumer_all_data.setter + @locked(consumer_all_data_lock) + def consumer_all_data(self, value): + self._consumer_all_data = value + @property def counter_data(self) -> Dict[str, Counter]: return self._counter_data @@ -271,6 +295,7 @@ def print_all(self): log.info(f"general_data\n{self._general_data.data}") log.info(f"general_data-display\n{self._general_data.data.extern_display_mode}") log.info(f"graph_data\n{self._graph_data.data}") + self._print_dictionaries(self._consumer_data) self._print_io_actions(self._io_actions) self._print_dictionaries(self._io_states) log.info(f"optional_data\n{self._optional_data.data}") @@ -445,6 +470,7 @@ def copy_data(self) -> None: self.__copy_system_data() self.__copy_module_data() self.graph_data = copy.deepcopy(SubData.graph_data) + self.__copy_consumer_data() except Exception: log.exception("Fehler im Prepare-Modul") @@ -464,6 +490,17 @@ def __copy_ev_data(self) -> None: except Exception: log.exception("Fehler im Prepare-Modul für EV "+str(vehicle)) + def __copy_consumer_data(self) -> None: + try: + self.consumer_data.clear() + for consumer in SubData.consumer_data: + self.consumer_data[consumer] = Consumer(SubData.consumer_data[consumer].num) + self.consumer_data[consumer].data = copy.deepcopy(SubData.consumer_data[consumer].data) + self.consumer_data[consumer].module = SubData.consumer_data[consumer].module + self.consumer_all_data = copy.deepcopy(SubData.consumer_all_data) + except Exception: + log.exception("Fehler im Prepare-Modul beim Kopieren der Verbraucherdaten") + class ModuleDataReceivedContext: """ Moduldaten erst kopieren, wenn alle Daten vom Broker empfangen wurden.""" diff --git a/packages/control/general.py b/packages/control/general.py index 717f1d5a74..088b79a713 100644 --- a/packages/control/general.py +++ b/packages/control/general.py @@ -82,9 +82,21 @@ def prices_factory() -> Prices: return Prices() +@dataclass +class ConsumerConfig: + switch_on_delay: int = field(default=60, metadata={"topic": "switch_on_delay"}) + switch_off_delay: int = field(default=60, metadata={"topic": "switch_off_delay"}) + switch_off_threshold: int = field(default=0, metadata={"topic": "power_threshold"}) + + +def consumer_config_factory() -> ConsumerConfig: + return ConsumerConfig() + + @dataclass class GeneralData: chargemode_config: ChargemodeConfig = field(default_factory=chargemode_config_factory) + consumer_config: ConsumerConfig = field(default_factory=consumer_config_factory) control_interval: int = field(default=10, metadata={"topic": "control_interval"}) extern_display_mode: str = field(default="primary", metadata={ "topic": "extern_display_mode"}) diff --git a/packages/control/loadmanagement.py b/packages/control/loadmanagement.py index 040c4106ee..a2193e8c72 100644 --- a/packages/control/loadmanagement.py +++ b/packages/control/loadmanagement.py @@ -6,6 +6,7 @@ from control.chargepoint.chargepoint import Chargepoint from control.counter import Counter from control.limiting_value import LimitingValue, LoadmanagementLimit +from control.load_protocol import Load from helpermodules.phase_handling import voltages_mean from modules.common.utils.component_parser import get_component_name_by_id @@ -17,15 +18,16 @@ class Loadmanagement: def get_available_currents(self, missing_currents: List[float], counter: Counter, - cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: + load: Load, + feed_in: int = 0) -> Tuple[List[float], LoadmanagementLimit]: raw_currents_left = counter.data.set.raw_currents_left try: - available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, cp) + available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, load) - available_currents, new_limit = self._limit_by_dimming(available_currents, cp) + available_currents, new_limit = self._limit_by_dimming(available_currents, load) limit = new_limit if new_limit.limiting_value is not None else limit - available_currents, new_limit = self._limit_by_ripple_control_receiver(available_currents, cp) + available_currents, new_limit = self._limit_by_ripple_control_receiver(available_currents, load) limit = new_limit if new_limit.limiting_value is not None else limit except ValueError as e: available_currents = [0]*3 @@ -34,8 +36,10 @@ def get_available_currents(self, available_currents, new_limit = self._limit_by_current(counter, available_currents, raw_currents_left) limit = new_limit if new_limit.limiting_value is not None else limit - available_currents, new_limit = self._limit_by_power( - counter, available_currents, voltages_mean(cp.data.get.voltages), counter.data.set.raw_power_left) + available_currents, new_limit = self._limit_by_power(counter, + available_currents, + voltages_mean(load.data.get.voltages), + counter.data.set.raw_power_left) limit = new_limit if new_limit.limiting_value is not None else limit if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str(): @@ -47,20 +51,21 @@ def get_available_currents(self, def get_available_currents_surplus(self, missing_currents: List[float], - cp_voltage: float, + load_voltage: float, counter: Counter, - cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: + load: Load, + feed_in: int = 0) -> Tuple[List[float], LoadmanagementLimit]: raw_currents_left = counter.data.set.raw_currents_left - available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, cp) + available_currents, limit = self._limit_by_dimming_via_direct_control(missing_currents, load) - available_currents, new_limit = self._limit_by_ripple_control_receiver(available_currents, cp) + available_currents, new_limit = self._limit_by_ripple_control_receiver(available_currents, load) limit = new_limit if new_limit.limiting_value is not None else limit available_currents, new_limit = self._limit_by_current(counter, available_currents, raw_currents_left) limit = new_limit if new_limit.limiting_value is not None else limit available_currents, new_limit = self._limit_by_power( - counter, available_currents, cp_voltage, counter.data.set.surplus_power_left) + counter, available_currents, load_voltage, counter.data.set.surplus_power_left) limit = new_limit if new_limit.limiting_value is not None else limit if f"counter{counter.num}" == data.data.counter_all_data.get_evu_counter_str(): @@ -138,13 +143,14 @@ def _limit_by_current(self, def _limit_by_dimming_via_direct_control(self, missing_currents: List[float], - cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: - value, limit = data.data.io_actions.dimming_via_direct_control({"type": "cp", "id": cp.num}) + load: Load) -> Tuple[List[float], LoadmanagementLimit]: + value, limit = data.data.io_actions.dimming_via_direct_control( + {"type": "cp" if isinstance(load, Chargepoint) else "consumer", "id": load.num}) if value is not None: phases = 3-missing_currents.count(0) current_per_phase = 4200 / 230 / phases available_currents = [current_per_phase - - cp.data.set.target_current if c > 0 else 0 for c in missing_currents] + load.data.set.target_current if c > 0 else 0 for c in missing_currents] log.debug(f"Dimmung per Direkt-Steuerung: {available_currents}A") else: available_currents = missing_currents @@ -152,8 +158,9 @@ def _limit_by_dimming_via_direct_control(self, def _limit_by_dimming(self, available_currents: List[float], - cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: - dimming_power_left, limit = data.data.io_actions.dimming_get_import_power_left({"type": "cp", "id": cp.num}) + load: Load) -> Tuple[List[float], LoadmanagementLimit]: + dimming_power_left, limit = data.data.io_actions.dimming_get_import_power_left( + {"type": "cp" if isinstance(load, Chargepoint) else "consumer", "id": load.num}) if dimming_power_left is not None: if sum(available_currents)*230 > dimming_power_left: phases = 3-available_currents.count(0) diff --git a/packages/control/prepare.py b/packages/control/prepare.py index e10429952e..4f559f8e87 100644 --- a/packages/control/prepare.py +++ b/packages/control/prepare.py @@ -29,10 +29,13 @@ def setup_algorithm(self) -> None: for element in level: if element["type"] == ComponentType.COUNTER.value: data.data.counter_data[f"counter{element['id']}"].setup_counter() + for consumer in data.data.consumer_data.values(): + consumer.update() for cp in data.data.cp_data.values(): cp.update(data.data.ev_data) # Nach cp update, da für die Speicher-Sperre der Lademodus bekannt sein muss. data.data.bat_all_data.setup_bat() + data.data.consumer_all_data.get_consumer_sum() data.data.cp_all_data.get_cp_sum() data.data.cp_all_data.no_charge() data.data.counter_all_data.set_home_consumption() diff --git a/packages/control/process.py b/packages/control/process.py index 9fa439b828..193b626d4c 100644 --- a/packages/control/process.py +++ b/packages/control/process.py @@ -9,6 +9,9 @@ from control.chargepoint import chargepoint from control import data from control.chargepoint.chargepoint_state import ChargepointState +from control.consumer.consumer import Consumer +from control.consumer.usage import ConsumerUsage +from helpermodules.phase_handling import voltages_mean from helpermodules.pub import Pub from helpermodules.utils._thread_handler import joined_thread_handler from modules.common.abstract_io import AbstractIoDevice @@ -42,7 +45,7 @@ def process_algorithm_results(self) -> None: if control_parameter.state == ChargepointState.NO_CHARGING_ALLOWED and cp.data.set.current != 0: control_parameter.state = ChargepointState.WAIT_FOR_USING_PHASES cp.set_timestamp_charge_start() - self._update_state(cp) + self._update_state_cp(cp) else: control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED cp.data.set.current = 0 @@ -71,6 +74,23 @@ def process_algorithm_results(self) -> None: args=(bat_component, data.data.bat_data[f"bat{bat_component.component_config.id}"].data.set.power_limit), name=f"set power limit {bat_component.component_config.id}")) + for consumer in data.data.consumer_data.values(): + try: + control_parameter = consumer.data.control_parameter + if (control_parameter.state != ChargepointState.NO_CHARGING_ALLOWED or + consumer.data.set.current != 0): + control_parameter.state = ChargepointState.CHARGING_ALLOWED + else: + control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED + self._update_state_consumer(consumer) + if consumer.data.get.state_str is None: + if consumer.data.get.charge_state: + consumer.data.get.state_str = "Verbraucher läuft." + else: + consumer.data.get.state_str = "Verbraucher wird gestartet... " + modules_threads.append(self._start_consumer(consumer)) + except Exception: + log.exception("Fehler im Process-Modul für Verbaucher "+str(consumer)) for action in data.data.io_actions.actions.values(): if isinstance(action, DimmingDirectControl): for d in action.config.configuration.devices: @@ -109,7 +129,7 @@ def process_algorithm_results(self) -> None: except Exception: log.exception("Fehler im Process-Modul") - def _update_state(self, chargepoint: chargepoint.Chargepoint) -> None: + def _update_state_cp(self, chargepoint: chargepoint.Chargepoint) -> None: """aktualisiert den Zustand des Ladepunkts. """ charging_ev = chargepoint.data.set.charging_ev_data @@ -149,3 +169,20 @@ def _start_charging(self, chargepoint: chargepoint.Chargepoint) -> Thread: return Thread(target=chargepoint.chargepoint_module.set_current, args=(chargepoint.data.set.current,), name=f"set current cp{chargepoint.chargepoint_module.config.id}") + + def _update_state_consumer(self, consumer: Consumer) -> None: + consumer.data.set.current = round(consumer.data.set.current, 2) + consumer.data.set.power = consumer.data.set.current * \ + voltages_mean(consumer.data.get.voltages) * consumer.data.config.connected_phases + log.info(f"Verbraucher{consumer.num}: set current {consumer.data.set.current} A, " + f"state {ChargepointState(consumer.data.control_parameter.state).name}") + + def _start_consumer(self, consumer: Consumer) -> Thread: + if consumer.data.usage.type in (ConsumerUsage.CONTINUOUS, + ConsumerUsage.SUSPENDABLE_ONOFF): + return Thread( + target=consumer.module.switch_on if consumer.data.set.current > 0 else consumer.module.switch_off, + name=f"set current consumer{consumer.num}") + return Thread(target=consumer.module.set_power_limit, + args=(consumer.data.set.power,), + name=f"set current consumer{consumer.num}") diff --git a/packages/helpermodules/abstract_plans.py b/packages/helpermodules/abstract_plans.py index 20b3ce6b98..8e2c2b7dd1 100644 --- a/packages/helpermodules/abstract_plans.py +++ b/packages/helpermodules/abstract_plans.py @@ -106,3 +106,23 @@ class TimeChargingPlan(TimeframePlan): class AutolockPlan(TimeframePlan): id: Optional[int] = None name: str = "neuer Plan für Sperren nach Uhrzeit" + + +@dataclass +class TimeChargingPlanConsumer(TimeframePlan): + id: Optional[int] = None + min_bat_soc: Optional[int] = None + name: str = "neuer Verbraucher-Plan" + + +@dataclass +class SuspendableScheduledPlanConsumer(TimeframePlan): + id: Optional[int] = None + name: str = "neuer Verbraucher-Plan" + duration: int = 60 + + +@dataclass +class ContinuousScheduledPlanConsumer(TimeframePlan): + id: Optional[int] = None + name: str = "neuer Verbraucher-Plan" diff --git a/packages/helpermodules/auto_str.py b/packages/helpermodules/auto_str.py index 93fcc047c1..4f21210816 100644 --- a/packages/helpermodules/auto_str.py +++ b/packages/helpermodules/auto_str.py @@ -10,5 +10,12 @@ def __str__(self) -> str: ', '.join('%s=%s' % item for item in vars(self).items()) ) + def __repr__(self) -> str: + return '%s(%s)' % ( + type(self).__name__, + ', '.join('%s=%s' % item for item in vars(self).items()) + ) + cls.__str__ = __str__ + cls.__repr__ = __repr__ return cls diff --git a/packages/helpermodules/changed_values_handler.py b/packages/helpermodules/changed_values_handler.py index e191f5851d..d32e5d3430 100644 --- a/packages/helpermodules/changed_values_handler.py +++ b/packages/helpermodules/changed_values_handler.py @@ -94,6 +94,9 @@ def pub_changed_values(self): for key, value in data.data.counter_data.items(): self._update_value(f"openWB/set/counter/{value.num}/", self.prev_data.counter_data[key].data, value.data) + for key, value in data.data.consumer_data.items(): + self._update_value(f"openWB/set/consumer/{value.num}/", + self.prev_data.consumer_data[key].data, value.data) for key, value in data.data.pv_data.items(): self._update_value(f"openWB/set/pv/{value.num}/", self.prev_data.pv_data[key].data, value.data) # chargepoint, ev template, autolock, time and scheduled charging plans mutable_by_algorithm immer false diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 55998e30e5..530bfa3b1c 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -17,8 +17,12 @@ from control.chargepoint import chargepoint from control.chargepoint.chargepoint_template import get_chargepoint_template_default +from control.consumer.consumer_data import GET_DEFAULTS_BY_USAGE, Set, get_plan_class_for_usage +from control.consumer.usage import ConsumerUsage +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 control.consumer.consumer_data import ConsumerConfig from helpermodules import pub from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan from helpermodules.utils.run_command import run_command @@ -39,7 +43,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, counter, counter_all, data, pv +from control import bat, bridge, counter, pv from control.ev import ev from modules.chargepoints.internal_openwb.chargepoint_module import ChargepointModule from modules.chargepoints.internal_openwb.config import InternalChargepointMode @@ -59,7 +63,8 @@ class Command: "nested payload": [("autolock_plan", "openWB/chargepoint/template/[0-9]+$", -1), ("charge_template_scheduled_plan", "openWB/vehicle/template/charge_template/[0-9]+$", -1), - ("charge_template_time_charging_plan", "openWB/vehicle/template/charge_template/[0-9]+$", -1)], + ("charge_template_time_charging_plan", "openWB/vehicle/template/charge_template/[0-9]+$", -1), + ("usage_plan", "openWB/consumer/[0-9]+/usage$", -1)], "topic": [("mqtt_bridge", "openWB/system/mqtt/bridge", -1), ("vehicle", "openWB/vehicle/[0-9]+/name$", 0)], @@ -86,7 +91,8 @@ def _get_max_ids(self) -> None: "autolock_plan": lambda p: p.get("autolock", {}).get("plans", []), "charge_template_scheduled_plan": lambda p: p.get("chargemode", {}).get("scheduled_charging", {}).get("plans", []), - "charge_template_time_charging_plan": lambda p: p.get("time_charging", {}).get("plans", []) + "charge_template_time_charging_plan": lambda p: p.get("time_charging", {}).get("plans", []), + "usage_plan": lambda p: p.get("plans", []) } try: received_topics = ProcessBrokerBranch("").get_max_id() @@ -96,7 +102,7 @@ def _get_max_ids(self) -> None: for topic, payload in received_topics.items(): try: if max_id_type == "nested payload": - if re.search(topic_str, topic) is not None: + if re.search(topic_str, topic) is not None and payload is not None: extractor = plan_extractors.get(id_topic) for plan in extractor(payload): try: @@ -199,24 +205,6 @@ def removeDevice(self, connection_id: str, payload: dict) -> None: f'Die ID \'{payload["data"]["id"]}\' ist größer als die maximal vergebene ID \'{self.max_id_device}\'.', MessageType.ERROR) - def addIoAction(self, connection_id: str, payload: dict) -> None: - new_id = self.max_id_io_action + 1 - dev = importlib.import_module(f".io_actions.{'.'.join(payload['data']['type'])}.api", - "modules") - descriptor = dev.device_descriptor.configuration_factory() - device_default = dataclass_utils.asdict(descriptor) - device_default["id"] = new_id - Pub().pub(f'openWB/set/io/action/{new_id}/config', device_default) - self.max_id_io_action = new_id - Pub().pub("openWB/set/command/max_id/io_action", self.max_id_io_action) - # add ACL roles for IO action access, if user management is active - if SubData.system_data["system"].data["security"]["user_management_active"]: - add_acl_role("io-action--access", new_id) - pub_user_message( - payload, connection_id, - f'Neue IO-Aktion vom Typ \'{" / ".join(payload["data"]["type"])}\' mit ID \'{new_id}\' hinzugefügt.', - MessageType.SUCCESS) - def removeIoAction(self, connection_id: str, payload: dict) -> None: if self.max_id_io_action >= payload["data"]["id"]: ProcessBrokerBranch(f'io/action/{payload["data"]["id"]}/').remove_topics() @@ -775,9 +763,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) + SubData.counter_all_data.add_loadmanagement_prio_item(ComponentType.VEHICLE, new_id) Pub().pub("openWB/set/counter/get/loadmanagement_prios", - data.data.counter_all_data.data.get.loadmanagement_prios) + SubData.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: @@ -793,9 +781,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"]) + SubData.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) + SubData.counter_all_data.data.get.loadmanagement_prios) pub_user_message( payload, connection_id, f'EV mit ID \'{payload["data"]["id"]}\' gelöscht.', MessageType.SUCCESS) @@ -1154,6 +1142,110 @@ def resetUserPassword(self, connection_id: str, payload: dict) -> None: "Der Token ist ungültig oder abgelaufen.", MessageType.ERROR) + def addConsumer(self, connection_id: str, payload: dict) -> None: + """ sendet das Topic, zu dem ein neues Device erstellt werden soll. + """ + new_id = self.max_id_hierarchy + 1 + dev = importlib.import_module(f'.consumers.{payload["data"]["vendor"]}' + f'.{payload["data"]["type"]}.consumer', + "modules") + consumer_default = dataclass_utils.asdict(dev.device_descriptor.configuration_factory()) + consumer_default["id"] = new_id + Pub().pub(f'openWB/set/consumer/{new_id}/module', consumer_default) + Pub().pub(f"openWB/set/consumer/{new_id}/config", dataclass_utils.asdict(ConsumerConfig())) + Pub().pub(f"openWB/set/consumer/{new_id}/extra_meter", None) + Pub().pub(f"openWB/set/consumer/{new_id}/set", dataclass_utils.asdict(Set())) + Pub().pub(f"openWB/set/consumer/{new_id}/usage", None) + self.max_id_hierarchy = new_id + Pub().pub("openWB/set/command/max_id/hierarchy", new_id) + try: + evu_counter = SubData.counter_all_data.get_id_evu_counter() + SubData.counter_all_data.hierarchy_add_item_below( + new_id, ComponentType.CONSUMER, evu_counter) + Pub().pub("openWB/set/counter/get/hierarchy", SubData.counter_all_data.data.get.hierarchy) + except (TypeError, IndexError): + pub_user_message(payload, connection_id, + "Bitte zuerst einen EVU-Zähler konfigurieren oder in den Steuerungsmodus 'secondary' " + "umschalten.", + MessageType.ERROR) + try: + SubData.counter_all_data.add_loadmanagement_prio_item(ComponentType.CONSUMER, new_id) + Pub().pub("openWB/set/counter/get/loadmanagement_prios", + SubData.counter_all_data.data.get.loadmanagement_prios) + except (TypeError, IndexError) as e: + pub_user_message(payload, connection_id, + str(e), + MessageType.ERROR) + pub_user_message( + payload, connection_id, + f'Neues Gerät vom Typ \'{payload["data"]["type"]}\' mit ID \'{new_id}\' hinzugefügt.', + MessageType.SUCCESS) + + def removeConsumer(self, connection_id: str, payload: dict) -> None: + """ löscht ein Ladepunkt. + """ + if self.max_id_hierarchy < payload["data"]["consumer_id"]: + pub_user_message( + payload, connection_id, + f'Die ID \'{payload["data"]["consumer_id"]}\' ist größer als die maximal vergebene ' + f'ID \'{self.max_id_hierarchy}\'.', MessageType.ERROR) + ProcessBrokerBranch(f'consumer/{payload["data"]["consumer_id"]}/').remove_topics() + SubData.counter_all_data.hierarchy_remove_item(payload["data"]["consumer_id"]) + Pub().pub("openWB/set/counter/get/hierarchy", SubData.counter_all_data.data.get.hierarchy) + SubData.counter_all_data.remove_loadmanagement_prio_item(payload["data"]["consumer_id"]) + Pub().pub("openWB/set/counter/get/loadmanagement_prios", SubData.counter_all_data.data.get.loadmanagement_prios) + pub_user_message(payload, connection_id, + f'Ladepunkt mit ID \'{payload["data"]["consumer_id"]}\' gelöscht.', MessageType.SUCCESS) + + def selectUsage(self, connection_id: str, payload: dict) -> None: + """ weist einem Verbraucher eine Nutzung zu. + """ + usage = ConsumerUsage(payload["data"]["usage"]) + usage_config = GET_DEFAULTS_BY_USAGE[usage] + Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage_config)) + pub_user_message( + payload, connection_id, + f'Dem Verbraucher mit ID \'{payload["data"]["consumer_id"]}\' wurde die Nutzung ' + f'\'{payload["data"]["usage"]}\' zugewiesen.', + MessageType.SUCCESS) + + def addUsagePlan(self, connection_id: str, payload: dict) -> None: + """ sendet das Topic, zu dem ein neuer Zielladen-Plan erstellt werden soll. + """ + usage = SubData.consumer_data[f'consumer{payload["data"]["consumer_id"]}'].data.usage + plan = get_plan_class_for_usage(usage.type)() + new_id = self.max_id_usage_plan + 1 + plan.id = new_id + usage.plans.append(plan) + Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage)) + self.max_id_usage_plan = new_id + Pub().pub("openWB/set/command/max_id/usage_plan", new_id) + pub_user_message( + payload, connection_id, + f'Neuer Zielladen-Plan mit ID \'{new_id}\' zu Profil ' + f'\'{payload["data"]["template"]}\' hinzugefügt.', + MessageType.SUCCESS) + + def removeUsagePlan(self, connection_id: str, payload: dict) -> None: + """ löscht einen Zielladen-Plan. + """ + if self.max_id_usage_plan < payload["data"]["plan"]: + log.error( + payload, connection_id, + f'Die ID \'{payload["data"]["plan"]}\' ist größer als die maximal vergebene ' + f'ID \'{self.max_id_usage_plan}\'.', MessageType.ERROR) + usage = SubData.consumer_data[f'consumer{payload["data"]["consumer_id"]}'].data.usage + for plan in usage.plans: + if plan.id == payload["data"]["plan"]: + usage.plans.remove(plan) + break + Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage)) + pub_user_message( + payload, connection_id, + f'Zielladen-Plan mit ID \'{payload["data"]["plan"]}\' von Profil ' + f'{payload["data"]["template"]}\' gelöscht.', + MessageType.SUCCESS) + class ErrorHandlingContext: def __init__(self, payload: dict, connection_id: str): diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 03fcb26733..69e58d3244 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -105,6 +105,8 @@ def on_message(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage): self.process_internal_chargepoint_topic(msg) elif "openWB/set/LegacySmartHome/" in msg.topic: self.process_legacy_smart_home_topic(msg) + elif "openWB/set/consumer/" in msg.topic: + self.process_consumer_topic(msg) def _validate_value(self, msg: mqtt.MQTTMessage, data_type, ranges=[], collection=None, pub_json=False, retain: bool = True): @@ -512,34 +514,8 @@ def process_chargepoint_topic(self, msg: mqtt.MQTTMessage): msg, int, [(0, float("inf"))], pub_json=True) elif "get" in msg.topic: self.process_chargepoint_get_topics(msg) - elif "/control_parameter/required_current" in msg.topic: - if hardware_configuration.get_hardware_configuration_setting("dc_charging"): - self._validate_value(msg, float, [(0, 0), (6, 32), (0, 450)]) - else: - self._validate_value(msg, float, [(6, 32), (0, 0)]) - elif ("/control_parameter/phases" in msg.topic or - "/control_parameter/template_phases" in msg.topic): - self._validate_value(msg, int, [(0, 3)]) - elif "/control_parameter/failed_phase_switches" in msg.topic: - self._validate_value(msg, int, [(0, 4)]) - elif ("/control_parameter/submode" in msg.topic or - "/control_parameter/chargemode" in msg.topic): - self._validate_value(msg, str) - elif "/control_parameter/limit" in msg.topic: - self._validate_value(msg, "json") - elif "/control_parameter/prio" in msg.topic: - self._validate_value(msg, bool) - elif "/control_parameter/current_plan" in msg.topic: - self._validate_value(msg, int) - elif ("/control_parameter/min_current" in msg.topic or - "/control_parameter/timestamp_switch_on_off" in msg.topic or - "/control_parameter/timestamp_charge_start" in msg.topic or - "/control_parameter/timestamp_chargemode_changed" in msg.topic or - "/control_parameter/timestamp_last_phase_switch" in msg.topic or - "/control_parameter/timestamp_phase_switch_buffer_start" in msg.topic): - self._validate_value(msg, float, [(0, float("inf"))]) - elif "/control_parameter/state" in msg.topic: - self._validate_value(msg, int, [(0, 7)]) + elif "/control_parameter/" in msg.topic: + self.process_control_parameter_topics(msg) elif "/disable_after_unplug" in msg.topic: self._validate_value(msg, bool, pub_json=True) else: @@ -549,6 +525,39 @@ def process_chargepoint_topic(self, msg: mqtt.MQTTMessage): except Exception: log.exception(f"Fehler im setdata-Modul: Topic {msg.topic}, Value: {msg.payload}") + def process_control_parameter_topics(self, msg): + if "/control_parameter/required_current" in msg.topic: + if "chargepoint" in msg.topic: + if hardware_configuration.get_hardware_configuration_setting("dc_charging"): + self._validate_value(msg, float, [(0, 0), (6, 32), (0, 450)]) + else: + self._validate_value(msg, float, [(6, 32), (0, 0)]) + elif "consumer" in msg.topic: + self._validate_value(msg, float, [(0, float("inf"))]) + elif ("/control_parameter/phases" in msg.topic or + "/control_parameter/template_phases" in msg.topic): + self._validate_value(msg, int, [(0, 3)]) + elif "/control_parameter/failed_phase_switches" in msg.topic: + self._validate_value(msg, int, [(0, 4)]) + elif ("/control_parameter/submode" in msg.topic or + "/control_parameter/chargemode" in msg.topic): + self._validate_value(msg, str) + elif "/control_parameter/limit" in msg.topic: + self._validate_value(msg, "json") + elif "/control_parameter/prio" in msg.topic: + self._validate_value(msg, bool) + elif "/control_parameter/current_plan" in msg.topic: + self._validate_value(msg, int) + elif ("/control_parameter/min_current" in msg.topic or + "/control_parameter/timestamp_switch_on_off" in msg.topic or + "/control_parameter/timestamp_charge_start" in msg.topic or + "/control_parameter/timestamp_chargemode_changed" in msg.topic or + "/control_parameter/timestamp_last_phase_switch" in msg.topic or + "/control_parameter/timestamp_phase_switch_buffer_start" in msg.topic): + self._validate_value(msg, float, [(0, float("inf"))]) + elif "/control_parameter/state" in msg.topic: + self._validate_value(msg, int, [(0, 7)]) + def process_chargepoint_get_topics(self, msg): if ("/get/voltages" in msg.topic): self._validate_value(msg, float, [(0, 500)], collection=list) @@ -771,9 +780,12 @@ def process_general_topic(self, msg: mqtt.MQTTMessage): elif ("openWB/set/general/chargemode_config/feed_in_yield" in msg.topic or "openWB/set/general/chargemode_config/pv_charging/switch_on_threshold" in msg.topic or "openWB/set/general/chargemode_config/pv_charging/switch_on_delay" in msg.topic or - "openWB/set/general/chargemode_config/pv_charging/switch_off_delay" in msg.topic): + "openWB/set/general/chargemode_config/pv_charging/switch_off_delay" in msg.topic or + "openWB/set/general/consumer/config/switch_on_delay" in msg.topic or + "openWB/set/general/consumer/config/switch_off_delay" in msg.topic): self._validate_value(msg, int, [(0, float("inf"))]) - elif "openWB/set/general/chargemode_config/pv_charging/switch_off_threshold" in msg.topic: + elif ("openWB/set/general/chargemode_config/pv_charging/switch_off_threshold" in msg.topic or + "openWB/set/general/consumer/config/switch_off_threshold" in msg.topic): self._validate_value(msg, float) elif "openWB/set/general/chargemode_config/pv_charging/phase_switch_delay" in msg.topic: self._validate_value(msg, int, [(5, 180)]) @@ -846,6 +858,8 @@ def process_io_topic(self, msg: mqtt.MQTTMessage): def process_mqtt_topic(self, msg: mqtt.MQTTMessage): if "openWB/set/mqtt/chargepoint/" in msg.topic: self.process_chargepoint_get_topics(msg) + elif "openWB/set/mqtt/consumer/" in msg.topic: + self.process_consumer_topic(msg) elif "openWB/set/mqtt/counter/" in msg.topic: self.process_counter_topic(msg) elif "openWB/set/mqtt/bat/" in msg.topic: @@ -1216,5 +1230,47 @@ def process_legacy_smart_home_topic(self, msg): except Exception: log.exception(f"Fehler im setdata-Modul: Topic {msg.topic}, Value: {msg.payload}") + def process_consumer_topic(self, msg): + try: + if ("openWB/set/consumer/get/imported" in msg.topic or + "openWB/set/consumer/get/exported" in msg.topic or + "openWB/set/consumer/get/power" in msg.topic or + "openWB/set/consumer/get/daily_imported" in msg.topic or + "openWB/set/consumer/get/daily_exported" in msg.topic): + self._validate_value(msg, float) + elif (re.search("openWB/set/consumer/[0-9]+/module$", msg.topic) is not None or + re.search("openWB/set/consumer/[0-9]+/config$", msg.topic) is not None or + re.search("openWB/set/consumer/[0-9]+/usage$", msg.topic) is not None): + self._validate_value(msg, "json") + elif re.search("openWB/set/consumer/[0-9]+/extra_meter$", msg.topic) is not None: + self._validate_value(msg, int) + elif (re.search("consumer/[0-9]+/get/power$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/power$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/current$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/set_power$", msg.topic) is not None): + self._validate_value(msg, float) + elif (re.search("consumer/[0-9]+/get/currents$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/voltages$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/powers$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/temperatures$", msg.topic) is not None): + self._validate_value(msg, float, collection=list) + elif (re.search("consumer/[0-9]+/get/imported$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/daily_imported$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/timestamp_last_current_set$", msg.topic) is not None): + self._validate_value(msg, float, [(0, float("inf"))]) + elif re.search("consumer/[0-9]+/get/fault_state$", msg.topic) is not None: + self._validate_value(msg, int, [(0, 2)]) + elif (re.search("consumer/[0-9]+/get/fault_str$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/state_str$", msg.topic) is not None): + self._validate_value(msg, str) + elif re.search("consumer/[0-9]+/get/state$", msg.topic) is not None: + self._validate_value(msg, bool) + elif re.search("consumer/[0-9]+/control_parameter/", msg.topic) is not None: + self.process_control_parameter_topics(msg) + else: + self.__unknown_topic(msg) + except Exception: + log.exception(f"Fehler im setdata-Modul: Topic {msg.topic}, Value: {msg.payload}") + def _get_ramdisk_path(self) -> Path: return Path(__file__).resolve().parents[2]/"ramdisk" diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 503259077d..3f1320c875 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -16,6 +16,10 @@ 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.consumer.consumer import Consumer +from control.consumer.consumer_all import AllConsumers +from control.consumer.consumer_data import GET_CLASS_BY_USAGE +from control.consumer.usage import ConsumerUsage from control.ev.charge_template import ChargeTemplate, ChargeTemplateData from control.ev import ev from control.ev.ev_template import EvTemplate, EvTemplateData @@ -75,6 +79,8 @@ class SubData: optional_data = optional.Optional() system_data = {"system": system.System()} graph_data = graph.Graph() + consumer_data: Dict[str, Consumer] = {} + consumer_all_data = AllConsumers() def __init__(self, event_ev_template: Event, @@ -140,6 +146,7 @@ def on_connect(self, client: mqtt.Client, userdata, flags: dict, rc: int): ("openWB/internal_io/#", 2), ("openWB/optional/#", 2), ("openWB/counter/#", 2), + ("openWB/consumer/#", 2), ("openWB/command/command_completed", 2), ("openWB/internal_chargepoint/#", 2), # MQTT Bridge Topics vor "openWB/system/+" abonnieren, damit sie auch vor @@ -200,6 +207,8 @@ def on_message(self, client: mqtt.Client, userdata, msg: mqtt.MQTTMessage): self.process_legacy_smarthome_topic(client, self.counter_all_data, msg) elif "openWB/command/command_completed" == msg.topic: self.event_command_completed.set() + elif "openWB/consumer/" in msg.topic: + self.process_consumer_topic(client, self.consumer_data, msg) else: log.warning("unknown subdata-topic: "+str(msg.topic)) @@ -1214,3 +1223,47 @@ def process_legacy_smarthome_topic(self, client: mqtt.Client, var: counter_all.C var.data.set.smarthome_power_excluded_from_home_consumption = decode_payload(msg.payload) except Exception: log.exception("Fehler im subdata-Modul") + + def process_consumer_topic(self, client: mqtt.Client, var: Dict[str, Consumer], msg: mqtt.MQTTMessage): + try: + index = get_index(msg.topic) + if re.search("openWB/consumer/[0-9]+/module", msg.topic) is not None: + if decode_payload(msg.payload) == "": + if "consumer"+index in var: + var.pop("consumer"+index) + else: + log.error("Es konnte kein Verbraucher mit der ID " + + str(index)+" gefunden werden.") + else: + if f"consumer{index}" not in var: + var[f"consumer{index}"] = Consumer(int(index)) + consumer_config = decode_payload(msg.payload) + con = importlib.import_module( + f".consumers.{consumer_config['vendor']}.{consumer_config['type']}.consumer", + "modules") + config = dataclass_from_dict(con.device_descriptor.configuration_factory, consumer_config) + var["consumer"+index].module = con.create_consumer(config) + var["consumer"+index].data.module = config + elif re.search("openWB/consumer/[0-9]+/config", msg.topic) is not None: + self.set_json_payload_class(var["consumer"+index].data.config, msg) + elif re.search("openWB/consumer/[0-9]+/get", msg.topic) is not None: + self.set_json_payload_class(var["consumer"+index].data.get, msg) + elif re.search("openWB/consumer/[0-9]+/extra_meter", msg.topic) is not None: + self.set_json_payload_class(var[f"consumer{index}"].data, msg) + elif re.search("openWB/consumer/[0-9]+/usage$", msg.topic) is not None: + payload = decode_payload(msg.payload) + if payload is not None: + var[f"consumer{index}"].data.usage = dataclass_from_dict( + GET_CLASS_BY_USAGE[ConsumerUsage(payload["type"])], + payload) + elif re.search("openWB/consumer/[0-9]+/control_parameter/", msg.topic) is not None: + if re.search("openWB/consumer/[0-9]+/control_parameter/limit", msg.topic) is not None: + payload = decode_payload(msg.payload) + var[f"consumer{index}"].data.control_parameter.limit = dataclass_from_dict( + LoadmanagementLimit, payload) + else: + self.set_json_payload_class(var[f"consumer{index}"].data.control_parameter, msg) + elif re.search("/consumer/get/", msg.topic) is not None: + self.set_json_payload_class(self.consumer_all_data.data.get, msg) + except Exception: + log.exception("Fehler im subdata-Modul") diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index a65ff63018..e0ac8bc70e 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -9,7 +9,7 @@ with ImportErrorContext(): from dateutil.relativedelta import relativedelta -from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan +from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan, TimeChargingPlanConsumer log = logging.getLogger(__name__) @@ -44,7 +44,7 @@ def is_now_in_locking_time(now: datetime.datetime, return False -T = TypeVar("T", AutolockPlan, TimeChargingPlan) +T = TypeVar("T", AutolockPlan, TimeChargingPlan, TimeChargingPlanConsumer) def check_plans_timeframe(plans: List[T]) -> Optional[T]: diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 6eb6945047..966b1e04ab 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -185,6 +185,24 @@ class UpdateConfig: "^openWB/command/[A-Za-z0-9_]+/error$", "^openWB/command/todo$", + "^openWB/consumer/get/power$", + "^openWB/consumer/get/exported$", + "^openWB/consumer/get/imported$", + "^openWB/consumer/get/daily_exported$", + "^openWB/consumer/get/daily_imported$", + "^openWB/consumer/[0-9]+/module$", + "^openWB/consumer/[0-9]+/config$", + "^openWB/consumer/[0-9]+/extra_meter$", + "^openWB/consumer/[0-9]+/usage$", + "^openWB/consumer/[0-9]+/get/currents$", + "^openWB/consumer/[0-9]+/get/fault_state$", + "^openWB/consumer/[0-9]+/get/fault_str$", + "^openWB/consumer/[0-9]+/get/powers$", + "^openWB/consumer/[0-9]+/get/power$", + "^openWB/consumer/[0-9]+/get/state$", + "^openWB/consumer/[0-9]+/get/state_str$", + "^openWB/consumer/[0-9]+/get/voltages$", + "^openWB/counter/config/consider_less_charging$", "^openWB/counter/config/home_consumption_source_id$", "^openWB/counter/get/hierarchy$", @@ -246,6 +264,9 @@ class UpdateConfig: "^openWB/general/chargemode_config/pv_charging/bat_power_reserve$", "^openWB/general/chargemode_config/pv_charging/bat_power_reserve_active$", "^openWB/general/chargemode_config/pv_charging/retry_failed_phase_switches$", + "^openWB/general/consumer/config/switch_on_delay$", + "^openWB/general/consumer/config/switch_off_delay$", + "^openWB/general/consumer/config/switch_off_threshold$", # obsolet, Daten hieraus müssen nach prices/ überführt werden "^openWB/general/price_kwh$", "^openWB/general/prices/bat$", @@ -318,6 +339,12 @@ class UpdateConfig: "^openWB/mqtt/chargepoint/[0-9]+/get/voltages$", "^openWB/mqtt/chargepoint/[0-9]+/get/power_factors$", "^openWB/mqtt/chargepoint/[0-9]+/get/rfid$", + "^openWB/mqtt/consumer/[0-9]+/get/currents$", + "^openWB/mqtt/consumer/[0-9]+/get/imported$", + "^openWB/mqtt/consumer/[0-9]+/get/exported$", + "^openWB/mqtt/consumer/[0-9]+/get/power$", + "^openWB/mqtt/consumer/[0-9]+/get/powers$", + "^openWB/mqtt/consumer/[0-9]+/get/voltages$", "^openWB/mqtt/counter/[0-9]+/get/currents$", "^openWB/mqtt/counter/[0-9]+/get/imported$", "^openWB/mqtt/counter/[0-9]+/get/exported$", @@ -503,6 +530,7 @@ class UpdateConfig: "^openWB/system/backup_password$", "^openWB/system/configurable/chargepoints$", "^openWB/system/configurable/chargepoints_internal$", + "^openWB/system/configurable/consumers$", "^openWB/system/configurable/devices_components$", "^openWB/system/configurable/flexible_tariffs$", "^openWB/system/configurable/grid_fees$", @@ -625,6 +653,9 @@ class UpdateConfig: PvCharging().retry_failed_phase_switches), ("openWB/general/chargemode_config/unbalanced_load", False), ("openWB/general/chargemode_config/unbalanced_load_limit", 18), + ("openWB/general/consumer/config/switch_on_delay", 60), + ("openWB/general/consumer/config/switch_off_delay", 60), + ("openWB/general/consumer/config/switch_off_threshold", 0), ("openWB/general/control_interval", 10), ("openWB/general/extern", False), ("openWB/general/extern_display_mode", "primary"), diff --git a/packages/modules/common/component_state.py b/packages/modules/common/component_state.py index be27fda208..4ac03f1eb1 100644 --- a/packages/modules/common/component_state.py +++ b/packages/modules/common/component_state.py @@ -266,3 +266,36 @@ def __init__(self, plug_state: bool, charge_state: bool, set_current: int, max_c self.charge_state = charge_state self.set_current = set_current self.max_current = max_current + + +@auto_str +class ConsumerState: + def __init__( + self, + imported: Optional[float] = None, + exported: Optional[float] = None, + power: Optional[float] = None, + voltages: Optional[List[Optional[float]]] = None, + currents: Optional[List[Optional[float]]] = None, + powers: Optional[List[Optional[float]]] = None, + set_power: Optional[float] = None, + state: Optional[bool] = False, + temperatures: Optional[List[Optional[float]]] = None, + ): + """Args: + imported: total imported energy in Wh + exported: total exported energy in Wh + power: actual power in W + voltages: actual voltages for 3 phases in V + currents: actual currents for 3 phases in A + powers: actual powers for 3 phases in W + power_factors: actual power factors for 3 phases + frequency: actual grid frequency in Hz + """ + self.currents, self.powers, self.voltages = _calculate_powers_and_currents(currents, powers, voltages) + self.imported = imported + self.exported = exported + self.power = power + self.set_power = set_power + self.state = state + self.temperatures = temperatures diff --git a/packages/modules/common/component_type.py b/packages/modules/common/component_type.py index a759d3c533..8ae99c397b 100644 --- a/packages/modules/common/component_type.py +++ b/packages/modules/common/component_type.py @@ -6,16 +6,20 @@ class ComponentType(Enum): BACKUP_CLOUD = "backup_cloud" BAT = "bat" CHARGEPOINT = "cp" + CONSUMER = "consumer" COUNTER = "counter" FLEXIBLE_TARIFF = "dynamic_tariff" GRID_FEE = "grid_tariff" INVERTER = "inverter" IO = "io" + VEHICLE = "vehicle" def special_to_general_type_mapping(component_type: str) -> ComponentType: if "bat" in component_type: return ComponentType.BAT + elif "consumer" in component_type: + return ComponentType.CONSUMER elif "counter" in component_type: return ComponentType.COUNTER elif "inverter" in component_type: @@ -29,6 +33,8 @@ def special_to_general_type_mapping(component_type: str) -> ComponentType: def type_to_topic_mapping(component_type: str) -> str: if "bat" in component_type: return "bat" + elif "consumer" in component_type: + return "consumer" elif "counter" in component_type: return "counter" elif "inverter" in component_type: diff --git a/packages/modules/common/simcount/_simcounter.py b/packages/modules/common/simcount/_simcounter.py index 09b74e5866..6606ef5658 100644 --- a/packages/modules/common/simcount/_simcounter.py +++ b/packages/modules/common/simcount/_simcounter.py @@ -19,6 +19,20 @@ def sim_count(self, power: float, dc_power: Optional[float] = None) -> Tuple[flo return self.data.imported, self.data.exported +class SimCounterConsumer: + def __init__(self, consumer_id: int, component_type: ComponentType): + self.topic = f"openWB/set/consumer/{consumer_id}/module/" + self.component_type = special_to_general_type_mapping( + component_type.value if isinstance(component_type, ComponentType) else component_type) + self.data: Optional[SimCounterState] = None + + def sim_count(self, power: float, dc_power: Optional[float] = None) -> Tuple[float, float]: + if self.component_type != ComponentType.INVERTER and dc_power is not None and dc_power == 0: + power = 0 + self.data = sim_count(power, self.topic, self.data, self.component_type) + return self.data.imported, self.data.exported + + class SimCounterChargepoint: def __init__(self, chargepoint_id: int): self.topic = f"openWB/set/chargepoint/{chargepoint_id}/get/" diff --git a/packages/modules/common/store/_factory.py b/packages/modules/common/store/_factory.py index 8b137aecab..20fba6f41b 100644 --- a/packages/modules/common/store/_factory.py +++ b/packages/modules/common/store/_factory.py @@ -2,6 +2,7 @@ from modules.common.component_type import ComponentType, special_to_general_type_mapping from modules.common.simcount._simcounter import SimCounter from modules.common.store._battery import get_bat_value_store +from modules.common.store._consumer import get_consumer_value_store from modules.common.store._counter import get_counter_value_store from modules.common.store._inverter import get_inverter_value_store @@ -14,6 +15,8 @@ def get_component_value_store(component_type_str: str, component_type_str.value if isinstance(component_type_str, ComponentType) else component_type_str) if ComponentType.BAT == component_type: return get_bat_value_store(component_num) + elif ComponentType.CONSUMER == component_type: + return get_consumer_value_store(component_num) elif ComponentType.COUNTER == component_type: return get_counter_value_store(component_num, add_child_values, simcounter) elif ComponentType.INVERTER == component_type: diff --git a/packages/modules/common/utils/peak_filter.py b/packages/modules/common/utils/peak_filter.py index fb7b07aa43..0ef0cc1af3 100644 --- a/packages/modules/common/utils/peak_filter.py +++ b/packages/modules/common/utils/peak_filter.py @@ -32,6 +32,9 @@ def check_values( elif self.component_type == ComponentType.BAT: bat = data.data.bat_data[f"bat{self.component_id}"] max_power = bat.data.config.max_power + elif self.component_type == ComponentType.CONSUMER: + consumer = data.data.consumer_data[f"consumer{self.component_id}"] + max_power = consumer.data.config.max_power else: raise ValueError(f"Unsupported component type {self.component_type!r} in PeakFilter") # prüfe Leistung und importierte/exportierte Energie auf Plausibilität diff --git a/packages/modules/configuration.py b/packages/modules/configuration.py index 0af7ccf629..7122f1d2d6 100644 --- a/packages/modules/configuration.py +++ b/packages/modules/configuration.py @@ -23,6 +23,7 @@ def pub_configurable(): _pub_configurable_io_devices() _pub_configurable_io_actions() _pub_configurable_monitoring() + _pub_configurable_consumers() def _pub_configurable_backup_clouds() -> None: @@ -189,6 +190,11 @@ def _pub_configurable_soc_modules() -> None: def _pub_configurable_devices_components() -> None: + Pub().pub("openWB/set/system/configurable/devices_components", + _get_configurable_devices_components(["*bat*", "*counter*", "*inverter*"])) + + +def _get_configurable_devices_components(component_pattern: List[str]) -> None: def update_nested_dict(dictionary: Dict, update: Dict) -> Dict: for key, value in update.items(): if isinstance(value, dict): @@ -218,7 +224,7 @@ def get_vendor_group_name(group: str) -> str: vendor_path = path.parts[-2] vendor_info = importlib.import_module( f".devices.{vendor_path}.vendor", "modules").vendor_descriptor.configuration_factory() - vendor_groups = update_nested_dict(vendor_groups, { + entry = { vendor_info.group: { "group_name": get_vendor_group_name(vendor_info.group), "vendors": { @@ -228,7 +234,9 @@ def get_vendor_group_name(group: str) -> str: } } } - }) + } + if len(entry[vendor_info.group]["vendors"][vendor_path]["devices"]) > 0: + vendor_groups = update_nested_dict(vendor_groups, entry) except Exception: log.exception(f"Fehler im configuration-Modul: vendors: {path}") return vendor_groups @@ -244,19 +252,21 @@ def get_vendor_devices(vendor: str) -> Dict: device_path = path.parts[-2] dev_defaults = importlib.import_module( f".devices.{vendor}.{device_path}.device", "modules").device_descriptor.configuration_factory() - devices.update({ + entry = { dev_defaults.type: { "device_name": dev_defaults.name, "components": get_device_components(vendor, dev_defaults.type) } - }) + } + if len(entry[dev_defaults.type]["components"]) > 0: + devices.update(entry) except Exception: log.exception(f"Fehler im configuration-Modul: devices: {path}") return devices def get_device_components(vendor: str, device: str) -> Dict: components = {} - for pattern in ["*bat*", "*counter*", "*inverter*"]: + for pattern in component_pattern: path_list = Path(_get_packages_path()/"modules"/"devices"/vendor/device).glob(f'**/{pattern}.py') for path in path_list: try: @@ -276,7 +286,7 @@ def get_device_components(vendor: str, device: str) -> Dict: return components try: - Pub().pub("openWB/set/system/configurable/devices_components", get_vendor_groups()) + return get_vendor_groups() except Exception: log.exception("Fehler im configuration-Modul") @@ -403,5 +413,82 @@ def _pub_configurable_monitoring() -> None: log.exception("Fehler im configuration-Modul") +def _pub_configurable_consumers() -> None: + try: + path_list = Path(_get_packages_path()/"modules"/"consumer").glob('**/consumer.py') + + def update_nested_dict(dictionary: Dict, update: Dict) -> Dict: + for key, value in update.items(): + if isinstance(value, dict): + dictionary[key] = update_nested_dict(dictionary.get(key, {}), value) + else: + dictionary[key] = value + return dictionary + + def get_vendor_consumers(vendor: str) -> Dict: + consumers = {} + path_list = Path(_get_packages_path()/"modules"/"consumers"/vendor).glob('**/consumer.py') + for path in path_list: + try: + if path.name.endswith("_test.py"): + # Tests überspringen + continue + device_path = path.parts[-2] + dev_defaults = importlib.import_module( + f".consumers.{vendor}.{device_path}.consumer", + "modules").device_descriptor.configuration_factory() + usage_defaults = [] + for usage_type in dev_defaults.usage: + usage_defaults.append(usage_type.value) + dev_defaults.usage = usage_defaults + consumers.update({ + dev_defaults.type: { + "consumer_name": dev_defaults.name, + "defaults": dataclass_utils.asdict(dev_defaults) + } + }) + except Exception: + log.exception(f"Fehler im configuration-Modul: devices: {path}") + return consumers + + def get_vendor_group_name(group: str) -> str: + # ToDo: find a better way to lookup the group names in "devices/vendors.py" + if group == "generic": + return "herstellerunabhängig" + if group == "openwb": + return "openWB" + if group == "vendors": + return "andere Hersteller" + return group + + vendor_groups = {} + path_list = Path(_get_packages_path()/"modules"/"consumers").glob('**/vendor.py') + for path in path_list: + try: + if path.name.endswith("_test.py"): + # Tests überspringen + continue + vendor_path = path.parts[-2] + vendor_info = importlib.import_module( + f".consumers.{vendor_path}.vendor", "modules").vendor_descriptor.configuration_factory() + vendor_groups = update_nested_dict(vendor_groups, { + vendor_info.group: { + "group_name": get_vendor_group_name(vendor_info.group), + "vendors": { + vendor_path: { + "vendor_name": vendor_info.vendor, + "consumers": get_vendor_consumers(vendor_path) + } + } + } + }) + except Exception: + log.exception(f"Fehler im configuration-Modul: vendors: {path}") + + Pub().pub("openWB/set/system/configurable/consumers", vendor_groups) + except Exception: + log.exception("Fehler im configuration-Modul") + + def _get_packages_path() -> Path: return Path(__file__).resolve().parents[2]/"packages" diff --git a/packages/modules/loadvars.py b/packages/modules/loadvars.py index 5fc9efd6a4..f5ef2a32f7 100644 --- a/packages/modules/loadvars.py +++ b/packages/modules/loadvars.py @@ -60,6 +60,13 @@ def _set_values(self) -> List[str]: args=(), name=f"set values cp{cp.chargepoint_module.config.id}")) except Exception: log.exception(f"Fehler im loadvars-Modul bei Element {cp.num}") + for consumer in data.data.consumer_data.values(): + try: + modules_threads.append(Thread(target=consumer.module.update, + args=(), + name=f"set values consumer{consumer.data.module.id}")) + except Exception: + log.exception(f"Fehler im loadvars-Modul bei Element {consumer.num}") return joined_thread_handler(modules_threads, data.data.general_data.data.control_interval/3) def _update_values_of_level_buttom_top(self, elements, not_finished_threads: List[str]) -> None: @@ -76,6 +83,14 @@ def _update_values_of_level_buttom_top(self, elements, not_finished_threads: Lis target=update_values, args=(chargepoint.chargepoint_module,), name=f"update values cp{chargepoint.chargepoint_module.config.id}")) + elif element["type"] == ComponentType.CONSUMER.value: + consumer = data.data.consumer_data[f'{type_to_topic_mapping(element["type"])}{element["id"]}'] + thread_name = f"set values consumer{consumer.data.module.id}" + if thread_name not in not_finished_threads: + modules_threads.append(Thread( + target=update_values, + args=(consumer.module,), + name=f"update values consumer{consumer.data.module.id}")) else: component = get_finished_component_obj_by_id(element["id"], not_finished_threads) if component is None: From 077f6a2f70df28fa5a692af8c189d24a74766a9c Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Wed, 17 Jun 2026 14:41:11 +0200 Subject: [PATCH 05/21] fixes --- packages/control/consumer/consumer.py | 46 +++++++++---------- .../counter_all/loadmanagement_prio.py | 4 +- .../counter_all/loadmanagement_prio_test.py | 4 +- packages/helpermodules/command.py | 5 +- packages/helpermodules/setdata.py | 7 ++- packages/helpermodules/update_config.py | 2 + .../modules/consumers/generic/mqtt/config.py | 2 +- 7 files changed, 39 insertions(+), 31 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 17e3c055b8..540ceb30d8 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -100,6 +100,13 @@ def get_parameter(self) -> Tuple[int, int, Optional[str], Chargemode, Chargemode raise ValueError(f"Ungültiger Lademodus {self.data.usage.chargemode} für Verbraucher {self.num}") return min_current, required_current, message, mode, submode + def _parse_required_current_by_usage(self, required_current: float) -> float: + if self.data.usage.type in [ConsumerUsage.CONTINUOUS, ConsumerUsage.SUSPENDABLE_ONOFF]: + return get_medium_charging_current( + self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + else: + return required_current + def scheduled_charging(self) -> Tuple[int, str, Optional[str], int]: required_current = 0 message = None @@ -128,14 +135,12 @@ def time_charging(self) -> Tuple[int, str, Optional[str], int]: else: log.debug( "Zeitladen: minimaler Speicher-SoC überschritten, Laden mit Zeitladen möglich.") - required_current = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) submode = Chargemode.TIME_CHARGING else: message = self.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL else: - required_current = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) submode = Chargemode.TIME_CHARGING else: message = self.TIME_CHARGING_NO_PLAN_ACTIVE @@ -144,8 +149,7 @@ def time_charging(self) -> Tuple[int, str, Optional[str], int]: return required_current, message, None, submode def instant_charging(self) -> Tuple[int, str, Optional[str], int]: - required_current = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) message = None mode = Chargemode.INSTANT_CHARGING submode = Chargemode.INSTANT_CHARGING @@ -158,26 +162,22 @@ def eco_charging(self) -> Tuple[int, str, Optional[str], int]: mode = Chargemode.ECO_CHARGING if data.data.optional_data.data.electricity_pricing.configured: if data.data.optional_data.ep_is_charging_allowed_price_threshold(self.data.usage.eco_charging.price_limit): - required_current = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) message = self.CHARGING_PRICE_LOW submode = Chargemode.INSTANT_CHARGING else: - required_current = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) message = self.CHARGING_PRICE_EXCEEDED if self.data.control_parameter.state in CHARGING_STATES: message += "Lädt mit Überschuss. " submode = Chargemode.PV_CHARGING else: - required_current = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) submode = Chargemode.PV_CHARGING return required_current, message, mode, submode def pv_charging(self) -> Tuple[int, str, Optional[str], int]: - required_current = get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + required_current = self._parse_required_current_by_usage(self.data.usage.min_current) message = None mode = Chargemode.PV_CHARGING submode = Chargemode.PV_CHARGING @@ -185,8 +185,8 @@ def pv_charging(self) -> Tuple[int, str, Optional[str], int]: def wait_for_start_handler( self, func: Callable[[], Tuple[int, str, Optional[str], int]]) -> Tuple[int, str, Optional[str], int]: - if self.data.usage.wait_for_start.active: - timestamp_next_test = self.data.usage.wait_for_start.last_test_timestamp + 60*60 + if self.data.usage.wait_for_start_active: + timestamp_next_test = self.data.set.wait_for_start_last_test_timestamp + 60*60 wait_for_start_state = self._wait_for_start_singal(timestamp_next_test) if wait_for_start_state == WaitForStartStates.START_SIGNAL_RECEIVED: required_current, message, mode, submode = func() @@ -207,22 +207,22 @@ def wait_for_start_handler( return required_current, message, mode, submode def _wait_for_start_singal(self, timestamp_next_test: float) -> WaitForStartStates: - if self.data.usage.wait_for_start.signal_received: + if self.data.set.wait_for_start_signal_received: return WaitForStartStates.START_SIGNAL_RECEIVED else: - if self.data.usage.wait_for_start.test_running: + if self.data.set.wait_for_start_test_running: if self.data.get.charge_state: - self.data.usage.wait_for_start.signal_received = True - self.data.usage.wait_for_start.test_running = False + self.data.set.wait_for_start_signal_received = True + self.data.set.wait_for_start_test_running = False return WaitForStartStates.START_SIGNAL_RECEIVED - elif timecheck.create_timestamp() < self.data.usage.wait_for_start.last_test_timestamp + 60: + elif timecheck.create_timestamp() < self.data.set.wait_for_start_last_test_timestamp + 60: return WaitForStartStates.START_TEST_RUN else: - self.data.usage.wait_for_start.test_running = False + self.data.set.wait_for_start_test_running = False log.debug("Testlauf für kontinuierlichen Verbraucher beendet, kein Startsignal empfangen.") return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN elif timecheck.create_timestamp() > timestamp_next_test: - self.data.usage.wait_for_start.last_test_timestamp = timecheck.create_timestamp() + self.data.set.wait_for_start_last_test_timestamp = timecheck.create_timestamp() return WaitForStartStates.TEST_RUNNIG else: return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN diff --git a/packages/control/counter_all/loadmanagement_prio.py b/packages/control/counter_all/loadmanagement_prio.py index 6095210cde..c004f9f1ff 100644 --- a/packages/control/counter_all/loadmanagement_prio.py +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -19,7 +19,7 @@ def remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, id: int) - def _remove_loadmanagement_prio_item(self: LoadmanagementPrioProtocol, id: int, entry: List[Dict]) -> bool: for item in entry: - if item["type"] == "vehicle": + if item["type"] == "vehicle" or item["type"] == "consumer": if item["id"] == id: entry.remove(item) return True @@ -48,7 +48,7 @@ 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": + if entry["type"] == "vehicle" or entry["type"] == "consumer": grouped_cps = [] for cp in filtered_cps: if cp.data.config.ev == entry["id"]: diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py index fb7d04a32a..e1b8ccdd6b 100644 --- a/packages/control/counter_all/loadmanagement_prio_test.py +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -80,7 +80,9 @@ def test_add_item(loadmanagement_prios: List[Dict], "loadmanagement_prios, id, expected_loadmanagement_prios", [ pytest.param([{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 2}], - 2, [{"type": "vehicle", "id": 3}], id="flat list"), + 2, [{"type": "vehicle", "id": 3}], id="flat list, remove vehicle"), + pytest.param([{"type": "vehicle", "id": 3}, {"type": "consumer", "id": 2}], + 2, [{"type": "vehicle", "id": 3}], id="flat list, remove consumer"), pytest.param([ { "type": "group", diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 530bfa3b1c..0a83439262 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -17,7 +17,7 @@ from control.chargepoint import chargepoint from control.chargepoint.chargepoint_template import get_chargepoint_template_default -from control.consumer.consumer_data import GET_DEFAULTS_BY_USAGE, Set, get_plan_class_for_usage +from control.consumer.consumer_data import GET_DEFAULTS_BY_USAGE, get_plan_class_for_usage from control.consumer.usage import ConsumerUsage from control.counter_all import counter_all from control.ev.charge_template import ChargeTemplate, get_new_charge_template @@ -1154,7 +1154,6 @@ def addConsumer(self, connection_id: str, payload: dict) -> None: Pub().pub(f'openWB/set/consumer/{new_id}/module', consumer_default) Pub().pub(f"openWB/set/consumer/{new_id}/config", dataclass_utils.asdict(ConsumerConfig())) Pub().pub(f"openWB/set/consumer/{new_id}/extra_meter", None) - Pub().pub(f"openWB/set/consumer/{new_id}/set", dataclass_utils.asdict(Set())) Pub().pub(f"openWB/set/consumer/{new_id}/usage", None) self.max_id_hierarchy = new_id Pub().pub("openWB/set/command/max_id/hierarchy", new_id) @@ -1195,7 +1194,7 @@ def removeConsumer(self, connection_id: str, payload: dict) -> None: SubData.counter_all_data.remove_loadmanagement_prio_item(payload["data"]["consumer_id"]) Pub().pub("openWB/set/counter/get/loadmanagement_prios", SubData.counter_all_data.data.get.loadmanagement_prios) pub_user_message(payload, connection_id, - f'Ladepunkt mit ID \'{payload["data"]["consumer_id"]}\' gelöscht.', MessageType.SUCCESS) + f'Verbraucher mit ID \'{payload["data"]["consumer_id"]}\' gelöscht.', MessageType.SUCCESS) def selectUsage(self, connection_id: str, payload: dict) -> None: """ weist einem Verbraucher eine Nutzung zu. diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 69e58d3244..0c9220933d 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -859,7 +859,12 @@ def process_mqtt_topic(self, msg: mqtt.MQTTMessage): if "openWB/set/mqtt/chargepoint/" in msg.topic: self.process_chargepoint_get_topics(msg) elif "openWB/set/mqtt/consumer/" in msg.topic: - self.process_consumer_topic(msg) + if re.search("openWB/set/mqtt/consumer/[0-9]+/set/switch", msg.topic) is not None: + self._validate_value(msg, bool) + elif re.search("openWB/set/mqtt/consumer/[0-9]+/set/power", msg.topic) is not None: + self._validate_value(msg, float) + else: + self.process_consumer_topic(msg) elif "openWB/set/mqtt/counter/" in msg.topic: self.process_counter_topic(msg) elif "openWB/set/mqtt/bat/" in msg.topic: diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 966b1e04ab..1202c86e3a 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -345,6 +345,8 @@ class UpdateConfig: "^openWB/mqtt/consumer/[0-9]+/get/power$", "^openWB/mqtt/consumer/[0-9]+/get/powers$", "^openWB/mqtt/consumer/[0-9]+/get/voltages$", + "^openWB/mqtt/consumer/[0-9]+/set/power$", + "^openWB/mqtt/consumer/[0-9]+/set/switch$", "^openWB/mqtt/counter/[0-9]+/get/currents$", "^openWB/mqtt/counter/[0-9]+/get/imported$", "^openWB/mqtt/counter/[0-9]+/get/exported$", diff --git a/packages/modules/consumers/generic/mqtt/config.py b/packages/modules/consumers/generic/mqtt/config.py index 7a4bebee6d..3c5e816e51 100644 --- a/packages/modules/consumers/generic/mqtt/config.py +++ b/packages/modules/consumers/generic/mqtt/config.py @@ -15,7 +15,7 @@ def __init__(self): @auto_str class Mqtt(ConsumerSetup[MqttConfiguration]): def __init__(self, - name: str = "Mqtt", + name: str = "MQTT", type: str = "mqtt", id: int = 0, configuration: MqttConfiguration = None, From 1ac3be1bf78cd9f8303faacf43ff10ac4d179df2 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Thu, 18 Jun 2026 08:38:24 +0200 Subject: [PATCH 06/21] fixes --- packages/control/algorithm/filter_chargepoints.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/control/algorithm/filter_chargepoints.py b/packages/control/algorithm/filter_chargepoints.py index 1659aca5bf..944af89944 100644 --- a/packages/control/algorithm/filter_chargepoints.py +++ b/packages/control/algorithm/filter_chargepoints.py @@ -6,6 +6,8 @@ from control import data from control.chargemode import Chargemode from control.chargepoint.chargepoint import Chargepoint +from control.consumer.consumer import Consumer +from control.consumer.usage import ConsumerUsage from control.load_protocol import Load log = logging.getLogger(__name__) @@ -90,6 +92,11 @@ def get_preferenced_load_charging( log.info(f"{'LP' if isinstance(load, Chargepoint) else 'Verbraucher'} {load.num}: " f"Lädt nicht, daher keine weitere Berücksichtigung") preferenced_loads_without_set_current.append(load) + elif (isinstance(load, Consumer) and + load.data.usage.type in [ConsumerUsage.CONTINUOUS, ConsumerUsage.SUSPENDABLE_ONOFF]): + log.info(f"Verbraucher {load.num}: Verbrauchsart {load.data.usage.type} führt zu keiner weiteren " + "Berücksichtigung") + preferenced_loads_without_set_current.append(load) else: valid_group.append(load) group[:] = valid_group From bf8d79bd662189145b7bad0261e7abbc7a6e3739 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Wed, 24 Jun 2026 08:48:27 +0200 Subject: [PATCH 07/21] =?UTF-8?q?Anlauferkennung,=20Betriebsmodus=20zur?= =?UTF-8?q?=C3=BCcksetzen,=20Befehle=20f=C3=BCr=20Pl=C3=A4ne=20(#3588)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * draft * Anlauferkennung * Betriebsmodus zurücksetzen * ammend * Anlauferkennung * draft * draft scheduled charging * add scheduled und time plan commands for adding and removing * flake8 * update config * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * review * fix pytest --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- packages/control/consumer/consumer.py | 331 ++++++++++++++---- packages/control/consumer/consumer_data.py | 53 ++- packages/control/consumer/consumer_test.py | 121 +++++++ packages/helpermodules/abstract_plans.py | 6 +- packages/helpermodules/command.py | 95 +++-- .../missing_role_topics_test.py | 2 + packages/helpermodules/setdata.py | 7 +- packages/helpermodules/subdata.py | 5 + packages/helpermodules/timecheck.py | 7 +- packages/helpermodules/update_config.py | 7 + packages/main.py | 4 + 11 files changed, 530 insertions(+), 108 deletions(-) create mode 100644 packages/control/consumer/consumer_test.py diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 540ceb30d8..03499cc25f 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -1,27 +1,21 @@ -from enum import Enum import logging from typing import Callable, Optional, Tuple from control import data from control.algorithm.utils import get_medium_charging_current from control.chargemode import Chargemode from control.chargepoint.chargepoint_state import CHARGING_STATES -from control.consumer.consumer_data import ConsumerData, ConsumerUsage, MeterOnlyConfig +from control.consumer.consumer_data import ConsumerData, ConsumerUsage, MeterOnlyConfig, ResetModes, WaitForStartStates from control.load_protocol import Load from helpermodules import timecheck -from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase +from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase, voltages_mean from modules.common.configurable_consumer import ConfigurableConsumer log = logging.getLogger(__name__) -class WaitForStartStates(Enum): - START_TEST_RUN = "start_test_run" - TEST_RUNNIG = "test_running" - START_SIGNAL_RECEIVED = "start_singal_received" - WAIT_FOR_NEXT_TEST_RUN = "wait_for_next_test_run" - - class Consumer(Load): + PAUSE_BETWEEN_WAIT_FOR_START_TEST_RUNS = 3600 + def __init__(self, index: int): self.num = index self.data: ConsumerData = ConsumerData() @@ -37,7 +31,11 @@ def set_state_and_log(self, message: str) -> None: elif message not in self.data.get.state_str: self.data.get.state_str += f" {message}" + def setup_values_at_start(self): + self.data.get.state_str = None + def update(self): + self.setup_values_at_start() if isinstance(self.data.usage, MeterOnlyConfig): return else: @@ -53,9 +51,9 @@ def update(self): self.data.set.phases_to_use = self.data.config.connected_phases self.data.get.charge_state = True if self.data.get.power > 0 else False min_current, required_current, message, mode, submode = self.get_parameter() + self.set_mode_changed(submode, mode) self.set_control_parameter(min_current, required_current, self.data.config.connected_phases, submode, mode) self.set_state_and_log(message) - self.set_mode_changed(submode, mode) log.debug( f"Verbraucher {self.num}: Sollstrom {required_current}, min. Ist-Strom {max(self.data.get.currents)}," f" Modus {mode}, Submodus {submode}, {message}") @@ -67,12 +65,9 @@ def update(self): BELOW_PRICE_LIMIT = "Preislimit für Verbraucher aktiv, aktueller Preis in Ordnung." SURPLUS_CONTINOUS_STILL_RUNNING = ("Verbraucher läuft ggf auch ohne ausreichend Überschuss weiter, da der " "Verbraucher nicht abgeschaltet werden darf.") - WAIT_FOR_START_SIGNAL = "Warte auf Startsignal für kontinuierlichen Verbraucher. Nächster Testlauf in {}" - WAIT_FOR_START_SIGNAL_TEST_RUN = "Verbraucher eingeschaltet, um zu testen, ob ein Startsignal empfangen wird." - WAIT_FOR_START_SIGNAL_RECEIVED = "Startsignal für kontinuierlichen Verbraucher empfangen." def get_parameter(self) -> Tuple[int, int, Optional[str], Chargemode, Chargemode]: - if timecheck.create_timestamp() < self.data.set.timestamp_last_current_set + self.data.usage.min_intervall: + if timecheck.create_timestamp() < self.data.set.timestamp_last_current_set + self.data.config.min_intervall: log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") return (self.data.control_parameter.min_current, self.data.control_parameter.required_current, @@ -80,7 +75,7 @@ def get_parameter(self) -> Tuple[int, int, Optional[str], Chargemode, Chargemode self.data.control_parameter.chargemode, self.data.control_parameter.submode) - min_current = self.data.usage.min_current + min_current = self.data.config.min_current if self.data.usage.chargemode == Chargemode.SCHEDULED_CHARGING: required_current, message, mode, submode = self.wait_for_start_handler(self.scheduled_charging) elif self.data.usage.chargemode == Chargemode.TIME_CHARGING: @@ -103,16 +98,183 @@ def get_parameter(self) -> Tuple[int, int, Optional[str], Chargemode, Chargemode def _parse_required_current_by_usage(self, required_current: float) -> float: if self.data.usage.type in [ConsumerUsage.CONTINUOUS, ConsumerUsage.SUSPENDABLE_ONOFF]: return get_medium_charging_current( - self.data.get.currents) if self.data.get.charge_state else self.data.usage.min_current + self.data.get.currents) if self.data.get.charge_state else self.data.config.min_current else: return required_current - def scheduled_charging(self) -> Tuple[int, str, Optional[str], int]: - required_current = 0 - message = None - mode = None - submode = None - return required_current, message, mode, submode + def _convert_power_to_current(self, power: float) -> float: + phases = self.data.config.connected_phases or 1 + voltage = voltages_mean(self.data.get.voltages) or 230.0 + return power / phases / voltage + + BUFFER_AFTER_END_TIME = -1200 # nach mehr als 20 Min Überschreitung wird der Termin als verpasst angesehen + BUFFER_START_EARLIER = 600 # 10 Min vor dem geplanten Start kann begonnen werden + + def scheduled_charging(self) -> Tuple[Chargemode, str]: + return self.data.config.min_current, "Zielladen noch nicht implementiert", Chargemode.STOP, Chargemode.STOP + plan_data, remaining_time, duration = self._find_recent_plan(self.data.usage.scheduled_charging.plans) + if plan_data: + self.data.config.control_parameter.current_plan = plan_data.id + else: + self.data.config.control_parameter.current_plan = None + return self.scheduled_charging_calc_current( + plan_data, + remaining_time, + duration) + + # def _find_recent_plan(self, + # plans: List[Union[ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer]]): + # plans_diff_end_date = [] + # for p in plans: + # try: + # plans_diff_end_date.append( + # {p.id: timecheck.check_end_time(p, self.BUFFER_AFTER_END_TIME)}) + # log.debug(f"Verbleibende Zeit bis zum Zieltermin [s]: {plans_diff_end_date}") + # except Exception: + # log.exception("Fehler im ev-Modul "+str(self.data.id)) + # if plans_diff_end_date: + # # ermittle den Key vom kleinsten value in plans_diff_end_date + # filtered_plans = [d for d in plans_diff_end_date if list(d.values())[0] is not None] + # if filtered_plans: + # sorted_plans = sorted(filtered_plans, key=lambda x: list(x.values())[0]) + # for plan in sorted_plans: + # if self.BUFFER_AFTER_END_TIME < list(plan.values())[0]: + # plan_dict = plan + # break + # else: + # return None + # plan_id = list(plan_dict.keys())[0] + # diff_end_date = list(plan_dict.values())[0] + + # for p in plans: + # if p.id == plan_id: + # plan = p + + # remaining_time, duration = self._calc_remaining_time(plan, diff_end_date) + + # return plan, remaining_time, duration + # else: + # return None + + # def _calc_remaining_time(self, + # plan: Union[ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer], + # diff_end_date: float) -> Tuple[float, float]: + # duration = plan.duration - self.data.set.log.on_time + # remaining_time = diff_end_date - duration + + # log.debug(f"Verbleibende Zeit bis zum Ladestart [s]:{remaining_time}, Dauer [h]: {duration/3600}") + # return remaining_time, duration + + # SCHEDULED_REACHED_MAX_ON_TIME = ("Zielladen abgeschlossen, da die geplante Ladedauer bereits überschritten " + # "wurde.") + # SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC = ( + # "Zielladen abgeschlossen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" + # " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") + # SCHEDULED_CHARGING_REACHED_AMOUNT = "Zielladen abgeschlossen, da die Energiemenge bereits erreicht wurde. " + # SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC = ("Falls vorhanden wird mit EVU-Überschuss geladen, da der Ziel-Soc " + # "für Zielladen bereits erreicht wurde. ") + # SCHEDULED_CHARGING_BIDI = ("Der Ziel-Soc für Zielladen wurde bereits erreicht. Das Auto wird " + # "bidirektional ge-/entladen, sodass möglichst weder Bezug noch " + # "Einspeisung erfolgt. ") + # SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Kein Zielladen, da keine Ziel-Termine konfiguriert sind." + # SCHEDULED_CHARGING_NO_DATE_PENDING = "Kein Zielladen, da kein Ziel-Termin ansteht. " + # SCHEDULED_CHARGING_USE_PV = "Zielladen startet {}. Falls vorhanden, wird mit Überschuss geladen. " + # SCHEDULED_CHARGING_MAX_CURRENT = "Zielladen mit {}A. Der Ladestrom wurde erhöht, um das Ziel zu erreichen. " + # SCHEDULED_CHARGING_LIMITED_BY_SOC = 'einen SoC von {}%' + # SCHEDULED_CHARGING_LIMITED_BY_AMOUNT = '{}kWh geladene Energie' + # SCHEDULED_CHARGING_IN_TIME = ('Zielladen mit mindestens {}A, um {} um {} zu erreichen. Falls vorhanden wird ' + # 'zusätzlich EVU-Überschuss geladen. ') + # SCHEDULED_CHARGING_CHEAP_HOUR = "Zielladen, da ein günstiger Zeitpunkt zum preisbasierten Laden ist. {}" + # SCHEDULED_CHARGING_EXPENSIVE_HOUR = ( + # "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + # "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen. ") + # SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC = ( + # "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + # "Laden ist. {} " + + # "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden erreicht wurde.") + + # def scheduled_charging_calc_current(self, + # plan: Optional[Union[ContinuousScheduledPlanConsumer, + # SuspendableScheduledPlanConsumer]], + # remaining_time: float, + # duration: float) -> Tuple[str, str]: + # submode = "stop" + # if plan is None: + # if len(self.data.usage.scheduled_charging.plans) == 0: + # return submode, self.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED + # else: + # return submode, self.SCHEDULED_CHARGING_NO_DATE_PENDING + # if self.data.set.log.on_time >= duration: + # message = self.SCHEDULED_REACHED_MAX_ON_TIME + # elif (0 < remaining_time < self.BUFFER_START_EARLIER): + # submode = "instant_charging" + # # weniger als die berechnete Zeit verfügbar + # elif remaining_time <= 0: + # submode = "instant_charging" + # else: + # # Wenn dynamische Tarife aktiv sind, prüfen, ob jetzt ein günstiger Zeitpunkt zum Laden + # # ist. + # if plan.et_active: + # def get_hours_message() -> str: + # def end_of_today_timestamp() -> int: + # return datetime.datetime.now().replace( + # hour=23, minute=59, second=59, microsecond=999000).timestamp() + + # def is_loading_hour(hour: int) -> bool: + # return data.data.optional_data.ep_is_charging_allowed_hours_list(hour) + + # def convert_loading_hours_to_string(hour_list: List[int]) -> str: + # if 1 < len(hour_list): + # times_string = ", ".join(hour.strftime('%-H:%M') for hour in hour_list[:-1]) + # return times_string + " und " + hour_list[-1].strftime('%-H:%M') + # else: + # return ", ".join(hour.strftime('%-H:%M') for hour in hour_list) + # midnight = end_of_today_timestamp() + # loading_times_today = [datetime.datetime.fromtimestamp(hour) + # for hour in sorted(hour_list) if hour <= midnight] + # loading_times_today = (loading_times_today[1:] + # if is_loading_hour(hour_list) else loading_times_today) + # loading_times_tomorrow = [datetime.datetime.fromtimestamp(hour) + # for hour in sorted(hour_list) if hour > midnight] + + # parts = [] + + # if is_loading_hour(hour_list): + # parts.append("jetzt") + + # if 0 < len(loading_times_today): + # if parts: + # parts.append(" und ") + # parts.append(f"heute {convert_loading_hours_to_string(loading_times_today)}") + + # if 0 < len(loading_times_tomorrow): + # if parts: + # parts.append(" sowie ") + # parts.append(f"morgen {convert_loading_hours_to_string(loading_times_tomorrow)}") + + # loading_message = "Geladen wird " + "".join(parts) + # return loading_message + '.' + + # hour_list = data.data.optional_data.ep_get_loading_hours(duration, duration + remaining_time) + + # log.debug(f"Günstige Ladezeiten: {hour_list}") + # if data.data.optional_data.ep_is_charging_allowed_hours_list(hour_list): + # message = self.SCHEDULED_CHARGING_CHEAP_HOUR.format(get_hours_message()) + # submode = "instant_charging" + # else: + # message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(get_hours_message()) + # submode = "pv_charging" + # else: + # now = datetime.datetime.today() + # start_time = now + datetime.timedelta(seconds=remaining_time) + # if start_time.year == now.year and start_time.month == now.month and start_time.day == now.day: + # message = self.SCHEDULED_CHARGING_USE_PV.format( + # f"um {start_time.strftime('%-H:%M')} Uhr") + # else: + # message = self.SCHEDULED_CHARGING_USE_PV.format( + # f"am {start_time.strftime('%d.%m')} um {start_time.strftime('%-H:%M')} Uhr") + # submode = "pv_charging" + # return submode, message TIME_CHARGING_MIN_BAT_SOC_REACHED = ("Betrieb mit Zeitladen nach Speicher-SoC nicht möglich, da der SoC des" " Speichers unter dem minimalen SoC liegt.") @@ -135,12 +297,14 @@ def time_charging(self) -> Tuple[int, str, Optional[str], int]: else: log.debug( "Zeitladen: minimaler Speicher-SoC überschritten, Laden mit Zeitladen möglich.") - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) submode = Chargemode.TIME_CHARGING else: message = self.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL else: - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) submode = Chargemode.TIME_CHARGING else: message = self.TIME_CHARGING_NO_PLAN_ACTIVE @@ -149,7 +313,8 @@ def time_charging(self) -> Tuple[int, str, Optional[str], int]: return required_current, message, None, submode def instant_charging(self) -> Tuple[int, str, Optional[str], int]: - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) message = None mode = Chargemode.INSTANT_CHARGING submode = Chargemode.INSTANT_CHARGING @@ -162,70 +327,94 @@ def eco_charging(self) -> Tuple[int, str, Optional[str], int]: mode = Chargemode.ECO_CHARGING if data.data.optional_data.data.electricity_pricing.configured: if data.data.optional_data.ep_is_charging_allowed_price_threshold(self.data.usage.eco_charging.price_limit): - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) message = self.CHARGING_PRICE_LOW submode = Chargemode.INSTANT_CHARGING else: - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage(self.data.config.min_current) message = self.CHARGING_PRICE_EXCEEDED if self.data.control_parameter.state in CHARGING_STATES: message += "Lädt mit Überschuss. " submode = Chargemode.PV_CHARGING else: - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage(self.data.config.min_current) submode = Chargemode.PV_CHARGING return required_current, message, mode, submode def pv_charging(self) -> Tuple[int, str, Optional[str], int]: - required_current = self._parse_required_current_by_usage(self.data.usage.min_current) + required_current = self._parse_required_current_by_usage(self.data.config.min_current) message = None mode = Chargemode.PV_CHARGING submode = Chargemode.PV_CHARGING return required_current, message, mode, submode + WAIT_FOR_DEVICE_START = "Warte auf Gerätestart, Gerät eingeschaltet lassen." + WAIT_FOR_STOPPED_DEVICE = "Gerätestart erkannt, warte auf gestopptes Gerät." + DEVICE_WAITING_FOR_START = "Gerätestart erkannt. Warte auf ausreichend Überschuss, um das Gerät zu starten." + def wait_for_start_handler( - self, func: Callable[[], Tuple[int, str, Optional[str], int]]) -> Tuple[int, str, Optional[str], int]: + self, func: Callable[[], Tuple[int, str, Optional[str], int]] + ) -> Tuple[float, str, Optional[Chargemode], Chargemode]: if self.data.usage.wait_for_start_active: - timestamp_next_test = self.data.set.wait_for_start_last_test_timestamp + 60*60 - wait_for_start_state = self._wait_for_start_singal(timestamp_next_test) - if wait_for_start_state == WaitForStartStates.START_SIGNAL_RECEIVED: + if self.data.set.wait_for_start_state == WaitForStartStates.WAIT_FOR_DEVICE_START: + if self.data.get.charge_state: + self.data.set.wait_for_start_state = WaitForStartStates.WAIT_FOR_STOPPED_DEVICE + required_current = 0 + message = self.WAIT_FOR_STOPPED_DEVICE + mode = Chargemode.STOP + submode = Chargemode.STOP + else: + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) + message = self.WAIT_FOR_DEVICE_START + mode = Chargemode.INSTANT_CHARGING + submode = Chargemode.INSTANT_CHARGING + elif self.data.set.wait_for_start_state == WaitForStartStates.WAIT_FOR_STOPPED_DEVICE: + if self.data.get.charge_state is False: + self.data.set.wait_for_start_state = WaitForStartStates.DEVICE_WAITING_FOR_START + required_current, message, mode, submode = func() + message = self.DEVICE_WAITING_FOR_START + " " + (message if message else "") + else: + self.data.set.wait_for_start_state = WaitForStartStates.WAIT_FOR_STOPPED_DEVICE + required_current = 0 + message = self.WAIT_FOR_STOPPED_DEVICE + mode = Chargemode.STOP + submode = Chargemode.STOP + elif self.data.set.wait_for_start_state == WaitForStartStates.DEVICE_WAITING_FOR_START: + if self.data.get.charge_state: + self.data.set.wait_for_start_state = WaitForStartStates.START_SIGNAL_RECEIVED + required_current, message, mode, submode = func() + else: + required_current, message, mode, submode = func() + message = self.DEVICE_WAITING_FOR_START + " " + (message if message else "") + elif self.data.set.wait_for_start_state == WaitForStartStates.START_SIGNAL_RECEIVED: required_current, message, mode, submode = func() - elif (wait_for_start_state == WaitForStartStates.START_TEST_RUN or - wait_for_start_state == WaitForStartStates.TEST_RUNNIG): - required_current = self.data.usage.min_current - message = self.WAIT_FOR_START_SIGNAL_TEST_RUN - mode = Chargemode.INSTANT_CHARGING - submode = Chargemode.INSTANT_CHARGING - elif wait_for_start_state == WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN: - required_current = 0 - message = self.WAIT_FOR_START_SIGNAL.format( - timecheck.convert_timestamp_delta_to_time_string(timestamp_next_test, 0)) - mode = Chargemode.STOP - submode = Chargemode.STOP + else: + raise ValueError( + f"Ungültiger wait_for_start_state {self.data.set.wait_for_start_state} für Verbraucher {self.num}") else: required_current, message, mode, submode = func() return required_current, message, mode, submode - def _wait_for_start_singal(self, timestamp_next_test: float) -> WaitForStartStates: - if self.data.set.wait_for_start_signal_received: - return WaitForStartStates.START_SIGNAL_RECEIVED - else: - if self.data.set.wait_for_start_test_running: - if self.data.get.charge_state: - self.data.set.wait_for_start_signal_received = True - self.data.set.wait_for_start_test_running = False - return WaitForStartStates.START_SIGNAL_RECEIVED - elif timecheck.create_timestamp() < self.data.set.wait_for_start_last_test_timestamp + 60: - return WaitForStartStates.START_TEST_RUN - else: - self.data.set.wait_for_start_test_running = False - log.debug("Testlauf für kontinuierlichen Verbraucher beendet, kein Startsignal empfangen.") - return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN - elif timecheck.create_timestamp() > timestamp_next_test: - self.data.set.wait_for_start_last_test_timestamp = timecheck.create_timestamp() - return WaitForStartStates.TEST_RUNNIG - else: - return WaitForStartStates.WAIT_FOR_NEXT_TEST_RUN + def reset_wait_for_start(self): + self.data.set.wait_for_start_state = WaitForStartStates.WAIT_FOR_DEVICE_START + + def reset_chargemode_at_midnight(self): + if self.data.usage.reset_chargemode.mode == ResetModes.MIDNIGHT: + if self.data.usage.chargemode != self.data.usage.reset_chargemode.chargemode: + log.info(f"Zurücksetzen des Lademodus auf {self.data.usage.reset_chargemode.chargemode} " + f"für Verbraucher {self.num} um Mitternacht.") + self.data.usage.chargemode = self.data.usage.reset_chargemode.chargemode + + def reset_chargemode_at_time(self): + if (self.data.usage.reset_chargemode.mode == ResetModes.TIME and + self.data.usage.reset_chargemode.time is not None): + if timecheck.create_timestamp() > self.data.usage.reset_chargemode.time: + if self.data.usage.chargemode != self.data.usage.reset_chargemode.chargemode: + log.info(f"Zurücksetzen des Lademodus auf {self.data.usage.reset_chargemode.chargemode} " + f"für Verbraucher {self.num} um definierte Zeit.") + self.data.usage.chargemode = self.data.usage.reset_chargemode.chargemode def set_control_parameter(self, min_current, required_current, phases, submode, mode): self.data.control_parameter.min_current = min_current @@ -251,4 +440,12 @@ def is_charging_stop_allowed(self) -> bool: def set_mode_changed(self, submode: Chargemode, mode: Chargemode) -> None: self.submode_changed = (submode != self.data.control_parameter.submode) - self.chargemode_changed = (mode != self.data.control_parameter.chargemode) + if ((submode == Chargemode.TIME_CHARGING and + self.data.control_parameter.chargemode != Chargemode.TIME_CHARGING) or + (submode != Chargemode.TIME_CHARGING and + self.data.control_parameter.chargemode != mode)): + self.chargemode_changed = True + log.debug("Änderung des Lademodus") + self.data.control_parameter.timestamp_chargemode_changed = timecheck.create_timestamp() + else: + self.chargemode_changed = False diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index aa389e8a32..fb765d43a8 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, field +from enum import Enum from typing import Callable, Dict, List, Optional, Union from control.chargemode import Chargemode from control.chargepoint.control_parameter import ControlParameter, control_parameter_factory @@ -31,6 +32,31 @@ class TimeCharging: plans: List[TimeChargingPlanConsumer] = field(default_factory=empty_list_factory) +class WaitForStartStates(Enum): + WAIT_FOR_DEVICE_START = "wait_for_device_start" + WAIT_FOR_STOPPED_DEVICE = "wait_for_stopped_device" + DEVICE_WAITING_FOR_START = "device_waiting_for_start" + START_SIGNAL_RECEIVED = "start_signal_received" + + +class ResetModes(Enum): + NEVER = "never" + MIDNIGHT = "midnight" + TIME = "time" + + +@dataclass +class ResetChargemode: + mode: ResetModes = ResetModes.NEVER + time: Optional[int] = None + chargemode: Chargemode = Chargemode.INSTANT_CHARGING + + +@dataclass +class Log: + on_time: float = 0 + + @dataclass class MeterOnlyConfig: type: ConsumerUsage = ConsumerUsage.METER_ONLY @@ -39,37 +65,34 @@ class MeterOnlyConfig: @dataclass class SuspendableTunableDeviceConfig: chargemode: Chargemode = Chargemode.INSTANT_CHARGING - min_current: float = 0.5 - min_intervall: int = 60 eco_charging: EcoCharging = field(default_factory=lambda: EcoCharging()) scheduled_charging: ScheduledSuspendableCharging = field(default_factory=lambda: ScheduledSuspendableCharging()) time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) type: ConsumerUsage = ConsumerUsage.SUSPENDABLE_TUNABLE + reset_chargemode: ResetChargemode = field(default_factory=lambda: ResetChargemode()) wait_for_start_active: bool = True @dataclass class SuspendableOnOffDeviceConfig: chargemode: Chargemode = Chargemode.INSTANT_CHARGING - min_current: float = 0.5 - min_intervall: int = 60 eco_charging: EcoCharging = field(default_factory=lambda: EcoCharging()) scheduled_charging: ScheduledSuspendableCharging = field(default_factory=lambda: ScheduledSuspendableCharging()) time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) type: ConsumerUsage = ConsumerUsage.SUSPENDABLE_ONOFF + reset_chargemode: ResetChargemode = field(default_factory=lambda: ResetChargemode()) wait_for_start_active: bool = True @dataclass class ContinuousDeviceConfig: chargemode: Chargemode = Chargemode.INSTANT_CHARGING - min_current: float = 0.5 - min_intervall: int = 2*60*60 eco_charging: EcoCharging = field(default_factory=lambda: EcoCharging()) scheduled_charging: ScheduledContinuousCharging = field(default_factory=lambda: ScheduledContinuousCharging()) time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) type: ConsumerUsage = ConsumerUsage.CONTINUOUS wait_for_start_active: bool = True + reset_chargemode: ResetChargemode = field(default_factory=lambda: ResetChargemode()) GET_DEFAULTS_BY_USAGE: Dict[ConsumerUsage, Union[MeterOnlyConfig, @@ -89,7 +112,7 @@ class ContinuousDeviceConfig: ConsumerUsage.CONTINUOUS: ContinuousDeviceConfig, } -get_plan_class_for_usage: Dict[ConsumerUsage, Callable] = { +GET_PLAN_CLASS_FOR_USAGE: Dict[ConsumerUsage, Callable] = { ConsumerUsage.SUSPENDABLE_TUNABLE: SuspendableScheduledPlanConsumer, ConsumerUsage.SUSPENDABLE_ONOFF: SuspendableScheduledPlanConsumer, ConsumerUsage.CONTINUOUS: ContinuousScheduledPlanConsumer, @@ -101,6 +124,8 @@ class ConsumerConfig: connected_phases: int = 1 phase_1: int = 1 max_power: float = 5000 + min_current: float = 0.5 + min_intervall: int = 60 @dataclass @@ -124,21 +149,19 @@ class Get: @dataclass class Set: - current: float = 0 + current: float = field(default=0, metadata={"topic": "set/current"}) loadmanagement_available: bool = False - phases_to_use: int = 0 - plug_time: Optional[float] = 0 + phases_to_use: int = field(default=1, metadata={"topic": "set/phases_to_use"}) + plug_time: Optional[float] = field(default=None, metadata={"topic": "set/plug_time"}) required_power: float = 0 current_prev: float = 0 target_current: float = 0 charge_state_prev: bool = False + log: Log = field(default_factory=lambda: Log()) power: Optional[float] = None timestamp_last_current_set: float = field(default=0, metadata={"topic": "set/timestamp_last_current_set"}) - wait_for_start_signal_received: bool = field( - default=False, metadata={"topic": "set/wait_for_start_signal_received"}) - wait_for_start_test_running: bool = field(default=False, metadata={"topic": "set/wait_for_start_test_running"}) - wait_for_start_last_test_timestamp: float = field( - default=0, metadata={"topic": "set/wait_for_start_last_test_timestamp"}) + wait_for_start_state: WaitForStartStates = field( + default=WaitForStartStates.WAIT_FOR_DEVICE_START, metadata={"topic": "set/wait_for_start_state"}) @dataclass diff --git a/packages/control/consumer/consumer_test.py b/packages/control/consumer/consumer_test.py new file mode 100644 index 0000000000..6e6228f585 --- /dev/null +++ b/packages/control/consumer/consumer_test.py @@ -0,0 +1,121 @@ +from typing import Optional, Tuple +from unittest.mock import Mock + +import pytest + +from control import data +from control.chargemode import Chargemode +from control.consumer.consumer import Consumer +from control.consumer.consumer_data import ContinuousDeviceConfig, WaitForStartStates + + +@pytest.fixture(autouse=True) +def mock_data() -> None: + data.data_init(Mock()) + + +@pytest.fixture +def consumer() -> Consumer: + load = Consumer(1) + load.data.usage = ContinuousDeviceConfig() + load.data.config.min_current = 6 + load.data.config.max_power = 2300 + load.data.config.connected_phases = 1 + load.data.get.voltages = [230] + return load + + +@pytest.mark.parametrize( + ("wait_for_start_active", + "state", "charge_state", + "func_result", + "expected_result", + "expected_state", + "func_calls"), + [ + pytest.param( + False, + WaitForStartStates.WAIT_FOR_DEVICE_START, + False, + (11, "ok", Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING), + (11, "ok", Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING), + WaitForStartStates.WAIT_FOR_DEVICE_START, + 1, + id="wait-for-start-disabled-passthrough", + ), + pytest.param( + True, + WaitForStartStates.WAIT_FOR_DEVICE_START, + True, + (9, "running", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + (0, Consumer.WAIT_FOR_STOPPED_DEVICE, Chargemode.STOP, Chargemode.STOP), + WaitForStartStates.WAIT_FOR_STOPPED_DEVICE, + 0, + id="wait-for-device-start-charge-detected", + ), + pytest.param( + True, + WaitForStartStates.WAIT_FOR_DEVICE_START, + False, + (9, "running", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + (6, Consumer.WAIT_FOR_DEVICE_START, Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING), + WaitForStartStates.WAIT_FOR_DEVICE_START, + 0, + id="wait-for-device-start-no-charge", + ), + pytest.param( + True, + WaitForStartStates.WAIT_FOR_STOPPED_DEVICE, + False, + (8, "pv ok", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + (8, f"{Consumer.DEVICE_WAITING_FOR_START} pv ok", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + WaitForStartStates.DEVICE_WAITING_FOR_START, + 1, + id="wait-for-stopped-device-started-waiting", + ), + pytest.param( + True, + WaitForStartStates.DEVICE_WAITING_FOR_START, + True, + (7, "started", Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING), + (7, "started", Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING), + WaitForStartStates.START_SIGNAL_RECEIVED, + 1, + id="device-waiting-start-signal-received", + ), + pytest.param( + True, + WaitForStartStates.START_SIGNAL_RECEIVED, + True, + (10, "continue", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + (10, "continue", Chargemode.PV_CHARGING, Chargemode.PV_CHARGING), + WaitForStartStates.START_SIGNAL_RECEIVED, + 1, + id="start-signal-received-passthrough", + ), + ], +) +def test_wait_for_start_handler( + consumer: Consumer, + wait_for_start_active: bool, + state: WaitForStartStates, + charge_state: bool, + func_result: Tuple[int, str, Chargemode, Optional[Chargemode]], + expected_result: Tuple[int, str, Chargemode, Optional[Chargemode]], + expected_state: WaitForStartStates, + func_calls: int, +): + # setup + assert isinstance(consumer.data.usage, ContinuousDeviceConfig) + consumer.data.usage.wait_for_start_active = wait_for_start_active + consumer.data.set.wait_for_start_state = state + consumer.data.get.charge_state = charge_state + charging_func = Mock(return_value=func_result) + + # execution + result = consumer.wait_for_start_handler(charging_func) + + # evaluation + assert result == expected_result + assert consumer.data.set.wait_for_start_state == expected_state + assert charging_func.call_count == func_calls diff --git a/packages/helpermodules/abstract_plans.py b/packages/helpermodules/abstract_plans.py index 8e2c2b7dd1..6e89a39879 100644 --- a/packages/helpermodules/abstract_plans.py +++ b/packages/helpermodules/abstract_plans.py @@ -112,17 +112,17 @@ class AutolockPlan(TimeframePlan): class TimeChargingPlanConsumer(TimeframePlan): id: Optional[int] = None min_bat_soc: Optional[int] = None - name: str = "neuer Verbraucher-Plan" + name: str = "neuer Zeit-Plan" @dataclass class SuspendableScheduledPlanConsumer(TimeframePlan): id: Optional[int] = None - name: str = "neuer Verbraucher-Plan" + name: str = "neuer Ziel-Plan" duration: int = 60 @dataclass class ContinuousScheduledPlanConsumer(TimeframePlan): id: Optional[int] = None - name: str = "neuer Verbraucher-Plan" + name: str = "neuer Ziel-Plan" diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 0a83439262..4cb06e970c 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -17,14 +17,14 @@ from control.chargepoint import chargepoint from control.chargepoint.chargepoint_template import get_chargepoint_template_default -from control.consumer.consumer_data import GET_DEFAULTS_BY_USAGE, get_plan_class_for_usage +from control.consumer.consumer_data import GET_DEFAULTS_BY_USAGE, GET_PLAN_CLASS_FOR_USAGE from control.consumer.usage import ConsumerUsage 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 control.consumer.consumer_data import ConsumerConfig from helpermodules import pub -from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan +from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan, TimeChargingPlanConsumer from helpermodules.utils.run_command import run_command # ToDo: move to module commands if implemented from modules.backup_clouds.onedrive.api import generateMSALAuthCode, retrieveMSALTokens @@ -64,7 +64,8 @@ class Command: [("autolock_plan", "openWB/chargepoint/template/[0-9]+$", -1), ("charge_template_scheduled_plan", "openWB/vehicle/template/charge_template/[0-9]+$", -1), ("charge_template_time_charging_plan", "openWB/vehicle/template/charge_template/[0-9]+$", -1), - ("usage_plan", "openWB/consumer/[0-9]+/usage$", -1)], + ("consumer_scheduled_plan", "openWB/consumer/[0-9]+/usage$", -1), + ("consumer_time_plan", "openWB/consumer/[0-9]+/usage$", -1)], "topic": [("mqtt_bridge", "openWB/system/mqtt/bridge", -1), ("vehicle", "openWB/vehicle/[0-9]+/name$", 0)], @@ -92,7 +93,8 @@ def _get_max_ids(self) -> None: "charge_template_scheduled_plan": lambda p: p.get("chargemode", {}).get("scheduled_charging", {}).get("plans", []), "charge_template_time_charging_plan": lambda p: p.get("time_charging", {}).get("plans", []), - "usage_plan": lambda p: p.get("plans", []) + "consumer_scheduled_plan": lambda p: p.get("scheduled_charging", {}).get("plans", []), + "consumer_time_plan": lambda p: p.get("time_charging", {}).get("plans", []), } try: received_topics = ProcessBrokerBranch("").get_max_id() @@ -1208,41 +1210,94 @@ def selectUsage(self, connection_id: str, payload: dict) -> None: f'\'{payload["data"]["usage"]}\' zugewiesen.', MessageType.SUCCESS) - def addUsagePlan(self, connection_id: str, payload: dict) -> None: + def addConsumerSchedulePlan(self, connection_id: str, payload: dict) -> None: """ sendet das Topic, zu dem ein neuer Zielladen-Plan erstellt werden soll. """ usage = SubData.consumer_data[f'consumer{payload["data"]["consumer_id"]}'].data.usage - plan = get_plan_class_for_usage(usage.type)() - new_id = self.max_id_usage_plan + 1 - plan.id = new_id - usage.plans.append(plan) + # check if "payload" contains "data.copy" + if "data" in payload and "copy" in payload["data"] and usage.scheduled_charging.plans is not None: + for plan in usage.scheduled_charging.plans: + if plan.id == payload["data"]["copy"]: + new_consumer_schedule_plan = copy.deepcopy(plan) + break + new_consumer_schedule_plan.name = f'Kopie von {new_consumer_schedule_plan.name}' + else: + new_consumer_schedule_plan = GET_PLAN_CLASS_FOR_USAGE[usage.type]() + new_id = self.max_id_consumer_scheduled_plan + 1 + new_consumer_schedule_plan.id = new_id + usage.scheduled_charging.plans.append(new_consumer_schedule_plan) + self.max_id_consumer_scheduled_plan = new_id + Pub().pub("openWB/set/command/max_id/consumer_scheduled_plan", new_id) Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage)) - self.max_id_usage_plan = new_id - Pub().pub("openWB/set/command/max_id/usage_plan", new_id) pub_user_message( payload, connection_id, - f'Neuer Zielladen-Plan mit ID \'{new_id}\' zu Profil ' - f'\'{payload["data"]["template"]}\' hinzugefügt.', + f'Neuer Zielladen-Plan mit ID \'{new_id}\' zu Verbraucher ' + f'\'{payload["data"]["consumer_id"]}\' hinzugefügt.', MessageType.SUCCESS) - def removeUsagePlan(self, connection_id: str, payload: dict) -> None: + def removeConsumerSchedulePlan(self, connection_id: str, payload: dict) -> None: """ löscht einen Zielladen-Plan. """ - if self.max_id_usage_plan < payload["data"]["plan"]: + usage = SubData.consumer_data[f'consumer{payload["data"]["consumer_id"]}'].data.usage + if self.max_id_consumer_scheduled_plan < payload["data"]["plan"]: log.error( payload, connection_id, f'Die ID \'{payload["data"]["plan"]}\' ist größer als die maximal vergebene ' - f'ID \'{self.max_id_usage_plan}\'.', MessageType.ERROR) + f'ID \'{self.max_id_consumer_scheduled_plan}\'.', MessageType.ERROR) + return + for plan in usage.scheduled_charging.plans: + if plan.id == payload["data"]["plan"]: + usage.scheduled_charging.plans.remove(plan) + break + Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage)) + pub_user_message( + payload, connection_id, + (f'Zielladen-Plan mit ID \'{payload["data"]["plan"]}\' von Verbraucher ' + f'\'{payload["data"]["consumer_id"]}\' gelöscht.'), + MessageType.SUCCESS) + + def addConsumerTimePlan(self, connection_id: str, payload: dict) -> None: + """ sendet das Topic, zu dem ein neuer Zeitladen-Plan erstellt werden soll. + """ + usage = SubData.consumer_data[f'consumer{payload["data"]["consumer_id"]}'].data.usage + # check if "payload" contains "data.copy" + if "data" in payload and "copy" in payload["data"]: + for plan in usage.time_charging.plans: + if plan.id == payload["data"]["copy"]: + new_time_charging_plan = copy.deepcopy(plan) + break + new_time_charging_plan.name = f'Kopie von {new_time_charging_plan.name}' + else: + new_time_charging_plan = TimeChargingPlanConsumer() + new_id = self.max_id_consumer_time_plan + 1 + new_time_charging_plan.id = new_id + usage.time_charging.plans.append(new_time_charging_plan) + Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage)) + self.max_id_consumer_time_plan = new_id + Pub().pub( + "openWB/set/command/max_id/consumer_time_plan", new_id) + pub_user_message( + payload, connection_id, + f'Neuer Zeitladen-Plan mit ID \'{new_id}\' zu Verbraucher ' + f'\'{payload["data"]["consumer_id"]}\' hinzugefügt.', MessageType.SUCCESS) + + def removeConsumerTimePlan(self, connection_id: str, payload: dict) -> None: + """ löscht einen Zeitladen-Plan. + """ usage = SubData.consumer_data[f'consumer{payload["data"]["consumer_id"]}'].data.usage - for plan in usage.plans: + if self.max_id_consumer_time_plan < payload["data"]["plan"]: + log.error(payload, connection_id, "Die ID ist größer als die maximal vergebene ID.", + MessageType.ERROR) + return + for plan in usage.time_charging.plans: if plan.id == payload["data"]["plan"]: - usage.plans.remove(plan) + usage.time_charging.plans.remove(plan) break Pub().pub(f'openWB/set/consumer/{payload["data"]["consumer_id"]}/usage', dataclass_utils.asdict(usage)) pub_user_message( payload, connection_id, - f'Zielladen-Plan mit ID \'{payload["data"]["plan"]}\' von Profil ' - f'{payload["data"]["template"]}\' gelöscht.', + (f'Zeitladen-Plan mit ID \'{payload["data"]["plan"]}\' zu Verbraucher ' + f'\'{payload["data"]["consumer_id"]}\' gelöscht.'), MessageType.SUCCESS) diff --git a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py index b06822ab14..b1c74c086e 100644 --- a/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py +++ b/packages/helpermodules/mosquitto_dynsec/missing_role_topics_test.py @@ -24,6 +24,8 @@ def _get_data_path() -> Path: 'openWB/command/max_id/charge_template', 'openWB/command/max_id/charge_template_scheduled_plan', 'openWB/command/max_id/charge_template_time_charging_plan', + 'openWB/command/max_id/consumer_scheduled_plan', + 'openWB/command/max_id/consumer_time_plan', 'openWB/command/max_id/chargepoint_template', 'openWB/command/max_id/device', 'openWB/command/max_id/ev_template', diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 0c9220933d..b71d5f4452 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -1252,6 +1252,8 @@ def process_consumer_topic(self, msg): elif (re.search("consumer/[0-9]+/get/power$", msg.topic) is not None or re.search("consumer/[0-9]+/set/power$", msg.topic) is not None or re.search("consumer/[0-9]+/set/current$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/plug_time$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/timestamp_last_current_set$", msg.topic) is not None or re.search("consumer/[0-9]+/get/set_power$", msg.topic) is not None): self._validate_value(msg, float) elif (re.search("consumer/[0-9]+/get/currents$", msg.topic) is not None or @@ -1266,10 +1268,13 @@ def process_consumer_topic(self, msg): elif re.search("consumer/[0-9]+/get/fault_state$", msg.topic) is not None: self._validate_value(msg, int, [(0, 2)]) elif (re.search("consumer/[0-9]+/get/fault_str$", msg.topic) is not None or - re.search("consumer/[0-9]+/get/state_str$", msg.topic) is not None): + re.search("consumer/[0-9]+/get/state_str$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/wait_for_start_state$", msg.topic) is not None): self._validate_value(msg, str) elif re.search("consumer/[0-9]+/get/state$", msg.topic) is not None: self._validate_value(msg, bool) + elif re.search("consumer/[0-9]+/set/phases_to_use$", msg.topic) is not None: + self._validate_value(msg, int, [(0, 3)]) elif re.search("consumer/[0-9]+/control_parameter/", msg.topic) is not None: self.process_control_parameter_topics(msg) else: diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 3f1320c875..4e1d01e4ef 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -1,5 +1,6 @@ """ Modul, um die Daten vom Broker zu erhalten. """ +from enum import Enum import importlib import logging from pathlib import Path @@ -256,6 +257,8 @@ def set_json_payload_class(self, class_obj: Dict, msg: mqtt.MQTTMessage): if isinstance(payload, Dict): for key, value in payload.items(): setattr(class_obj, key, value) + elif isinstance(getattr(class_obj, key, None), Enum): + setattr(class_obj, key, type(getattr(class_obj, key))(payload)) else: setattr(class_obj, key, decode_payload(msg.payload)) else: @@ -1248,6 +1251,8 @@ def process_consumer_topic(self, client: mqtt.Client, var: Dict[str, Consumer], self.set_json_payload_class(var["consumer"+index].data.config, msg) elif re.search("openWB/consumer/[0-9]+/get", msg.topic) is not None: self.set_json_payload_class(var["consumer"+index].data.get, msg) + elif re.search("openWB/consumer/[0-9]+/set", msg.topic) is not None: + self.set_json_payload_class(var["consumer"+index].data.set, msg) elif re.search("openWB/consumer/[0-9]+/extra_meter", msg.topic) is not None: self.set_json_payload_class(var[f"consumer{index}"].data, msg) elif re.search("openWB/consumer/[0-9]+/usage$", msg.topic) is not None: diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index e0ac8bc70e..ccc37b5606 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -9,7 +9,8 @@ with ImportErrorContext(): from dateutil.relativedelta import relativedelta -from helpermodules.abstract_plans import AutolockPlan, ScheduledChargingPlan, TimeChargingPlan, TimeChargingPlanConsumer +from helpermodules.abstract_plans import (AutolockPlan, ContinuousScheduledPlanConsumer, ScheduledChargingPlan, + SuspendableScheduledPlanConsumer, TimeChargingPlan, TimeChargingPlanConsumer) log = logging.getLogger(__name__) @@ -113,7 +114,9 @@ def is_timeframe_valid(now: datetime.datetime, begin: datetime.datetime, end: da return state -def check_end_time(plan: ScheduledChargingPlan, +def check_end_time(plan: Union[ContinuousScheduledPlanConsumer, + ScheduledChargingPlan, + SuspendableScheduledPlanConsumer], buffer: Optional[float]) -> Optional[float]: """ gibt die verbleibende Zeit in Sekunden zurück. diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 1202c86e3a..8ed8194816 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -174,6 +174,8 @@ class UpdateConfig: "^openWB/command/max_id/charge_template$", "^openWB/command/max_id/charge_template_scheduled_plan$", "^openWB/command/max_id/charge_template_time_charging_plan$", + "^openWB/command/max_id/consumer_scheduled_plan$", + "^openWB/command/max_id/consumer_time_plan$", "^openWB/command/max_id/chargepoint_template$", "^openWB/command/max_id/device$", "^openWB/command/max_id/ev_template$", @@ -202,6 +204,11 @@ class UpdateConfig: "^openWB/consumer/[0-9]+/get/state$", "^openWB/consumer/[0-9]+/get/state_str$", "^openWB/consumer/[0-9]+/get/voltages$", + "^openWB/consumer/[0-9]+/set/current$", + "^openWB/consumer/[0-9]+/set/phases_to_use$", + "^openWB/consumer/[0-9]+/set/plug_time$", + "^openWB/consumer/[0-9]+/set/timestamp_last_current_set$", + "^openWB/consumer/[0-9]+/set/wait_for_start_state$", "^openWB/counter/config/consider_less_charging$", "^openWB/counter/config/home_consumption_source_id$", diff --git a/packages/main.py b/packages/main.py index 3385139ba9..1e9570706d 100755 --- a/packages/main.py +++ b/packages/main.py @@ -234,6 +234,10 @@ def handler_midnight(self): thread_errors_path = Path(Path(__file__).resolve().parents[1]/"ramdisk"/"thread_errors.log") with thread_errors_path.open("w") as f: f.write("") + with ChangedValuesContext(loadvars_.event_module_update_completed): + for consumer in data.data.consumer_data.values(): + consumer.reset_wait_for_start() + consumer.reset_chargemode_at_midnight() except Exception: log.exception("Fehler im Main-Modul") From 9833f48d8e10140f9a82c349a90e6088f7cf2f8f Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:00:24 +0200 Subject: [PATCH 08/21] on time, fixes (#3612) * on_time * eco default price, typo --- packages/control/consumer/consumer.py | 335 +++++++++++---------- packages/control/consumer/consumer_data.py | 5 +- packages/helpermodules/setdata.py | 1 + packages/helpermodules/timecheck.py | 3 +- packages/helpermodules/update_config.py | 1 + packages/main.py | 3 +- 6 files changed, 184 insertions(+), 164 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 03499cc25f..a7819344f6 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -1,5 +1,6 @@ +import datetime import logging -from typing import Callable, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple, Union from control import data from control.algorithm.utils import get_medium_charging_current from control.chargemode import Chargemode @@ -7,6 +8,7 @@ from control.consumer.consumer_data import ConsumerData, ConsumerUsage, MeterOnlyConfig, ResetModes, WaitForStartStates from control.load_protocol import Load from helpermodules import timecheck +from helpermodules.abstract_plans import ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase, voltages_mean from modules.common.configurable_consumer import ConfigurableConsumer @@ -54,10 +56,23 @@ def update(self): self.set_mode_changed(submode, mode) self.set_control_parameter(min_current, required_current, self.data.config.connected_phases, submode, mode) self.set_state_and_log(message) + self.process_on_time() log.debug( f"Verbraucher {self.num}: Sollstrom {required_current}, min. Ist-Strom {max(self.data.get.currents)}," f" Modus {mode}, Submodus {submode}, {message}") + def process_on_time(self): + if self.data.control_parameter.timestamp_charge_start is None: + if max(self.data.get.currents) > self.data.config.min_current: + self.data.control_parameter.timestamp_charge_start = timecheck.create_timestamp() + elif max(self.data.get.currents) < self.data.config.min_current: + self.reset_timestamp_start() + else: + self.data.set.on_time = timecheck.create_timestamp() - self.data.control_parameter.timestamp_charge_start + + def reset_timestamp_start(self): + self.data.control_parameter.timestamp_charge_start = None + PRICE_LIMIT_EXCEEDED = "Preislimit für Verbraucher aktiv, aktueller Preis zu hoch." PRICE_LIMIT_EXCEEDED_CONTINOUS_STILL_RUNNING = ("Preislimit für Verbraucher aktiv, aktueller Preis zu hoch. " "Verbraucher läuft weiter, da der Verbraucher nicht abgeschaltet " @@ -67,7 +82,7 @@ def update(self): "Verbraucher nicht abgeschaltet werden darf.") def get_parameter(self) -> Tuple[int, int, Optional[str], Chargemode, Chargemode]: - if timecheck.create_timestamp() < self.data.set.timestamp_last_current_set + self.data.config.min_intervall: + if timecheck.create_timestamp() < self.data.set.timestamp_last_current_set + self.data.config.min_interval: log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") return (self.data.control_parameter.min_current, self.data.control_parameter.required_current, @@ -111,170 +126,169 @@ def _convert_power_to_current(self, power: float) -> float: BUFFER_START_EARLIER = 600 # 10 Min vor dem geplanten Start kann begonnen werden def scheduled_charging(self) -> Tuple[Chargemode, str]: - return self.data.config.min_current, "Zielladen noch nicht implementiert", Chargemode.STOP, Chargemode.STOP plan_data, remaining_time, duration = self._find_recent_plan(self.data.usage.scheduled_charging.plans) if plan_data: - self.data.config.control_parameter.current_plan = plan_data.id + self.data.control_parameter.current_plan = plan_data.id else: - self.data.config.control_parameter.current_plan = None + self.data.control_parameter.current_plan = None return self.scheduled_charging_calc_current( plan_data, remaining_time, duration) - # def _find_recent_plan(self, - # plans: List[Union[ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer]]): - # plans_diff_end_date = [] - # for p in plans: - # try: - # plans_diff_end_date.append( - # {p.id: timecheck.check_end_time(p, self.BUFFER_AFTER_END_TIME)}) - # log.debug(f"Verbleibende Zeit bis zum Zieltermin [s]: {plans_diff_end_date}") - # except Exception: - # log.exception("Fehler im ev-Modul "+str(self.data.id)) - # if plans_diff_end_date: - # # ermittle den Key vom kleinsten value in plans_diff_end_date - # filtered_plans = [d for d in plans_diff_end_date if list(d.values())[0] is not None] - # if filtered_plans: - # sorted_plans = sorted(filtered_plans, key=lambda x: list(x.values())[0]) - # for plan in sorted_plans: - # if self.BUFFER_AFTER_END_TIME < list(plan.values())[0]: - # plan_dict = plan - # break - # else: - # return None - # plan_id = list(plan_dict.keys())[0] - # diff_end_date = list(plan_dict.values())[0] - - # for p in plans: - # if p.id == plan_id: - # plan = p - - # remaining_time, duration = self._calc_remaining_time(plan, diff_end_date) - - # return plan, remaining_time, duration - # else: - # return None - - # def _calc_remaining_time(self, - # plan: Union[ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer], - # diff_end_date: float) -> Tuple[float, float]: - # duration = plan.duration - self.data.set.log.on_time - # remaining_time = diff_end_date - duration - - # log.debug(f"Verbleibende Zeit bis zum Ladestart [s]:{remaining_time}, Dauer [h]: {duration/3600}") - # return remaining_time, duration - - # SCHEDULED_REACHED_MAX_ON_TIME = ("Zielladen abgeschlossen, da die geplante Ladedauer bereits überschritten " - # "wurde.") - # SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC = ( - # "Zielladen abgeschlossen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" - # " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") - # SCHEDULED_CHARGING_REACHED_AMOUNT = "Zielladen abgeschlossen, da die Energiemenge bereits erreicht wurde. " - # SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC = ("Falls vorhanden wird mit EVU-Überschuss geladen, da der Ziel-Soc " - # "für Zielladen bereits erreicht wurde. ") - # SCHEDULED_CHARGING_BIDI = ("Der Ziel-Soc für Zielladen wurde bereits erreicht. Das Auto wird " - # "bidirektional ge-/entladen, sodass möglichst weder Bezug noch " - # "Einspeisung erfolgt. ") - # SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Kein Zielladen, da keine Ziel-Termine konfiguriert sind." - # SCHEDULED_CHARGING_NO_DATE_PENDING = "Kein Zielladen, da kein Ziel-Termin ansteht. " - # SCHEDULED_CHARGING_USE_PV = "Zielladen startet {}. Falls vorhanden, wird mit Überschuss geladen. " - # SCHEDULED_CHARGING_MAX_CURRENT = "Zielladen mit {}A. Der Ladestrom wurde erhöht, um das Ziel zu erreichen. " - # SCHEDULED_CHARGING_LIMITED_BY_SOC = 'einen SoC von {}%' - # SCHEDULED_CHARGING_LIMITED_BY_AMOUNT = '{}kWh geladene Energie' - # SCHEDULED_CHARGING_IN_TIME = ('Zielladen mit mindestens {}A, um {} um {} zu erreichen. Falls vorhanden wird ' - # 'zusätzlich EVU-Überschuss geladen. ') - # SCHEDULED_CHARGING_CHEAP_HOUR = "Zielladen, da ein günstiger Zeitpunkt zum preisbasierten Laden ist. {}" - # SCHEDULED_CHARGING_EXPENSIVE_HOUR = ( - # "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " - # "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen. ") - # SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC = ( - # "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " - # "Laden ist. {} " + - # "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden erreicht wurde.") - - # def scheduled_charging_calc_current(self, - # plan: Optional[Union[ContinuousScheduledPlanConsumer, - # SuspendableScheduledPlanConsumer]], - # remaining_time: float, - # duration: float) -> Tuple[str, str]: - # submode = "stop" - # if plan is None: - # if len(self.data.usage.scheduled_charging.plans) == 0: - # return submode, self.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED - # else: - # return submode, self.SCHEDULED_CHARGING_NO_DATE_PENDING - # if self.data.set.log.on_time >= duration: - # message = self.SCHEDULED_REACHED_MAX_ON_TIME - # elif (0 < remaining_time < self.BUFFER_START_EARLIER): - # submode = "instant_charging" - # # weniger als die berechnete Zeit verfügbar - # elif remaining_time <= 0: - # submode = "instant_charging" - # else: - # # Wenn dynamische Tarife aktiv sind, prüfen, ob jetzt ein günstiger Zeitpunkt zum Laden - # # ist. - # if plan.et_active: - # def get_hours_message() -> str: - # def end_of_today_timestamp() -> int: - # return datetime.datetime.now().replace( - # hour=23, minute=59, second=59, microsecond=999000).timestamp() - - # def is_loading_hour(hour: int) -> bool: - # return data.data.optional_data.ep_is_charging_allowed_hours_list(hour) - - # def convert_loading_hours_to_string(hour_list: List[int]) -> str: - # if 1 < len(hour_list): - # times_string = ", ".join(hour.strftime('%-H:%M') for hour in hour_list[:-1]) - # return times_string + " und " + hour_list[-1].strftime('%-H:%M') - # else: - # return ", ".join(hour.strftime('%-H:%M') for hour in hour_list) - # midnight = end_of_today_timestamp() - # loading_times_today = [datetime.datetime.fromtimestamp(hour) - # for hour in sorted(hour_list) if hour <= midnight] - # loading_times_today = (loading_times_today[1:] - # if is_loading_hour(hour_list) else loading_times_today) - # loading_times_tomorrow = [datetime.datetime.fromtimestamp(hour) - # for hour in sorted(hour_list) if hour > midnight] - - # parts = [] - - # if is_loading_hour(hour_list): - # parts.append("jetzt") - - # if 0 < len(loading_times_today): - # if parts: - # parts.append(" und ") - # parts.append(f"heute {convert_loading_hours_to_string(loading_times_today)}") - - # if 0 < len(loading_times_tomorrow): - # if parts: - # parts.append(" sowie ") - # parts.append(f"morgen {convert_loading_hours_to_string(loading_times_tomorrow)}") - - # loading_message = "Geladen wird " + "".join(parts) - # return loading_message + '.' - - # hour_list = data.data.optional_data.ep_get_loading_hours(duration, duration + remaining_time) - - # log.debug(f"Günstige Ladezeiten: {hour_list}") - # if data.data.optional_data.ep_is_charging_allowed_hours_list(hour_list): - # message = self.SCHEDULED_CHARGING_CHEAP_HOUR.format(get_hours_message()) - # submode = "instant_charging" - # else: - # message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(get_hours_message()) - # submode = "pv_charging" - # else: - # now = datetime.datetime.today() - # start_time = now + datetime.timedelta(seconds=remaining_time) - # if start_time.year == now.year and start_time.month == now.month and start_time.day == now.day: - # message = self.SCHEDULED_CHARGING_USE_PV.format( - # f"um {start_time.strftime('%-H:%M')} Uhr") - # else: - # message = self.SCHEDULED_CHARGING_USE_PV.format( - # f"am {start_time.strftime('%d.%m')} um {start_time.strftime('%-H:%M')} Uhr") - # submode = "pv_charging" - # return submode, message + def _find_recent_plan(self, + plans: List[Union[ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer]]): + plans_diff_end_date: List[Dict[int, float]] = [] + for p in plans: + try: + plans_diff_end_date.append( + {p.id: timecheck.check_end_time(p, self.BUFFER_AFTER_END_TIME)}) + log.debug(f"Verbleibende Zeit bis zum Zieltermin [s]: {plans_diff_end_date}") + except Exception: + log.exception("Fehler im ev-Modul "+str(self.num)) + if plans_diff_end_date: + # ermittle den Key vom kleinsten value in plans_diff_end_date + filtered_plans = [d for d in plans_diff_end_date if list(d.values())[0] is not None] + if filtered_plans: + sorted_plans = sorted(filtered_plans, key=lambda x: list(x.values())[0]) + for plan in sorted_plans: + if self.BUFFER_AFTER_END_TIME < list(plan.values())[0]: + plan_dict = plan + break + else: + return None + plan_id = list(plan_dict.keys())[0] + diff_end_date = list(plan_dict.values())[0] + + for p in plans: + if p.id == plan_id: + plan = p + + remaining_time, duration = self._calc_remaining_time(plan, diff_end_date) + + return plan, remaining_time, duration + else: + return None + + def _calc_remaining_time(self, + plan: Union[ContinuousScheduledPlanConsumer, SuspendableScheduledPlanConsumer], + diff_end_date: float) -> Tuple[float, float]: + duration = plan.duration - self.data.set.on_time + remaining_time = diff_end_date - duration + + log.debug(f"Verbleibende Zeit bis zum Ladestart [s]:{remaining_time}, Dauer [h]: {duration/3600}") + return remaining_time, duration + + SCHEDULED_REACHED_MAX_ON_TIME = ("Zielladen abgeschlossen, da die geplante Ladedauer bereits überschritten " + "wurde.") + SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC = ( + "Zielladen abgeschlossen, da das Limit für Fahrzeug Laden mit Überschuss (SoC-Limit)" + " sowie der Fahrzeug-SoC (Ziel-SoC) bereits erreicht wurde. ") + SCHEDULED_CHARGING_REACHED_AMOUNT = "Zielladen abgeschlossen, da die Energiemenge bereits erreicht wurde. " + SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC = ("Falls vorhanden wird mit EVU-Überschuss geladen, da der Ziel-Soc " + "für Zielladen bereits erreicht wurde. ") + SCHEDULED_CHARGING_BIDI = ("Der Ziel-Soc für Zielladen wurde bereits erreicht. Das Auto wird " + "bidirektional ge-/entladen, sodass möglichst weder Bezug noch " + "Einspeisung erfolgt. ") + SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Kein Zielladen, da keine Ziel-Termine konfiguriert sind." + SCHEDULED_CHARGING_NO_DATE_PENDING = "Kein Zielladen, da kein Ziel-Termin ansteht. " + SCHEDULED_CHARGING_USE_PV = "Zielladen startet {}. Falls vorhanden, wird mit Überschuss geladen. " + SCHEDULED_CHARGING_MAX_CURRENT = "Zielladen mit {}A. Der Ladestrom wurde erhöht, um das Ziel zu erreichen. " + SCHEDULED_CHARGING_LIMITED_BY_SOC = 'einen SoC von {}%' + SCHEDULED_CHARGING_LIMITED_BY_AMOUNT = '{}kWh geladene Energie' + SCHEDULED_CHARGING_IN_TIME = ('Zielladen mit mindestens {}A, um {} um {} zu erreichen. Falls vorhanden wird ' + 'zusätzlich EVU-Überschuss geladen. ') + SCHEDULED_CHARGING_CHEAP_HOUR = "Zielladen, da ein günstiger Zeitpunkt zum preisbasierten Laden ist. {}" + SCHEDULED_CHARGING_EXPENSIVE_HOUR = ( + "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + "Laden ist. {} Falls vorhanden, wird mit Überschuss geladen. ") + SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC = ( + "Zielladen ausstehend, da jetzt kein günstiger Zeitpunkt zum preisbasierten " + "Laden ist. {} " + + "Kein Zielladen mit Überschuss, da das SoC-Limit für Überschuss-Laden erreicht wurde.") + + def scheduled_charging_calc_current(self, + plan: Optional[Union[ContinuousScheduledPlanConsumer, + SuspendableScheduledPlanConsumer]], + remaining_time: float, + duration: float) -> Tuple[str, str]: + submode = "stop" + if plan is None: + if len(self.data.usage.scheduled_charging.plans) == 0: + return submode, self.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED + else: + return submode, self.SCHEDULED_CHARGING_NO_DATE_PENDING + if self.data.set.on_time >= duration: + message = self.SCHEDULED_REACHED_MAX_ON_TIME + elif (0 < remaining_time < self.BUFFER_START_EARLIER): + submode = "instant_charging" + # weniger als die berechnete Zeit verfügbar + elif remaining_time <= 0: + submode = "instant_charging" + else: + # Wenn dynamische Tarife aktiv sind, prüfen, ob jetzt ein günstiger Zeitpunkt zum Laden + # ist. + if data.data.optional_data.data.electricity_pricing.configured: + def get_hours_message() -> str: + def end_of_today_timestamp() -> int: + return datetime.datetime.now().replace( + hour=23, minute=59, second=59, microsecond=999000).timestamp() + + def is_loading_hour(hour: int) -> bool: + return data.data.optional_data.ep_is_charging_allowed_hours_list(hour) + + def convert_loading_hours_to_string(hour_list: List[int]) -> str: + if 1 < len(hour_list): + times_string = ", ".join(hour.strftime('%-H:%M') for hour in hour_list[:-1]) + return times_string + " und " + hour_list[-1].strftime('%-H:%M') + else: + return ", ".join(hour.strftime('%-H:%M') for hour in hour_list) + midnight = end_of_today_timestamp() + loading_times_today = [datetime.datetime.fromtimestamp(hour) + for hour in sorted(hour_list) if hour <= midnight] + loading_times_today = (loading_times_today[1:] + if is_loading_hour(hour_list) else loading_times_today) + loading_times_tomorrow = [datetime.datetime.fromtimestamp(hour) + for hour in sorted(hour_list) if hour > midnight] + + parts = [] + + if is_loading_hour(hour_list): + parts.append("jetzt") + + if 0 < len(loading_times_today): + if parts: + parts.append(" und ") + parts.append(f"heute {convert_loading_hours_to_string(loading_times_today)}") + + if 0 < len(loading_times_tomorrow): + if parts: + parts.append(" sowie ") + parts.append(f"morgen {convert_loading_hours_to_string(loading_times_tomorrow)}") + + loading_message = "Geladen wird " + "".join(parts) + return loading_message + '.' + + hour_list = data.data.optional_data.ep_get_loading_hours(duration, duration + remaining_time) + + log.debug(f"Günstige Ladezeiten: {hour_list}") + if data.data.optional_data.ep_is_charging_allowed_hours_list(hour_list): + message = self.SCHEDULED_CHARGING_CHEAP_HOUR.format(get_hours_message()) + submode = "instant_charging" + else: + message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(get_hours_message()) + submode = "pv_charging" + else: + now = datetime.datetime.today() + start_time = now + datetime.timedelta(seconds=remaining_time) + if start_time.year == now.year and start_time.month == now.month and start_time.day == now.day: + message = self.SCHEDULED_CHARGING_USE_PV.format( + f"um {start_time.strftime('%-H:%M')} Uhr") + else: + message = self.SCHEDULED_CHARGING_USE_PV.format( + f"am {start_time.strftime('%d.%m')} um {start_time.strftime('%-H:%M')} Uhr") + submode = "pv_charging" + return submode, message TIME_CHARGING_MIN_BAT_SOC_REACHED = ("Betrieb mit Zeitladen nach Speicher-SoC nicht möglich, da der SoC des" " Speichers unter dem minimalen SoC liegt.") @@ -400,6 +414,11 @@ def wait_for_start_handler( def reset_wait_for_start(self): self.data.set.wait_for_start_state = WaitForStartStates.WAIT_FOR_DEVICE_START + def midnight_handler(self): + self.reset_chargemode_at_midnight() + self.reset_wait_for_start() + self.reset_timestamp_start() + def reset_chargemode_at_midnight(self): if self.data.usage.reset_chargemode.mode == ResetModes.MIDNIGHT: if self.data.usage.chargemode != self.data.usage.reset_chargemode.chargemode: diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index fb765d43a8..3ec679609f 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -13,7 +13,7 @@ @dataclass class EcoCharging: - price_limit: float = 0.07 + price_limit: float = 0.0002 @dataclass @@ -125,7 +125,7 @@ class ConsumerConfig: phase_1: int = 1 max_power: float = 5000 min_current: float = 0.5 - min_intervall: int = 60 + min_interval: int = 60 @dataclass @@ -158,6 +158,7 @@ class Set: target_current: float = 0 charge_state_prev: bool = False log: Log = field(default_factory=lambda: Log()) + on_time: float = field(default=0, metadata={"topic": "set/on_time"}) power: Optional[float] = None timestamp_last_current_set: float = field(default=0, metadata={"topic": "set/timestamp_last_current_set"}) wait_for_start_state: WaitForStartStates = field( diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index b71d5f4452..9625dc1ad9 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -1252,6 +1252,7 @@ def process_consumer_topic(self, msg): elif (re.search("consumer/[0-9]+/get/power$", msg.topic) is not None or re.search("consumer/[0-9]+/set/power$", msg.topic) is not None or re.search("consumer/[0-9]+/set/current$", msg.topic) is not None or + re.search("consumer/[0-9]+/set/on_time$", msg.topic) is not None or re.search("consumer/[0-9]+/set/plug_time$", msg.topic) is not None or re.search("consumer/[0-9]+/set/timestamp_last_current_set$", msg.topic) is not None or re.search("consumer/[0-9]+/get/set_power$", msg.topic) is not None): diff --git a/packages/helpermodules/timecheck.py b/packages/helpermodules/timecheck.py index ccc37b5606..1e8a3d7088 100644 --- a/packages/helpermodules/timecheck.py +++ b/packages/helpermodules/timecheck.py @@ -117,7 +117,7 @@ def is_timeframe_valid(now: datetime.datetime, begin: datetime.datetime, end: da def check_end_time(plan: Union[ContinuousScheduledPlanConsumer, ScheduledChargingPlan, SuspendableScheduledPlanConsumer], - buffer: Optional[float]) -> Optional[float]: + buffer: Optional[float]) -> float: """ gibt die verbleibende Zeit in Sekunden zurück. Return @@ -127,7 +127,6 @@ def check_end_time(plan: Union[ContinuousScheduledPlanConsumer, """ now = datetime.datetime.today() end = datetime.datetime.strptime(plan.time, '%H:%M') - remaining_time = None if plan.frequency.selected == "once": endDate = datetime.datetime.strptime(plan.frequency.once, "%Y-%m-%d") end = end.replace(endDate.year, endDate.month, endDate.day) diff --git a/packages/helpermodules/update_config.py b/packages/helpermodules/update_config.py index 8ed8194816..5eeb91786e 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -205,6 +205,7 @@ class UpdateConfig: "^openWB/consumer/[0-9]+/get/state_str$", "^openWB/consumer/[0-9]+/get/voltages$", "^openWB/consumer/[0-9]+/set/current$", + "^openWB/consumer/[0-9]+/set/on_time$", "^openWB/consumer/[0-9]+/set/phases_to_use$", "^openWB/consumer/[0-9]+/set/plug_time$", "^openWB/consumer/[0-9]+/set/timestamp_last_current_set$", diff --git a/packages/main.py b/packages/main.py index 1e9570706d..9243b0bbcc 100755 --- a/packages/main.py +++ b/packages/main.py @@ -236,8 +236,7 @@ def handler_midnight(self): f.write("") with ChangedValuesContext(loadvars_.event_module_update_completed): for consumer in data.data.consumer_data.values(): - consumer.reset_wait_for_start() - consumer.reset_chargemode_at_midnight() + consumer.midnight_handler() except Exception: log.exception("Fehler im Main-Modul") From 75dc5002c69e7984804cb52fbaa38ec3d8761b65 Mon Sep 17 00:00:00 2001 From: LKuemmel <76958050+LKuemmel@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:15:35 +0200 Subject: [PATCH 09/21] on_time accessible via 1883 (#3626) * on_time accessible via 1883 * fix --- data/config/mosquitto/local/openwb_local.conf | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/data/config/mosquitto/local/openwb_local.conf b/data/config/mosquitto/local/openwb_local.conf index ab42e95798..37a3930ec5 100644 --- a/data/config/mosquitto/local/openwb_local.conf +++ b/data/config/mosquitto/local/openwb_local.conf @@ -1,4 +1,4 @@ -# openwb-version:21 +# openwb-version:22 listener 1886 localhost allow_anonymous true @@ -30,9 +30,10 @@ topic openWB/chargepoint/template/# out 2 topic openWB/consumer/+/module out 2 topic openWB/consumer/+/config out 2 -topic openWB/consumer/+/extra_meter/# out 2 +topic openWB/consumer/+/extra_meter out 2 topic openWB/consumer/+/usage out 2 topic openWB/consumer/+/get/# out 2 +topic openWB/consumer/+/set/# out 2 topic openWB/internal_chargepoint/# out 2 From 81dd1e8fd5a88bb699696f2b4a9794f38e73d85b Mon Sep 17 00:00:00 2001 From: BrettS <168732306+Brett-S-OWB@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:34:00 +0200 Subject: [PATCH 10/21] Add Consumer tab and cards to koala web theme (#3630) * add consumer tab/cards * apply new design to consumer cards * add consumer time on to consumer card * fix consumer card CSS * remove redundant placeholders --- .../source/src/components/ConsumerCard.vue | 178 +++++++++++++ .../src/components/ConsumerInformation.vue | 44 +++ .../source/src/components/ConsumerMessage.vue | 43 +++ .../src/components/ConsumerModeButtons.vue | 72 +++++ .../src/components/ConsumerSettings.vue | 148 +++++++++++ .../source/src/css/quasar.variables.scss | 3 + .../koala/source/src/pages/IndexPage.vue | 24 +- .../source/src/stores/mqtt-store-model.ts | 22 ++ .../koala/source/src/stores/mqtt-store.ts | 251 ++++++++++++++++++ 9 files changed, 777 insertions(+), 8 deletions(-) create mode 100644 packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue create mode 100644 packages/modules/web_themes/koala/source/src/components/ConsumerInformation.vue create mode 100644 packages/modules/web_themes/koala/source/src/components/ConsumerMessage.vue create mode 100644 packages/modules/web_themes/koala/source/src/components/ConsumerModeButtons.vue create mode 100644 packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue new file mode 100644 index 0000000000..5d0b9e50d7 --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue @@ -0,0 +1,178 @@ + + + + + diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerInformation.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerInformation.vue new file mode 100644 index 0000000000..951e4e31d6 --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerInformation.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerMessage.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerMessage.vue new file mode 100644 index 0000000000..38974ef37d --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerMessage.vue @@ -0,0 +1,43 @@ + + + diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerModeButtons.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerModeButtons.vue new file mode 100644 index 0000000000..d36bca3106 --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerModeButtons.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue b/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue new file mode 100644 index 0000000000..b7907c11fd --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue @@ -0,0 +1,148 @@ + + + + + diff --git a/packages/modules/web_themes/koala/source/src/css/quasar.variables.scss b/packages/modules/web_themes/koala/source/src/css/quasar.variables.scss index 4cf374c2c5..cba1cb3b74 100644 --- a/packages/modules/web_themes/koala/source/src/css/quasar.variables.scss +++ b/packages/modules/web_themes/koala/source/src/css/quasar.variables.scss @@ -61,6 +61,7 @@ $chart-shadow: $secondary; // energy flow chart: shadow behind the pills and the icon circles $flow-chart-shadow: #000000; $flow-chart-shadow-opacity: 0.12; +$consumer: #7D42F5; // Light theme (default) :root { --q-primary: #{$primary}; @@ -104,6 +105,7 @@ $flow-chart-shadow-opacity: 0.12; --q-diagram-icon: #{$diagram-icon}; --q-table-hover: #{$table-hover}; --q-tab-icon: #{$grey-7}; + --q-consumer: #{$consumer}; // Main background background-color: var(--q-background-1); @@ -303,6 +305,7 @@ $dark-table-hover: #ffffff12; --q-diagram-icon: #{$white}; --q-table-hover: #{$dark-table-hover}; --q-tab-icon: #{$grey-7}; + --q-consumer: #{$consumer}; // Main background background-color: var(--q-background-1); diff --git a/packages/modules/web_themes/koala/source/src/pages/IndexPage.vue b/packages/modules/web_themes/koala/source/src/pages/IndexPage.vue index f41073f613..640d812f61 100644 --- a/packages/modules/web_themes/koala/source/src/pages/IndexPage.vue +++ b/packages/modules/web_themes/koala/source/src/pages/IndexPage.vue @@ -17,9 +17,9 @@ - + + + @@ -39,10 +39,14 @@ > - - + + + + @@ -55,7 +59,7 @@ import ChartCarousel from 'src/components/ChartCarousel.vue'; import ChargePointInformation from 'src/components/ChargePointInformation.vue'; import BatteryInformation from 'src/components/BatteryInformation.vue'; import VehicleInformation from 'src/components/VehicleInformation.vue'; -// import SmartHomeInformation from 'src/components/SmartHomeInformation.vue'; +import ConsumerInformation from 'src/components/ConsumerInformation.vue'; defineOptions({ name: 'IndexPage', @@ -67,6 +71,10 @@ const mqttStore = useMqttStore(); const accessBatteryAllowed = computed(() => { return mqttStore.batteryConfigured && mqttStore.batteryIds.length > 0; }); + +const consumersAvailable = computed(() => { + return mqttStore.consumerIds.length > 0; +}); diff --git a/packages/modules/web_themes/koala/source/src/components/charts/historyChart/HistoryChartLegendCategory.vue b/packages/modules/web_themes/koala/source/src/components/charts/historyChart/HistoryChartLegendCategory.vue index aeec8de4ee..3876526674 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/historyChart/HistoryChartLegendCategory.vue +++ b/packages/modules/web_themes/koala/source/src/components/charts/historyChart/HistoryChartLegendCategory.vue @@ -12,14 +12,14 @@ @@ -60,11 +60,12 @@