diff --git a/packages/control/algorithm/common.py b/packages/control/algorithm/common.py index 60f81c7fe5..68d1ebc86b 100644 --- a/packages/control/algorithm/common.py +++ b/packages/control/algorithm/common.py @@ -82,6 +82,7 @@ def set_current_counterdiff(diff_current: float, 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.io_actions.set_limit_loadmanager({"type": "cp", "id": chargepoint.num}, sum(diffs)*230) chargepoint.data.set.current = current log.info(f"LP{chargepoint.num}: Stromstärke {current}A") @@ -156,6 +157,7 @@ def update_raw_data(preferenced_chargepoints: List[Chargepoint], 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.io_actions.set_limit_loadmanager({"type": "cp", "id": chargepoint.num}, sum(diffs)*230) def consider_less_charging_chargepoint_in_loadmanagement(cp: Chargepoint, set_current: float) -> bool: diff --git a/packages/control/io_device.py b/packages/control/io_device.py index b24138a75b..678ff6f6af 100644 --- a/packages/control/io_device.py +++ b/packages/control/io_device.py @@ -4,6 +4,9 @@ from helpermodules.constants import NO_ERROR from modules.io_actions.controllable_consumers.dimming.api_eebus import DimmingEebus from modules.io_actions.controllable_consumers.dimming.api_io import DimmingIo + +from modules.io_actions.controllable_consumers.load_manager.api import LoadManager + from modules.io_actions.controllable_consumers.dimming_direct_control.api import DimmingDirectControl from modules.io_actions.controllable_consumers.ripple_control_receiver.api import RippleControlReceiver from modules.io_actions.generator_systems.stepwise_control.api_eebus import StepwiseControlEebus @@ -55,7 +58,8 @@ def __init__(self, num: Union[int, str]): class IoActions: def __init__(self): self.actions: Dict[int, Union[DimmingIo, DimmingEebus, DimmingDirectControl, - RippleControlReceiver, StepwiseControlEebus, StepwiseControlIo]] = {} + RippleControlReceiver, StepwiseControlEebus, StepwiseControlIo, + LoadManager]] = {} def setup(self): for action in self.actions.values(): @@ -102,3 +106,19 @@ def stepwise_control(self, device_id: int) -> Tuple[Optional[float], Loadmanagem return action.control_stepwise() else: return None, LoadmanagementLimit(None, None) + + def get_limit_loadmanager(self, device: Dict) -> Tuple[Optional[float], Optional[float], LoadmanagementLimit]: + for action in self.actions.values(): + if isinstance(action, LoadManager): + for d in action.config.configuration.devices: + if d == device: + return action.loadmanager_get_import_power_left() + else: + return None, None, LoadmanagementLimit(None, None) + + def set_limit_loadmanager(self, device: Dict, used_power: float) -> Optional[float]: + for action in self.actions.values(): + if isinstance(action, LoadManager): + for d in action.config.configuration.devices: + if d == device: + return action.loadmanager_set_import_power_left(used_power, used_power/230) diff --git a/packages/control/limiting_value.py b/packages/control/limiting_value.py index e4b32a16cf..c93d11772c 100644 --- a/packages/control/limiting_value.py +++ b/packages/control/limiting_value.py @@ -7,6 +7,9 @@ class LimitingValue(Enum): CURRENT = ", da der Maximal-Strom an Zähler {} erreicht ist." POWER = ", da die maximale Leistung an Zähler {} erreicht ist." UNBALANCED_LOAD = ", da die maximale Schieflast an Zähler {} erreicht ist." + LOADMANAGER = ", da der Lastmanager die Ladeleistung begrenzt." + LOADMANAGER_ERROR = (", da aufgrund eines Fehlers im Lastmanager die Ladeleistung auf die maximale Leistung " + "im Fehlerfall begrenzt wird.") DIMMING = ", da die Dimmung die Ladeleistung begrenzt." DIMMING_VIA_DIRECT_CONTROL = ", da die Dimmung per Direkt-Steuerung die Ladeleistung auf 4,2 kW begrenzt." RIPPLE_CONTROL_RECEIVER = (", da der Ladepunkt durch den RSE-Kontakt auf {}% der konfigurierten Anschlussleistung " diff --git a/packages/control/loadmanagement.py b/packages/control/loadmanagement.py index ae12e06f3c..dbfb3c67b6 100644 --- a/packages/control/loadmanagement.py +++ b/packages/control/loadmanagement.py @@ -26,6 +26,9 @@ def get_available_currents(self, available_currents, new_limit = self._limit_by_dimming(available_currents, cp) limit = new_limit if new_limit.limiting_value is not None else limit + available_currents, new_limit = self._limit_loadmanager(available_currents, cp) + 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) limit = new_limit if new_limit.limiting_value is not None else limit except ValueError as e: @@ -181,3 +184,30 @@ def _limit_by_ripple_control_receiver(self, if c > 0 else 0 for c in available_currents] log.debug(f"Reduzierung durch RSE-Kontakt auf {value*100}%, maximal {max_current*value}A") return available_currents, limit + + def _limit_loadmanager(self, + available_currents: List[float], + cp: Chargepoint) -> Tuple[List[float], LoadmanagementLimit]: + + loadmanager_power_left, loadmanager_current_left, limit = data.data.io_actions.get_limit_loadmanager({ + "type": "cp", "id": cp.num}) + + if loadmanager_power_left is not None and loadmanager_current_left is not None: + if sum(available_currents)*voltages_mean(cp.data.get.voltages) > loadmanager_power_left: + phases = 3-available_currents.count(0) + overload_per_phase = (sum(available_currents) - + loadmanager_power_left/voltages_mean(cp.data.get.voltages))/phases + available_currents = [c - overload_per_phase if c > 0 else 0 for c in available_currents] + log.debug( + f"Reduzierung der Leistung auf {sum(available_currents)*voltages_mean(cp.data.get.voltages)}W " + f"durch den Lastmanager. (Leistungsgrenze: {loadmanager_power_left}W)") + return available_currents, limit + if sum(available_currents) > loadmanager_current_left: + phases = 3 - available_currents.count(0) + overload_per_phase = (sum(available_currents) - loadmanager_current_left) / phases + available_currents = [max(c - overload_per_phase, 0) if c > 0 else 0 for c in available_currents] + log.debug( + f"Reduzierung der Ströme auf {available_currents}A durch den Lastmanager. " + f"(Stromgrenze: {loadmanager_current_left}A)") + return available_currents, limit + return available_currents, limit diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 94e877a19b..5108ef7530 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -851,6 +851,8 @@ def process_mqtt_topic(self, msg: mqtt.MQTTMessage): self.process_pv_topic(msg) elif "openWB/set/mqtt/vehicle/" in msg.topic: self.process_vehicle_topic(msg) + elif "openWB/set/mqtt/loadmanager/" in msg.topic: + self.loadmanager_topic(msg) def process_optional_topic(self, msg: mqtt.MQTTMessage): """ Handler für die Optionalen-Topics @@ -1214,3 +1216,19 @@ def process_legacy_smart_home_topic(self, msg): def _get_ramdisk_path(self) -> Path: return Path(__file__).resolve().parents[2]/"ramdisk" + + def loadmanager_topic(self, msg: mqtt.MQTTMessage): + """ Handler für die LoadManager-Topics + + Parameters + ---------- + msg: + enthält Topic und Payload + """ + try: + if re.search("^openWB/set/mqtt/loadmanager/[0-9]+/set/loadmanager$", msg.topic) is not None: + self._validate_value(msg, "json") + else: + self.__unknown_topic(msg) + except Exception: + log.exception(f"Fehler im setdata-Modul: Topic {msg.topic}, Value: {msg.payload}") diff --git a/packages/modules/io_actions/controllable_consumers/load_manager/api.py b/packages/modules/io_actions/controllable_consumers/load_manager/api.py new file mode 100644 index 0000000000..b419b04624 --- /dev/null +++ b/packages/modules/io_actions/controllable_consumers/load_manager/api.py @@ -0,0 +1,54 @@ +import logging +from typing import Optional, Tuple +from control import data +from control.limiting_value import LimitingValue, LoadmanagementLimit +from modules.common.abstract_io import AbstractIoAction +from modules.io_actions.common import check_fault_state_io_device +from modules.io_actions.controllable_consumers.load_manager.config import LoadManagerSetup +from modules.io_devices.load_manager.config import AnalogInputMapping + +from modules.common.abstract_device import DeviceDescriptor + +log = logging.getLogger(__name__) + + +class LoadManager(AbstractIoAction): + def __init__(self, config: LoadManagerSetup): + self.config = config + self.import_power_left = None + self.import_current_left = None + super().__init__() + + def setup(self) -> None: + if check_fault_state_io_device(self.config.configuration.io_device): + log.warning("Fehler des IO-Geräts: Lastmanager aktiviert für Failsafe-Modus.") + max_power = self.config.configuration.max_power_on_failure + max_current = self.config.configuration.max_current_on_failure + else: + max_power = data.data.io_states[f"io_states{self.config.configuration.io_device}" + ].data.get.analog_input[AnalogInputMapping.MAX_POWER.name] + max_current = sum(data.data.io_states[f"io_states{self.config.configuration.io_device}" + ].data.get.analog_input[AnalogInputMapping.MAX_CURRENT.name]) + self.import_power_left = max_power + self.import_current_left = max_current + + def loadmanager_get_import_power_left(self) -> Tuple[Optional[float], Optional[float], LoadmanagementLimit]: + if check_fault_state_io_device(self.config.configuration.io_device): + return (self.import_power_left, self.import_current_left, LoadmanagementLimit( + LimitingValue.LOADMANAGER_ERROR.value, + LimitingValue.LOADMANAGER_ERROR)) + return self.import_power_left, self.import_current_left, LoadmanagementLimit(LimitingValue.LOADMANAGER.value, + LimitingValue.LOADMANAGER) + + def loadmanager_set_import_power_left(self, used_power: float, used_current: float) -> None: + self.import_power_left -= used_power + self.import_current_left -= used_current + log.debug( + f"verbleibende Dimm-Leistung: {self.import_power_left}W, verbleibender Strom: {self.import_current_left}A") + + +def create_action(config: LoadManagerSetup, parent_device_type: str): + return LoadManager(config=config) + + +device_descriptor = DeviceDescriptor(configuration_factory=LoadManagerSetup) diff --git a/packages/modules/io_actions/controllable_consumers/load_manager/config.py b/packages/modules/io_actions/controllable_consumers/load_manager/config.py new file mode 100644 index 0000000000..8c066b1e75 --- /dev/null +++ b/packages/modules/io_actions/controllable_consumers/load_manager/config.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, field +from typing import Dict, List, Optional +from dataclass_utils.factories import empty_io_pattern_boolean_factory, empty_list_factory +from modules.io_actions.groups import ActionGroup + + +@dataclass +class LoadManagerConfig: + io_device: Optional[int] = None + input_pattern: List[Dict] = field(default_factory=empty_io_pattern_boolean_factory) + devices: List[Dict] = field(default_factory=empty_list_factory) + # [{"type": "cp", "id": 0}, + # {"type": "io", "id": 1, "digital_output": "SofortLa"}] + max_import_power: int = 0 + max_power_on_failure: float = 0 + max_current_on_failure: float = 0 + + +class LoadManagerSetup: + def __init__(self, + name: str = "Begrenzung per Lastmanager", + type: str = "load_manager", + id: int = 0, + configuration: LoadManagerConfig = None): + self.name = name + self.id = id + self.configuration = configuration or LoadManagerConfig() + self.type = type + self.group = ActionGroup.CONTROLLABLE_CONSUMERS.value diff --git a/packages/modules/io_devices/load_manager/__init__.py b/packages/modules/io_devices/load_manager/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/io_devices/load_manager/api.py b/packages/modules/io_devices/load_manager/api.py new file mode 100644 index 0000000000..67ab50e734 --- /dev/null +++ b/packages/modules/io_devices/load_manager/api.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 + +import logging + +from helpermodules import timecheck +from helpermodules.broker import BrokerClient +from helpermodules.utils.topic_parser import decode_payload +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import IoState +from modules.common.configurable_io import ConfigurableIo +from modules.io_devices.load_manager.config import AnalogInputMapping +from modules.io_devices.load_manager.config import LoadManager + +log = logging.getLogger(__name__) +control_command_log = logging.getLogger("steuve_control_command") + + +def create_io(config: LoadManager): + received_topics = {} + broker = None + + def read(): + broker.start_finite_loop() + log.debug(f"Empfange MQTT Daten für Lastmanager {config.id}: {received_topics}") + io_state = IoState() + io_state.analog_input = getattr(io_state, "analog_input", None) or {} + io_state.analog_output = getattr(io_state, "analog_output", None) or {} + io_state.digital_input = getattr(io_state, "digital_input", None) or {} + io_state.digital_output = getattr(io_state, "digital_output", None) or {} + + if received_topics.get(f"openWB/mqtt/loadmanager/{config.id}/set/loadmanager"): + payload = received_topics[f"openWB/mqtt/loadmanager/{config.id}/set/loadmanager"] + + timestamp = float(payload["timestamp"]) + age_s = timecheck.create_timestamp() - timestamp + # < = deaktiviert + if age_s > 60: + raise RuntimeError(f"Lastmanager-Daten sind veraltet: age={age_s:.1f}s, timestamp={timestamp}.") + + io_state.analog_input.update({AnalogInputMapping.MAX_POWER.name: payload["max_power"]}) + io_state.analog_input.update({AnalogInputMapping.MAX_CURRENT.name: payload["max_current"]}) + io_state.analog_input.update({AnalogInputMapping.TIMESTAMP.name: timestamp}) + return io_state + + def initializer(): + nonlocal broker + nonlocal received_topics + + def on_connect(client, userdata, flags, rc): + client.subscribe(f"openWB/mqtt/loadmanager/{config.id}/#") + + def on_message(client, userdata, message): + received_topics.update({message.topic: decode_payload(message.payload)}) + + received_topics = {} + broker = BrokerClient("subscribeMqttLoadmanager", + on_connect, on_message) + + return ConfigurableIo(config=config, component_reader=read, component_writer=lambda: None, initializer=initializer) + + +device_descriptor = DeviceDescriptor(configuration_factory=LoadManager) diff --git a/packages/modules/io_devices/load_manager/config.py b/packages/modules/io_devices/load_manager/config.py new file mode 100644 index 0000000000..810ee573b8 --- /dev/null +++ b/packages/modules/io_devices/load_manager/config.py @@ -0,0 +1,41 @@ +from enum import Enum +from typing import Dict, Union +from modules.common.io_setup import IoDeviceSetup + + +class AnalogInputMapping(Enum): + MAX_POWER = "max_power" + MAX_CURRENT = "max_current" + TIMESTAMP = "timestamp" + + +class LoadManagerConfiguration: + def __init__(self) -> None: + pass + + +def init_input(): + return {"analog": {pin.name: None for pin in AnalogInputMapping}} + + +def init_output(): + return {"analog": {}} + + +class LoadManager(IoDeviceSetup[LoadManagerConfiguration]): + def __init__(self, + name: str = "openWB Lastmanager", + type: str = "load_manager", + id: Union[int, str] = 0, + configuration: LoadManagerConfiguration = None, + input: Dict[str, Dict[int, float]] = None, + output: Dict[str, Dict[int, float]] = None) -> None: + self.name = name + self.type = type + self.id = id + self.configuration = configuration or LoadManagerConfiguration() + if input is None: + input = init_input() + if output is None: + output = init_output() + super().__init__(name, type, id, configuration or LoadManagerConfiguration(), input=input, output=output)