diff --git a/data/config/mosquitto/local/openwb_local.conf b/data/config/mosquitto/local/openwb_local.conf index f540b3dcf1..a0f04efd40 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:23 listener 1886 localhost allow_anonymous true @@ -28,6 +28,15 @@ topic openWB/chargepoint/+/get/# out 2 topic openWB/chargepoint/+/config/# out 2 topic openWB/chargepoint/template/# out 2 +topic openWB/consumer/get/# 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/consumer/+/set/# out 2 + topic openWB/internal_chargepoint/# out 2 topic openWB/io/# out 2 @@ -52,7 +61,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..238cc78842 100644 --- a/data/config/mosquitto/public/default-dynamic-security.json +++ b/data/config/mosquitto/public/default-dynamic-security.json @@ -70,7 +70,7 @@ "anonymousGroup": "anonymous", "roles": [ { - "rolename": "openwb-version:8", + "rolename": "openwb-version:9", "textname": "openWB Versionsnummer", "textdescription": "Diese Rolle ist ein Platzhalter für die openWB Versionsnummer und wird automatisch aktualisiert. Sie hat keine direkten Berechtigungen.", "acls": [] @@ -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 }, @@ -1091,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", @@ -1105,7 +1129,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 }, @@ -1144,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 } ] }, @@ -1508,6 +1556,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 +1616,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", @@ -1703,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", @@ -2807,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..fb373199dd 100644 --- a/data/config/mosquitto/public/role-templates.json +++ b/data/config/mosquitto/public/role-templates.json @@ -72,6 +72,62 @@ "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//usage", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/command/+/todo/addConsumerSchedulePlan", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/command/+/todo/addConsumerTimePlan", + "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/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/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..cecaebf573 100644 --- a/packages/control/algorithm/additional_current.py +++ b/packages/control/algorithm/additional_current.py @@ -3,10 +3,10 @@ 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__) @@ -18,45 +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}") - while len(preferenced_chargepoints): - cp = preferenced_chargepoints[0] - missing_currents, counts = common.get_missing_currents_left(preferenced_chargepoints) - 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) - 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 + 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 4dec5a7b4c..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,32 +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}") - while len(preferenced_cps): - cp = 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] = 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") - preferenced_cps.pop(0) + 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 707d88c0a2..347cd87e1f 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 @@ -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 f523b1d399..603dee204f 100644 --- a/packages/control/algorithm/filter_chargepoints.py +++ b/packages/control/algorithm/filter_chargepoints.py @@ -1,117 +1,106 @@ # 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.consumer.consumer import Consumer +from control.consumer.usage import ConsumerUsage +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.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) + 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 + 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 def2524811..5a408168de 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.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.chargepoint.chargepoint import Chargepoint +from control.consumer.consumer import Consumer +from control.counter_all.counter_all import CounterAll +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}], + [1, 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/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/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 48ce9eba6a..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,40 +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) - while len(preferenced_chargepoints): - cp = preferenced_chargepoints[0] - 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 - preferenced_chargepoints.pop(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 1a39d27678..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,87 +32,90 @@ 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}")) - 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) - 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], - feed_in_yield: Optional[int], - 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) - while len(chargepoints): - cp = chargepoints[0] - missing_currents, counts = common.get_missing_currents_left(chargepoints) - available_currents, limit = Loadmanagement().get_available_currents_surplus( - missing_currents, - voltages_mean(cp.data.get.voltages), - counter, - cp, - 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: - 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: - 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 = 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) - chargepoints.pop(0) + current = available_for_cp + + 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, 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)) + 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: @@ -132,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: @@ -152,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 a77060c8b0..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,27 +31,10 @@ 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.fixture(autouse=True) +def mock_data() -> None: + data.data_init(Mock()) + data.data.counter_all_data = CounterAll() @pytest.mark.parametrize("new_current, expected_current", @@ -81,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), @@ -89,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 @@ -134,27 +119,36 @@ 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.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/chargelog/chargelog_test.py b/packages/control/chargelog/chargelog_test.py index 3993d15ede..6f4ce60afb 100644 --- a/packages/control/chargelog/chargelog_test.py +++ b/packages/control/chargelog/chargelog_test.py @@ -30,6 +30,7 @@ def mock_daily_log(monkeypatch): 'hc': {'all': {'imported': 0}}, 'pv': {'all': {'exported': 2000}, 'pv1': {'exported': 2000}}, 'sh': {}, + 'consumer': {}, 'timestamp': 1652682900, 'prices': {'grid': 0.0003, 'pv': 0.00015, 'bat': 0.0002, 'cp': 0}}, {'bat': {'all': {'exported': 3000, 'imported': 2000, 'soc': 100}, @@ -44,6 +45,7 @@ def mock_daily_log(monkeypatch): 'hc': {'all': {'imported': 0}}, 'pv': {'all': {'exported': 2500}, 'pv1': {'exported': 2500}}, 'sh': {}, + 'consumer': {}, 'timestamp': 1652683200, 'prices': {'grid': 0.0003, 'pv': 0.00015, 'bat': 0.0002, 'cp': 0}}], "names": {"bat2": "Speicher2", "cp4": "LP 4", "pv1": "PV 1", "ev0": "EV0", "counter0": "Zähler0"}} @@ -132,6 +134,7 @@ def test_calc_charge_cost_reference_middle_day_change(mock_data, monkeypatch): 'hc': {'all': {'imported': 0}}, 'pv': {'all': {'exported': 2000}, 'pv1': {'exported': 2000}}, 'sh': {}, + 'consumer': {}, 'timestamp': 1652682900, 'prices': {'grid': 0.0003, 'pv': 0.00015, 'bat': 0.0002, 'cp': 0}}], "names": { @@ -151,6 +154,7 @@ def test_calc_charge_cost_reference_middle_day_change(mock_data, monkeypatch): 'hc': {'all': {'imported': 0}}, 'pv': {'all': {'exported': 2500}, 'pv1': {'exported': 2500}}, 'sh': {}, + 'consumer': {}, 'timestamp': 1652683200, 'prices': {'grid': 0.0003, 'pv': 0.00015, 'bat': 0.0002, 'cp': 0}}], "names": {"bat2": "Speicher2", "cp4": "LP 4", "pv1": "PV 1", "ev0": "EV0", "counter0": "Zähler0"}} 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..6620eb31d0 100644 --- a/packages/control/chargepoint/control_parameter.py +++ b/packages/control/chargepoint/control_parameter.py @@ -11,13 +11,12 @@ class ControlParameter: chargemode: Chargemode_enum = field(default=Chargemode_enum.STOP, metadata={ "topic": "control_parameter/chargemode"}) - current_plan: Optional[str] = field(default=None, metadata={"topic": "control_parameter/current_plan"}) + current_plan: Optional[int] = field(default=None, metadata={"topic": "control_parameter/current_plan"}) failed_phase_switches: int = field(default=0, metadata={"topic": "control_parameter/failed_phase_switches"}) limit: Optional[LoadmanagementLimit] = field(default_factory=loadmanagement_limit_factory, metadata={ "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/consumer/consumer.py b/packages/control/consumer/consumer.py new file mode 100644 index 0000000000..5ec2585569 --- /dev/null +++ b/packages/control/consumer/consumer.py @@ -0,0 +1,509 @@ +import datetime +import logging +from typing import Callable, Dict, List, 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, ChargepointState +from control.consumer.consumer_data import ConsumerData, ConsumerUsage, ResetModes, WaitForStartStates +from control.load_protocol import Load +from helpermodules import timecheck +from helpermodules.abstract_plans import ScheduledPlanConsumer +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 Consumer(Load): + PAUSE_BETWEEN_WAIT_FOR_START_TEST_RUNS = 3600 + + 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: Optional[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 setup_values_at_start(self): + self.data.set.state_str_prev = self.data.get.state_str + self.data.get.state_str = None + self.data.set.current_prev = self.data.set.current + + def update(self): + try: + self.setup_values_at_start() + if self.data.usage.type == ConsumerUsage.METER_ONLY: + 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 + self.reset_chargemode_at_time() + self.is_switch_interval_elapsed() + 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.process_on_time() + log.debug(f"Verbraucher {self.num}: Sollstrom {required_current}, " + f"min. Ist-Strom {max(self.data.get.currents)}," + f" Modus {mode}, Submodus {submode}, {message}") + except Exception: + log.exception(f"Fehler bei Verbraucher {self.num}") + + def process_on_time(self): + if self.data.get.charge_state: + self.data.set.on_time += data.data.general_data.data.control_interval + if self.data.control_parameter.timestamp_charge_start is None: + self.data.control_parameter.timestamp_charge_start = timecheck.create_timestamp() + elif self.data.get.charge_state is False: + self.data.control_parameter.timestamp_charge_start = None + + def reset_timestamp_start(self): + self.data.control_parameter.timestamp_charge_start = None + + def reset_on_time(self): + self.data.set.on_time = 0 + + def is_switch_interval_elapsed(self): + if (timecheck.create_timestamp() < self.data.set.timestamp_last_current_set + self.data.config.min_interval and + # wenn kein Betrieb, darf eingeschaltet werden, auch wenn das Intervall noch nicht abgelaufen ist + self.data.control_parameter.state != ChargepointState.NO_CHARGING_ALLOWED and + (self.data.usage.wait_for_start_active is False or + (self.data.usage.wait_for_start_active and + self.data.set.wait_for_start_state == WaitForStartStates.START_SIGNAL_RECEIVED))): + self.data.set.switch_interval_elapsed = False + else: + self.data.set.switch_interval_elapsed = True + + 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.") + + def get_parameter(self) -> Tuple[float, float, Optional[str], Optional[Chargemode], Chargemode]: + if self.data.set.switch_interval_elapsed is False: + log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") + return (0, + 0, + self.data.set.state_str_prev, + self.data.control_parameter.chargemode, + self.data.control_parameter.submode) + + if self.data.usage.type == ConsumerUsage.SUSPENDABLE_TUNABLE: + min_current = self.data.config.min_current + else: + min_current = self._convert_power_to_current(self.data.config.max_power) + required_current = 0 + submode = Chargemode.STOP + mode = self.data.usage.chargemode + message = None + if self.data.usage.chargemode == Chargemode.SCHEDULED_CHARGING: + required_current, tmp_message, submode = self.wait_for_start_handler(self.scheduled_charging) + message = f"{tmp_message or ''}".strip() + if submode != Chargemode.INSTANT_CHARGING and self.data.usage.time_charging.active: + required_current, tmp_message, submode = self.time_charging() + # Info vom Zielladen erhalten + message = f"{message or ''} {tmp_message or ''}".strip() + if required_current == 0: + if self.data.usage.chargemode == Chargemode.INSTANT_CHARGING: + required_current, tmp_message, submode = self.instant_charging() + elif self.data.usage.chargemode == Chargemode.ECO_CHARGING: + required_current, tmp_message, submode = self.wait_for_start_handler(self.eco_charging) + elif self.data.usage.chargemode == Chargemode.PV_CHARGING: + required_current, tmp_message, submode = self.wait_for_start_handler(self.pv_charging) + else: + tmp_message = None + message = f"{message or ''} {tmp_message or ''}".strip() + if self.data.usage.chargemode == Chargemode.STOP: + required_current, message, submode = self.stop() + 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]: + if self.data.get.charge_state: + return get_medium_charging_current(self.data.get.currents) + else: + return self._convert_power_to_current(self.data.config.max_power) + else: + return required_current + + 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[float, Optional[str], Chargemode]: + plan_data, remaining_time, duration = self._find_recent_plan(self.data.usage.scheduled_charging.plans) + if plan_data: + self.data.control_parameter.current_plan = plan_data.id + else: + 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[ScheduledPlanConsumer]) -> Tuple[Optional[ScheduledPlanConsumer], float, float]: + 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: + filtered_plans = [d for d in plans_diff_end_date if list(d.values())[0] > self.BUFFER_AFTER_END_TIME] + if filtered_plans: + sorted_plans = sorted(filtered_plans, key=lambda x: list(x.values())[0]) + for p in sorted_plans: + if self.BUFFER_AFTER_END_TIME < list(p.values())[0]: + plan_dict = p + break + else: + return None, 0, 0 + else: + return None, 0, 0 + 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 + break + else: + raise Exception(f"Verbraucher {self.num} konnte plan_id {plan_id} keine Plan zuordnen.") + + remaining_time, duration = self._calc_remaining_time(plan, diff_end_date) + + return plan, remaining_time, duration + return None, 0, 0 + + def _calc_remaining_time(self, + plan: ScheduledPlanConsumer, + 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 = ("Ziel erreicht, da das Gerät die gewünschte Dauer in Betrieb war.") + SCHEDULED_CHARGING_NO_PLANS_CONFIGURED = "Kein Ziel, da keine Ziel-Termine konfiguriert sind." + SCHEDULED_CHARGING_NO_DATE_PENDING = "Kein Ziel, da kein Ziel-Termin ansteht. " + SCHEDULED_CHARGING_USE_PV = "Betrieb startet {}. Falls vorhanden, wird Überschuss verwendet. " + SCHEDULED_CHARGING_CHEAP_HOUR = "Ziel, da ein günstiger Zeitpunkt für den preisbasierten Betrieb ist. {}" + SCHEDULED_CHARGING_EXPENSIVE_HOUR = ( + "Ziel ausstehend, da jetzt kein günstiger Zeitpunkt für den preisbasierten " + "Betrieb ist. {} Falls vorhanden, wird Überschuss verwendet. ") + + def scheduled_charging_calc_current( + self, + plan: Optional[ScheduledPlanConsumer], + remaining_time: float, + duration: float) -> Tuple[float, Optional[str], Chargemode]: + required_current = 0 + message = None + if plan is None: + if len(self.data.usage.scheduled_charging.plans) == 0: + return 0, self.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, Chargemode.STOP + else: + return 0, self.SCHEDULED_CHARGING_NO_DATE_PENDING, Chargemode.STOP + if (self.BUFFER_AFTER_END_TIME < remaining_time < 0 and + self.data.set.wait_for_start_state == WaitForStartStates.START_SIGNAL_RECEIVED): + self.reset_wait_for_start() + if duration <= 0: + message = self.SCHEDULED_REACHED_MAX_ON_TIME + submode = Chargemode.STOP + elif remaining_time < self.BUFFER_START_EARLIER: + submode = Chargemode.INSTANT_CHARGING + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) + 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() -> float: + return datetime.datetime.now().replace( + hour=23, minute=59, second=59, microsecond=999000).timestamp() + + def is_loading_hour(hour_list: List[int]) -> bool: + return data.data.optional_data.ep_is_charging_allowed_hours_list(hour_list) + + def convert_loading_hours_to_string(hour_list: List[datetime.datetime]) -> 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: List[str] = [] + + 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 = "Eingeschaltet 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 Einschaltzeiten: {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 = Chargemode.INSTANT_CHARGING + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) + else: + message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(get_hours_message()) + submode = Chargemode.PV_CHARGING + required_current = self._parse_required_current_by_usage(self.data.config.min_current) + 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 = Chargemode.PV_CHARGING + required_current = self._parse_required_current_by_usage(self.data.config.min_current) + return required_current, message, 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[float, Optional[str], Chargemode]: + 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 = 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._convert_power_to_current(self.data.config.max_power)) + submode = Chargemode.TIME_CHARGING + else: + message = self.TIME_CHARGING_NO_PLAN_ACTIVE + else: + message = self.TIME_CHARGING_NO_PLAN_CONFIGURED + return required_current, message, submode + + def instant_charging(self) -> Tuple[float, Optional[str], Chargemode]: + required_current = self._parse_required_current_by_usage( + self._convert_power_to_current(self.data.config.max_power)) + message = None + submode = Chargemode.INSTANT_CHARGING + return required_current, message, 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[float, Optional[str], Chargemode]: + message = None + 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._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.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.config.min_current) + submode = Chargemode.PV_CHARGING + return required_current, message, submode + + def pv_charging(self) -> Tuple[float, Optional[str], Chargemode]: + required_current = self._parse_required_current_by_usage(self.data.config.min_current) + message = None + submode = Chargemode.PV_CHARGING + return required_current, message, submode + + def stop(self) -> Tuple[float, Optional[str], Chargemode]: + required_current = 0 + message = "Verbraucher im Modus Stop. " + submode = Chargemode.STOP + return required_current, message, 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 Erreichen der Einschaltschwelle, um das Gerät zu starten.") + + def wait_for_start_handler( + self, func: Callable[[], Tuple[float, Optional[str], Chargemode]] + ) -> Tuple[float, Optional[str], Chargemode]: + if self.data.usage.wait_for_start_active: + if self.data.set.wait_for_start_state == WaitForStartStates.WAIT_FOR_DEVICE_START: + # mit Minimalstrom prüfen, damit Standby-Geräte nicht als laufend erkannt werden + if max(self.data.get.currents) > self.data.config.min_current: + self.data.set.wait_for_start_state = WaitForStartStates.WAIT_FOR_STOPPED_DEVICE + required_current = 0 + message = self.WAIT_FOR_STOPPED_DEVICE + 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 + submode = Chargemode.INSTANT_CHARGING + elif self.data.set.wait_for_start_state == WaitForStartStates.WAIT_FOR_STOPPED_DEVICE: + # mit Minimalstrom prüfen, damit Standby-Geräte nicht als laufend erkannt werden + if max(self.data.get.currents) < self.data.config.min_current: + self.data.set.wait_for_start_state = WaitForStartStates.DEVICE_WAITING_FOR_START + required_current, message, 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 + submode = Chargemode.STOP + elif self.data.set.wait_for_start_state == WaitForStartStates.DEVICE_WAITING_FOR_START: + # mit Minimalstrom prüfen, damit Standby-Geräte nicht als laufend erkannt werden + if max(self.data.get.currents) > self.data.config.min_current: + self.data.set.wait_for_start_state = WaitForStartStates.START_SIGNAL_RECEIVED + required_current, message, submode = func() + else: + required_current, message, 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, submode = func() + 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, submode = func() + return required_current, message, submode + + 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() + self.reset_on_time() + + 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 (self.data.usage.reset_chargemode.time < timecheck.create_timestamp() < + self.data.usage.reset_chargemode.time + data.data.general_data.data.control_interval): + 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: float, + required_current: float, + phases: int, + submode: Chargemode, + mode: Optional[Chargemode]): + 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: Optional[Chargemode]) -> None: + self.submode_changed = (submode != self.data.control_parameter.submode) + 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() + self.reset_wait_for_start() + else: + self.chargemode_changed = False diff --git a/packages/control/consumer/consumer_all.py b/packages/control/consumer/consumer_all.py new file mode 100644 index 0000000000..c33d036a2b --- /dev/null +++ b/packages/control/consumer/consumer_all.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass, field +import logging + +from control import data +from helpermodules.constants import NO_ERROR + + +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"}) + fault_str: str = field(default=NO_ERROR, metadata={"topic": "get/fault_str"}) + fault_state: int = field(default=0, metadata={"topic": "get/fault_state"}) + 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, fault_state = 0, 0, 0, 0 + try: + for consumer in data.data.consumer_data.values(): + try: + power = power + consumer.data.get.power + except Exception: + log.exception(f"Fehler in der allgemeinen Verbaucher-Klasse für Verbaucher {consumer}") + imported = imported + consumer.data.get.imported + exported = exported + consumer.data.get.exported + fault_state = max(fault_state, consumer.data.get.fault_state) + + self.data.get.power = power + self.data.get.imported = imported + self.data.get.exported = exported + self.data.get.fault_state = fault_state + self.data.get.fault_str = NO_ERROR if fault_state == 0 else ( + "Bitte die Statusmeldungen der Verbraucher prüfen. " + "Es haben nicht alle Module aktuelle Zählerstände geliefert.") + 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..584bd441b0 --- /dev/null +++ b/packages/control/consumer/consumer_data.py @@ -0,0 +1,121 @@ +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Optional +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 ScheduledPlanConsumer, TimeChargingPlanConsumer +from helpermodules.constants import NO_ERROR +from modules.common.consumer_setup import ConsumerSetup + + +@dataclass +class EcoCharging: + price_limit: float = 0.0002 + + +@dataclass +class ScheduledCharging: + plans: List[ScheduledPlanConsumer] = field(default_factory=empty_list_factory) + + +@dataclass +class TimeCharging: + active: bool = False + 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 Usage: + chargemode: Chargemode = Chargemode.INSTANT_CHARGING + eco_charging: EcoCharging = field(default_factory=lambda: EcoCharging()) + scheduled_charging: ScheduledCharging = field(default_factory=lambda: ScheduledCharging()) + time_charging: TimeCharging = field(default_factory=lambda: TimeCharging()) + type: ConsumerUsage = ConsumerUsage.METER_ONLY + reset_chargemode: ResetChargemode = field(default_factory=lambda: ResetChargemode()) + wait_for_start_active: bool = True + + +@dataclass +class ConsumerConfig: + connected_phases: int = 1 + phase_1: int = 1 + max_power: float = 5000 + min_current: float = 0.5 + min_interval: int = 60 + + +@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 = field(default=0, metadata={"topic": "set/current"}) + loadmanagement_available: bool = False + 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 + state_str_prev: str = "" + 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 + switch_interval_elapsed: bool = False + timestamp_last_current_set: float = field(default=0, metadata={"topic": "set/timestamp_last_current_set"}) + wait_for_start_state: WaitForStartStates = field( + default=WaitForStartStates.WAIT_FOR_DEVICE_START, metadata={"topic": "set/wait_for_start_state"}) + + +@dataclass +class ConsumerData: + control_parameter: ControlParameter = field(default_factory=control_parameter_factory) + get: Get = field(default_factory=lambda: Get()) + set: Set = field(default_factory=lambda: Set()) + module: ConsumerSetup = None + config: ConsumerConfig = field(default_factory=lambda: ConsumerConfig()) + extra_meter: Optional[int] = None + usage: Usage = field(default_factory=lambda: Usage(), metadata={"topic": "usage"}) diff --git a/packages/control/consumer/consumer_test.py b/packages/control/consumer/consumer_test.py new file mode 100644 index 0000000000..9f229a26b4 --- /dev/null +++ b/packages/control/consumer/consumer_test.py @@ -0,0 +1,385 @@ +from unittest.mock import Mock +from typing import Dict, List, Optional, Tuple +import datetime + +import pytest + +from helpermodules.abstract_plans import (ScheduledChargingPlan, ScheduledPlanConsumer, TimeChargingPlan, + TimeChargingPlanConsumer) +from helpermodules import timecheck +from control import optional +from control import data +from control.general import General +from control.bat_all import BatAll +from control.chargemode import Chargemode +from control.consumer.consumer import Consumer +from control.consumer.consumer_data import WaitForStartStates +from control.consumer.usage import ConsumerUsage + + +@pytest.fixture(autouse=True) +def mock_data() -> None: + data.data_init(Mock()) + data.data.general_data = General() + data.data.optional_data = optional.Optional() + + +@pytest.fixture +def consumer() -> Consumer: + load = Consumer(1) + load.data.usage.type = ConsumerUsage.CONTINUOUS + load.data.config.min_current = 6 + load.data.config.max_power = 2300 + load.data.config.connected_phases = 1 + load.data.get.voltages = [230] + load.data.get.currents = [10] + return load + + +@pytest.mark.parametrize( + ("wait_for_start_active", + "state", "currents", + "func_result", + "expected_result", + "expected_state", + "func_calls"), + [ + pytest.param( + False, + WaitForStartStates.WAIT_FOR_DEVICE_START, + [0]*3, + (11, "ok", Chargemode.PV_CHARGING), + (11, "ok", Chargemode.PV_CHARGING), + WaitForStartStates.WAIT_FOR_DEVICE_START, + 1, + id="wait-for-start-disabled-passthrough", + ), + pytest.param( + True, + WaitForStartStates.WAIT_FOR_DEVICE_START, + [10]*3, + (9, "running", Chargemode.PV_CHARGING), + (0, Consumer.WAIT_FOR_STOPPED_DEVICE, Chargemode.STOP), + WaitForStartStates.WAIT_FOR_STOPPED_DEVICE, + 0, + id="wait-for-device-start-charge-detected", + ), + pytest.param( + True, + WaitForStartStates.WAIT_FOR_DEVICE_START, + [0]*3, + (9, "running", Chargemode.PV_CHARGING), + (10, Consumer.WAIT_FOR_DEVICE_START, Chargemode.INSTANT_CHARGING), + WaitForStartStates.WAIT_FOR_DEVICE_START, + 0, + id="wait-for-device-start-no-charge", + ), + pytest.param( + True, + WaitForStartStates.WAIT_FOR_STOPPED_DEVICE, + [0]*3, + (8, "pv ok", Chargemode.PV_CHARGING), + (8, f"{Consumer.DEVICE_WAITING_FOR_START} pv ok", Chargemode.PV_CHARGING), + WaitForStartStates.DEVICE_WAITING_FOR_START, + 1, + id="wait-for-stopped-device-started-waiting", + ), + pytest.param( + True, + WaitForStartStates.DEVICE_WAITING_FOR_START, + [10]*3, + (7, "started", Chargemode.INSTANT_CHARGING), + (7, "started", Chargemode.INSTANT_CHARGING), + WaitForStartStates.START_SIGNAL_RECEIVED, + 1, + id="device-waiting-start-signal-received", + ), + pytest.param( + True, + WaitForStartStates.START_SIGNAL_RECEIVED, + [10]*3, + (10, "continue", Chargemode.PV_CHARGING), + (10, "continue", 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, + currents: List[float], + func_result: Tuple[int, str, Optional[Chargemode]], + expected_result: Tuple[int, str, Optional[Chargemode]], + expected_state: WaitForStartStates, + func_calls: int, +): + # setup + consumer.data.usage.wait_for_start_active = wait_for_start_active + consumer.data.set.wait_for_start_state = state + consumer.data.get.currents = currents + 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 + + +@pytest.mark.parametrize( + "plans, plan_found, expected", + [ + pytest.param({}, None, (0, + Consumer.TIME_CHARGING_NO_PLAN_CONFIGURED, Chargemode.STOP), id="no plan defined"), + pytest.param({"0": TimeChargingPlanConsumer(id=0)}, None, + (0, Consumer.TIME_CHARGING_NO_PLAN_ACTIVE, Chargemode.STOP), id="no plan active"), + pytest.param({"0": TimeChargingPlanConsumer(id=0)}, TimeChargingPlanConsumer(id=0), + (10, None, Chargemode.TIME_CHARGING), id="plan active"), + pytest.param({"0": TimeChargingPlanConsumer(id=0)}, None, + (0, Consumer.TIME_CHARGING_NO_PLAN_ACTIVE, Chargemode.STOP), + id="plan defined but not found"), + ] +) +def test_time_charging(plans: Dict[int, TimeChargingPlanConsumer], + plan_found: TimeChargingPlanConsumer, + expected: Tuple[float, Optional[str], Chargemode], + consumer: Consumer, + monkeypatch: pytest.MonkeyPatch): + # setup + consumer.data.usage.time_charging.plans = plans + check_plans_timeframe_mock = Mock(return_value=plan_found) + monkeypatch.setattr(timecheck, "check_plans_timeframe", check_plans_timeframe_mock) + + # execution + ret = consumer.time_charging() + + # evaluation + assert ret == expected + + +@pytest.mark.parametrize( + "min_bat_soc, charging_allowed_mock, soc, expected", + [ + pytest.param(80, False, 81, (0, Consumer.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL, Chargemode.STOP), + id="Konflikt mit aktiver Speichersteuerung -> nicht laden"), + pytest.param(80, True, 79, (0, Consumer.TIME_CHARGING_MIN_BAT_SOC_REACHED, Chargemode.STOP), + id="Mindest-SoC des Speichers unterschritten -> nicht laden"), + pytest.param(80, True, 80, (10, None, Chargemode.TIME_CHARGING), id="laden erlaubt"), + pytest.param(None, True, 80, (10, None, Chargemode.TIME_CHARGING), + id="Mindest-SoC-Beachtung nicht konfiguriert, laden erlaubt"), + ] +) +def test_time_charging_min_bat_soc(min_bat_soc: Optional[int], + charging_allowed_mock: bool, + soc: float, + expected: Tuple[float, Optional[str], Chargemode], + consumer: Consumer, + monkeypatch: pytest.MonkeyPatch): + # setup + plan = TimeChargingPlan(id=0) + plan.min_bat_soc = min_bat_soc + consumer.data.usage.time_charging.plans = [plan] + check_plans_timeframe_mock = Mock(return_value=plan) + monkeypatch.setattr(timecheck, "check_plans_timeframe", check_plans_timeframe_mock) + + data.data.bat_all_data = BatAll() + data.data.bat_all_data.data.config.configured = True + data.data.bat_all_data.data.get.soc = soc + monkeypatch.setattr(data.data.bat_all_data, "time_charging_min_bat_soc_allowed", + Mock(return_value=charging_allowed_mock)) + + # execution + ret = consumer.time_charging() + + # evaluation + assert ret == expected + + +def test_instant_charging(consumer: Consumer): + # setup & execution + ret = consumer.instant_charging() + + # evaluation + assert ret == (10, None, Chargemode.INSTANT_CHARGING) + + +def test_pv_charging(consumer: Consumer): + # setup & execution + ret = consumer.pv_charging() + + # evaluation + # bei Dauerverbrauchern wird Maximal-Strom angenommen + assert ret == (10, None, Chargemode.PV_CHARGING) + + +@pytest.mark.parametrize( + "on_time, plan_duration, diff_end_date, expected", + [ + pytest.param(0, 3600, 6000, (2400, 3600), id="no-runtime-yet"), + pytest.param(1200, 3600, 6000, (3600, 2400), id="already-running"), + pytest.param(3700, 3600, 1000, (1100, -100), id="duration-exceeded"), + ], +) +def test_calc_remaining_time(on_time: float, + plan_duration: float, + diff_end_date: float, + expected: Tuple[float, float], + consumer: Consumer): + # setup + consumer.data.set.on_time = on_time + plan = Mock(duration=plan_duration) + + # execution + ret = consumer._calc_remaining_time(plan, diff_end_date) + + # evaluation + assert ret == expected + + +@pytest.mark.parametrize( + "end_time_mock, expected_plan_num", + [ + pytest.param([1000, 1500, 2000], 0, id="nächster Zieltermin Plan 0"), + pytest.param([-100, 45000, 50000], 0, id="Plan 0 abgelaufen, nächster Tag"), + pytest.param([1500, 2000, 1000], 2, id="nächster Zieltermin Plan 2"), + pytest.param([-1201]*3, 0, id="kein Plan"), + ]) +def test_scheduled_charging_recent_plan(end_time_mock, + expected_plan_num: Optional[int], + consumer: Consumer, + monkeypatch: pytest.MonkeyPatch): + # setup + calculate_duration_mock = Mock(return_value=(100, 500)) + monkeypatch.setattr(Consumer, "_calc_remaining_time", calculate_duration_mock) + check_end_time_mock = Mock(side_effect=end_time_mock) + monkeypatch.setattr(timecheck, "check_end_time", check_end_time_mock) + plan_mock_0 = Mock(spec=ScheduledPlanConsumer, active=True, id=0) + plan_mock_1 = Mock(spec=ScheduledPlanConsumer, active=True, id=1) + plan_mock_2 = Mock(spec=ScheduledPlanConsumer, active=True, id=2) + plans = [plan_mock_0, plan_mock_1, plan_mock_2] + + # execution + selected_plan, remaining_time, duration = consumer._find_recent_plan(plans) + + # evaluation + if selected_plan: + assert selected_plan.id == expected_plan_num + assert remaining_time == 100 + assert duration == 500 + else: + assert selected_plan is None + assert remaining_time == 0 + assert duration == 0 + + +@pytest.mark.parametrize( + "plan, remaining_time, duration, expected", + [ + pytest.param(None, 0, 0, (0, Consumer.SCHEDULED_CHARGING_NO_DATE_PENDING, Chargemode.STOP), + id="no date pending"), + pytest.param(ScheduledPlanConsumer(), 0, 0, (0, Consumer.SCHEDULED_REACHED_MAX_ON_TIME, + Chargemode.STOP), id="reached max on time"), + pytest.param(ScheduledPlanConsumer(), 299, 3600, (10, None, Chargemode.INSTANT_CHARGING), + id="in time"), + pytest.param(ScheduledPlanConsumer(), -500, 3600, (10, None, Chargemode.INSTANT_CHARGING), + id="too late, but didn't miss for today"), + pytest.param(ScheduledPlanConsumer(), -800, 780, (10, None, Chargemode.INSTANT_CHARGING), + id="few minutes too late, but didn't miss for today"), + pytest.param(ScheduledPlanConsumer(), 601, 3600, (10, Consumer.SCHEDULED_CHARGING_USE_PV.format( + "um 8:50 Uhr"), Chargemode.PV_CHARGING), id="too early, use pv"), + ]) +def test_scheduled_charging_calc_current(plan: Optional[ScheduledPlanConsumer], + remaining_time: float, + duration: float, + expected: Tuple[float, Optional[str], Chargemode], + consumer: Consumer,): + # setup + if plan is not None: + plan.id = 0 + # json verwandelt Keys in strings + consumer.data.usage.scheduled_charging.plans = [plan] + + # execution + ret = consumer.scheduled_charging_calc_current(plan, remaining_time, duration) + + # evaluation + assert ret == expected + + +def test_scheduled_charging_calc_current_no_plans(consumer: Consumer): + # execution + ret = consumer.scheduled_charging_calc_current(None, 0, 0) + + # evaluation + assert ret == (0, Consumer.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, Chargemode.STOP) + + +LOADING_HOURS_TODAY = [datetime.datetime( + year=2022, month=5, day=16, hour=8, minute=0).timestamp()] + +LOADING_HOURS_TOMORROW = [datetime.datetime( + year=2022, month=5, day=17, hour=8, minute=0).timestamp()] + + +@pytest.mark.parametrize( + "is_loading_hour, loading_hours, expected", + [ + pytest.param(True, LOADING_HOURS_TODAY + LOADING_HOURS_TOMORROW, + ( + 10, + Consumer.SCHEDULED_CHARGING_CHEAP_HOUR.format( + "Eingeschaltet wird jetzt sowie morgen 8:00."), + Chargemode.INSTANT_CHARGING), + id="cheap_hour_charge_with_instant_charging"), + pytest.param(False, LOADING_HOURS_TODAY, + ( + 10, + Consumer.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format( + "Eingeschaltet wird heute 8:00."), + Chargemode.PV_CHARGING), + id="expensive_hour_charge_with_pv"), + pytest.param(False, LOADING_HOURS_TODAY, + ( + 10, + Consumer.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format( + "Eingeschaltet wird heute 8:00."), + Chargemode.PV_CHARGING), + id="expensive_hour_no_charge_with_pv "), + pytest.param(False, LOADING_HOURS_TODAY + LOADING_HOURS_TOMORROW, + ( + 10, + Consumer.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format( + "Eingeschaltet wird heute 8:00 sowie morgen 8:00."), + Chargemode.PV_CHARGING), + id="expensive_hour_no_charge_with_pv scheduled for tomorrow"), + ]) +def test_scheduled_charging_calc_current_electricity_tariff( + is_loading_hour: bool, + loading_hours: List[float], + expected: Tuple[float, Optional[str], Chargemode], + monkeypatch: pytest.MonkeyPatch, + consumer: Consumer): + # setup + datetime_mock = Mock(wraps=datetime.datetime) + datetime_mock.now.return_value = datetime.datetime.fromtimestamp(LOADING_HOURS_TODAY[0]) + monkeypatch.setattr(datetime, "datetime", datetime_mock) + + plan = ScheduledChargingPlan() + consumer.data.usage.scheduled_charging.plans = [plan] + # für Github-Test keinen Zeitstempel verwenden + mock_ep_get_loading_hours = Mock(return_value=loading_hours) + monkeypatch.setattr(data.data.optional_data, "ep_get_loading_hours", mock_ep_get_loading_hours) + mock_is_list_valid = Mock(return_value=is_loading_hour) + monkeypatch.setattr(data.data.optional_data, "ep_is_charging_allowed_hours_list", mock_is_list_valid) + data.data.optional_data.data.electricity_pricing.configured = True + + # execution + ret = consumer.scheduled_charging_calc_current(plan, remaining_time=601, duration=3600) + + # evaluation + assert ret == expected 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/counter.py b/packages/control/counter.py index e7351b3cef..17e6bf8af3 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__) @@ -94,6 +96,26 @@ class CounterData: set: Set = field(default_factory=set_factory) +@dataclass(frozen=True) +class SwitchOffTexts: + stop: str + waiting: str + waiting_min_interval: str + no_stop: str + exceeded: str + not_charging: str + + +@dataclass(frozen=True) +class SwitchOnTexts: + fallen_below: str + waiting: str + not_exceeded: str + expired: str + max_phases: str = "" + feed_in_limit_note: str = "" + + class Counter: MAX_EVU_ERROR_DURATION = 60 @@ -119,7 +141,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 +167,24 @@ 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] + # Wenn der Verbraucher nicht angesteuert werden darf, im LM als nicht veränderbaren Verbrauch + # berücksichtigen. + if load.data.set.switch_interval_elapsed is False: + log.debug(f"Verbraucher {load.num} als unveränderlichen Verbrauch im LM " + f"mit {load.data.get.power}W berücksichtigt.") + continue 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: @@ -186,6 +217,14 @@ def _set_power_left(self, loadmanagement_available: bool) -> None: power_raw = self.data.get.power for cp in data.data.cp_data.values(): power_raw -= cp.data.get.power + for consumer in data.data.consumer_data.values(): + # Wenn der Verbraucher nicht angesteuert werden darf, im LM als nicht veränderbaren Verbrauch + # berücksichtigen. + if consumer.data.set.switch_interval_elapsed: + power_raw -= consumer.data.get.power + else: + log.debug(f"Verbraucher {consumer.num} als unveränderlichen Verbrauch im LM " + f"mit {consumer.data.get.power}W berücksichtigt.") self.data.set.raw_power_left = self.data.config.max_total_power - power_raw log.info(f'Verbleibende Leistung an Zähler {self.num}: {self.data.set.raw_power_left}W') else: @@ -219,7 +258,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() @@ -231,9 +270,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 @@ -242,7 +285,7 @@ def get_control_range_state(self, feed_in_yield: int) -> 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 @@ -258,35 +301,61 @@ def get_usable_surplus(self, feed_in_yield: float) -> float: return (-self.calc_surplus() + self.data.set.released_surplus - self.data.set.reserved_surplus - feed_in_yield) - SWITCH_ON_FALLEN_BELOW = "Einschaltschwelle während der Wartezeit unterschritten." - SWITCH_ON_WAITING = "Die Ladung wird gestartet, sobald in {} die Wartezeit abgelaufen ist." - SWITCH_ON_NOT_EXCEEDED = ("Die Ladung kann nicht gestartet werden, da die Einschaltschwelle nicht erreicht " - "wird.") - 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]: + SWITCH_ON_TEXTS_CP = SwitchOnTexts( + fallen_below="Einschaltschwelle während der Wartezeit unterschritten.", + waiting="Die Ladung wird gestartet, sobald in {} die Wartezeit abgelaufen ist.", + not_exceeded=("Die Ladung kann nicht gestartet werden, da die Einschaltschwelle nicht erreicht " + "wird."), + expired="Einschaltschwelle für die Dauer der Wartezeit überschritten.", + max_phases="Der Überschuss ist ausreichend, um direkt mit {} Phasen zu laden.", + feed_in_limit_note=" Die Einspeisegrenze wird berücksichtigt.", + ) + SWITCH_ON_TEXTS_CONSUMER = SwitchOnTexts( + fallen_below="Einschaltschwelle während der Wartezeit unterschritten.", + waiting="Der Betrieb wird gestartet, sobald in {} die Wartezeit abgelaufen ist.", + not_exceeded=("Der Betrieb kann nicht gestartet werden, da die Einschaltschwelle nicht erreicht " + "wird."), + expired="Einschaltschwelle für die Dauer der Wartezeit überschritten.", + ) + + def _get_switch_on_texts(self, load: Load) -> SwitchOnTexts: + if isinstance(load, Chargepoint): + return self.SWITCH_ON_TEXTS_CP + return self.SWITCH_ON_TEXTS_CONSUMER + + 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 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 + 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 = chargepoint.data.set.charge_template.data.chargemode.pv_charging.\ - feed_in_limit - pv_config = data.data.general_data.data.chargemode_config.pv_charging + texts = self._get_switch_on_texts(load) + 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. @@ -294,7 +363,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 = texts.fallen_below control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED else: # Timer starten @@ -302,22 +371,22 @@ def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: not feed_in_limit): 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)) + message = texts.waiting.format(timecheck.convert_timestamp_delta_to_time_string( + timestamp_switch_on_off, switch_on_delay)) if feed_in_limit: - message += "Die Einspeisegrenze wird berücksichtigt." + message += texts.feed_in_limit_note control_parameter.state = ChargepointState.SWITCH_ON_DELAY else: # Einschaltschwelle nicht erreicht - message = self.SWITCH_ON_NOT_EXCEEDED.format(pv_config.switch_on_threshold) + message = texts.not_exceeded 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. @@ -328,117 +397,155 @@ 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 + texts = self._get_switch_on_texts(load) + 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): - msg = self.SWITCH_ON_WAITING.format(timecheck.convert_timestamp_delta_to_time_string( - control_parameter.timestamp_switch_on_off, pv_config.switch_on_delay)) + switch_on_delay): + msg = texts.waiting.format(timecheck.convert_timestamp_delta_to_time_string( + 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 = texts.expired 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 - 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 += texts.max_phases.format(ev_template.data.max_phases) + elif isinstance(load, Consumer): + control_parameter.state = ChargepointState.CHARGING_ALLOWED + load.set_state_and_log(msg) except Exception: log.exception("Fehler im allgemeinen PV-Modul") - SWITCH_OFF_STOP = "Ladevorgang nach Ablauf der Wartezeit gestoppt." - SWITCH_OFF_WAITING = "Ladevorgang wird nach Ablauf der Wartezeit in {} gestoppt." - SWITCH_OFF_NO_STOP = ("Der Ladevorgang wird trotz fehlenden Überschusses nicht gestoppt, da in dem Fahrzeug-Profil " - "die Einstellung 'Ladung aktiv halten' aktiviert ist.") - SWITCH_OFF_EXCEEDED = "Abschaltschwelle während der Verzögerung überschritten." - SWITCH_OFF_NOT_CHARGING = ("Da das EV nicht lädt und die Abschaltschwelle erreicht wird, " - "wird die Ladefreigabe sofort entzogen.") + SWITCH_OFF_TEXTS_CP = SwitchOffTexts( + stop="Ladevorgang nach Ablauf der Wartezeit gestoppt.", + waiting="Ladevorgang wird nach Ablauf der Wartezeit in {} gestoppt.", + waiting_min_interval="Ladevorgang wird nach Ablauf des minimalen Regelintervalls in {} gestoppt.", + no_stop=("Der Ladevorgang wird trotz fehlenden Überschusses nicht gestoppt, da in dem Fahrzeug-Profil " + "die Einstellung 'Ladung aktiv halten' aktiviert ist."), + exceeded="Abschaltschwelle während der Verzögerung überschritten.", + not_charging=("Da das EV nicht lädt und die Abschaltschwelle erreicht wird, " + "wird die Ladefreigabe sofort entzogen."), + ) + SWITCH_OFF_TEXTS_CONSUMER = SwitchOffTexts( + stop="Verbraucher nach Ablauf der Wartezeit gestoppt.", + waiting="Verbraucher wird nach Ablauf der Wartezeit in {} gestoppt.", + waiting_min_interval="Betrieb wird nach Ablauf des minimalen Regelintervalls in {} gestoppt.", + no_stop="Der Betrieb wird trotz fehlenden Überschusses nicht gestoppt.", + exceeded="Abschaltschwelle während der Verzögerung überschritten.", + not_charging=("Da der Verbraucher nicht aktiv ist und die Abschaltschwelle erreicht wird, " + "wird die Freigabe sofort entzogen."), + ) + + def _get_switch_off_texts(self, load: Load) -> SwitchOffTexts: + if isinstance(load, Chargepoint): + return self.SWITCH_OFF_TEXTS_CP + return self.SWITCH_OFF_TEXTS_CONSUMER + + 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, chargepoint: Chargepoint) -> None: + 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 + texts = self._get_switch_off_texts(load) + 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( + if (isinstance(load, Consumer) and + timecheck.create_timestamp() < (load.data.set.timestamp_last_current_set + + load.data.config.min_interval)): + msg = texts.waiting_min_interval.format(timecheck.convert_timestamp_delta_to_time_string( + load.data.set.timestamp_last_current_set, load.data.config.min_interval)) + elif 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 - msg = self.SWITCH_OFF_STOP + self.data.set.released_surplus -= load.data.set.required_power + msg = texts.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) + msg = texts.waiting.format(timecheck.convert_timestamp_delta_to_time_string( + 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 chargepoint.data.set.charge_template.data.chargemode.pv_charging.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.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 + 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 + texts = self._get_switch_off_texts(load) + 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 - msg = self.SWITCH_OFF_EXCEEDED + self.data.set.released_surplus -= load.data.set.required_power + msg = texts.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 @@ -446,33 +553,33 @@ 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 + msg = texts.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 - 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)) + self.data.set.released_surplus += load.data.set.required_power + msg = texts.waiting.format(timecheck.convert_timestamp_delta_to_time_string( + 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. else: - msg = self.SWITCH_OFF_NO_STOP + msg = texts.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): @@ -504,7 +611,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 @@ -519,8 +626,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.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..ea9e30e8e0 --- /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_loads(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: ComponentType, id: int) -> None: ... + def remove_loadmanagement_prio_item(self, id: int) -> 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( + 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..70d5ccaedb --- /dev/null +++ b/packages/control/counter_all/hierarchy.py @@ -0,0 +1,319 @@ +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_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_loads = [] + 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_loads_connected_to_counter(counter_object) + except KeyError: + # Kein Ladepunkt unter dem Zähler + pass + return self.connected_loads + + 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_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_loads_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 96% rename from packages/control/hierarchy_test.py rename to packages/control/counter_all/hierarchy_test.py index 297a9d7a3b..97354107e1 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 @@ -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 new file mode 100644 index 0000000000..c004f9f1ff --- /dev/null +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -0,0 +1,68 @@ +import logging +from typing import Dict, Generator, List, Tuple + +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: 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: + 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" or item["type"] == "consumer": + 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" or entry["type"] == "consumer": + grouped_cps = [] + for cp in filtered_cps: + if cp.data.config.ev == entry["id"]: + grouped_cps.append(cp) + if len(grouped_cps) > 0: + sorted_cps.append(grouped_cps) + elif entry["type"] == "group": + 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(grouped_cps) + if len(sorted_grouped_cps) > 0: + sorted_cps.append(sorted_grouped_cps) + return sorted_cps diff --git a/packages/control/counter_all/loadmanagement_prio_test.py b/packages/control/counter_all/loadmanagement_prio_test.py new file mode 100644 index 0000000000..e1b8ccdd6b --- /dev/null +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -0,0 +1,271 @@ + +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 +from modules.common.component_type import ComponentType + + +@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, 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", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], 4, ComponentType.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: ComponentType, + 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, expected_loadmanagement_prios", + [ + pytest.param([{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 2}], + 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", + "label": "Wichtige Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 0, }, + {"type": "vehicle", "id": 1, }, + ] + }, + {"type": "vehicle", "id": 2}, + ], 2, [ + { + "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, [ + { + "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, [{"type": "vehicle", "id": 2}], id="nested list, empty group"), + ] +) +def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], + id: int, + 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] == [cp1, cp2, cp3] # 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/counter_test.py b/packages/control/counter_test.py index 4b3e59466a..ef1eaac040 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 @@ -111,20 +111,22 @@ class Params: cases = [ Params("Einschaltschwelle wurde unterschritten, Timer zurücksetzen", False, 1500, -119, 1500, 1652683250.0, ChargepointState.SWITCH_ON_DELAY, - Counter.SWITCH_ON_FALLEN_BELOW.format(1500), None, 0), + Counter.SWITCH_ON_TEXTS_CP.fallen_below.format(1500), None, 0), Params("Timer starten", False, 0, 1501, 1500, None, ChargepointState.NO_CHARGING_ALLOWED, - Counter.SWITCH_ON_WAITING.format("30 Sek."), 1652683252.0, 1500), + Counter.SWITCH_ON_TEXTS_CP.waiting.format("30 Sek."), 1652683252.0, 1500), Params("Einschaltschwelle nicht erreicht", False, 0, 1499, 1500, - None, ChargepointState.NO_CHARGING_ALLOWED, Counter.SWITCH_ON_NOT_EXCEEDED.format(1500), None, 0), + None, ChargepointState.NO_CHARGING_ALLOWED, Counter.SWITCH_ON_TEXTS_CP.not_exceeded, None, 0), Params("Einschaltschwelle läuft", False, 1500, 121, 1500, 1652683250.0, ChargepointState.SWITCH_ON_DELAY, None, 1652683250.0, 1500), Params("Feed_in_limit, Einschaltschwelle wurde unterschritten, Timer zurücksetzen", True, 1500, -681, 15000, 1652683250.0, ChargepointState.SWITCH_ON_DELAY, - Counter.SWITCH_ON_FALLEN_BELOW.format(1500), None, 0), + Counter.SWITCH_ON_TEXTS_CP.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_TEXTS_CP.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), + 15000, None, ChargepointState.NO_CHARGING_ALLOWED, + Counter.SWITCH_ON_TEXTS_CP.not_exceeded.format(1500), None, 0), Params("Feed_in_limit, Einschaltschwelle läuft", True, 1500, 15001, 15000, 1652683250.0, ChargepointState.SWITCH_ON_DELAY, None, 1652683250.0, 1500), ] @@ -141,7 +143,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/data.py b/packages/control/data.py index 298ced12f5..b5121a65cc 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 @@ -19,7 +21,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 @@ -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 @@ -263,6 +287,8 @@ def print_all(self): log.info(f"cp_all_data\n{self._cp_all_data.data}") self._print_dictionaries(self._cp_data) self._print_dictionaries(self._cp_template_data) + log.info(f"consumer_all_data\n{self._consumer_all_data.data}") + self._print_dictionaries(self._consumer_data) self._print_dictionaries(self._counter_data) log.info(f"counter_all_data\n{self._counter_all_data.data}") self._print_dictionaries(self._ev_charge_template_data) @@ -445,6 +471,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 +491,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/ev/charge_template.py b/packages/control/ev/charge_template.py index 20f7ba88bb..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 @@ -110,7 +109,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/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..088b79a713 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"}) @@ -80,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/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/control/loadmanagement.py b/packages/control/loadmanagement.py index ae12e06f3c..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,16 +18,16 @@ class Loadmanagement: def get_available_currents(self, missing_currents: List[float], counter: Counter, - cp: Chargepoint, + 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 @@ -35,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, feed_in) + 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(): @@ -48,21 +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, + 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, feed_in) + 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(): @@ -96,8 +99,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 +107,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): @@ -141,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 @@ -155,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/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/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..8269a9385b 100644 --- a/packages/control/process.py +++ b/packages/control/process.py @@ -8,7 +8,11 @@ from control.chargelog import chargelog from control.chargepoint import chargepoint from control import data -from control.chargepoint.chargepoint_state import ChargepointState +from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState +from control.consumer.consumer import Consumer +from control.consumer.usage import ConsumerUsage +from helpermodules import timecheck +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 +46,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 +75,18 @@ 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: + 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 +125,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 +165,28 @@ 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: + if consumer.data.set.switch_interval_elapsed is False: + log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") + consumer.data.set.current = consumer.data.set.current_prev + else: + consumer.data.set.current = round(consumer.data.set.current, 2) + if consumer.data.set.current != 0 and consumer.data.control_parameter.state not in CHARGING_STATES: + consumer.data.control_parameter.state = ChargepointState.CHARGING_ALLOWED + 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.set.current != consumer.data.set.current_prev: + consumer.data.set.timestamp_last_current_set = timecheck.create_timestamp() + 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..65bec56e84 100644 --- a/packages/helpermodules/abstract_plans.py +++ b/packages/helpermodules/abstract_plans.py @@ -106,3 +106,19 @@ 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 Zeit-Plan" + + +@dataclass +class ScheduledPlanConsumer(PlanBase): + frequency: FrequencyDate = field(default_factory=frequency_date_factory) + id: Optional[int] = None + name: str = "neuer Ziel-Plan" + time: str = "07:00" + duration: int = 3600 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..a0931c64c6 100644 --- a/packages/helpermodules/changed_values_handler.py +++ b/packages/helpermodules/changed_values_handler.py @@ -81,6 +81,8 @@ def pub_changed_values(self): self._update_value("openWB/set/bat/", self.prev_data.bat_all_data.data, data.data.bat_all_data.data) self._update_value("openWB/set/chargepoint/", self.prev_data.cp_all_data.data.get, data.data.cp_all_data.data.get) + self._update_value("openWB/set/consumer/", self.prev_data.consumer_all_data.data, + data.data.consumer_all_data.data) self._update_value("openWB/set/counter/", self.prev_data.counter_all_data.data, data.data.counter_all_data.data) self._update_value("openWB/set/optional/", self.prev_data.optional_data.data, @@ -94,6 +96,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 2b429f135e..f024910af9 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -17,10 +17,15 @@ from control.chargepoint import chargepoint from control.chargepoint.chargepoint_template import get_chargepoint_template_default +from control.consumer.consumer_data import Usage +from control.consumer.consumer_data import Get as ConsumerGet, Set as ConsumerSet +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, + ScheduledPlanConsumer, 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 @@ -39,7 +44,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, 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 +64,9 @@ 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), + ("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)], @@ -86,7 +93,9 @@ 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", []), + "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() @@ -96,7 +105,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 +208,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,6 +766,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) + SubData.counter_all_data.add_loadmanagement_prio_item(ComponentType.VEHICLE, new_id) + Pub().pub("openWB/set/counter/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: @@ -790,6 +784,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"]) + SubData.counter_all_data.remove_loadmanagement_prio_item(payload["data"]["id"]) + Pub().pub("openWB/set/counter/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) @@ -1148,6 +1145,154 @@ 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())) + for (k, v) in dataclass_utils.asdict(ConsumerGet()).items(): + Pub().pub(f"openWB/set/consumer/{new_id}/get/"+k, v) + for (k, v) in dataclass_utils.asdict(ConsumerSet()).items(): + Pub().pub(f"openWB/set/consumer/{new_id}/set/"+k, v) + Pub().pub(f"openWB/set/consumer/{new_id}/extra_meter", None) + Pub().pub(f"openWB/set/consumer/{new_id}/usage", dataclass_utils.asdict(Usage())) + 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'Verbraucher mit ID \'{payload["data"]["consumer_id"]}\' gelöscht.', MessageType.SUCCESS) + + 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 + # 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 = ScheduledPlanConsumer() + 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)) + pub_user_message( + payload, connection_id, + f'Neuer Zielladen-Plan mit ID \'{new_id}\' zu Verbraucher ' + f'\'{payload["data"]["consumer_id"]}\' hinzugefügt.', + MessageType.SUCCESS) + + def removeConsumerSchedulePlan(self, connection_id: str, payload: dict) -> None: + """ löscht einen Zielladen-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_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 + 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.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'Zeitladen-Plan mit ID \'{payload["data"]["plan"]}\' zu Verbraucher ' + f'\'{payload["data"]["consumer_id"]}\' gelöscht.'), + MessageType.SUCCESS) + class ErrorHandlingContext: def __init__(self, payload: dict, connection_id: str): diff --git a/packages/helpermodules/command_test_data.py b/packages/helpermodules/command_test_data.py index 048def3eb1..b0cd812325 100644 --- a/packages/helpermodules/command_test_data.py +++ b/packages/helpermodules/command_test_data.py @@ -93,21 +93,48 @@ def regular_daily_log_entry_processed_legacy_converted(): 'power_exported': 0, 'power_imported': 0.448}}, 'prices': {'bat': 0.0002, 'grid': 0.0003, 'pv': 0.00015}, - 'pv': {'all': {'energy_exported': 0.809, + 'pv': {'all': {'energy_exported': 1.009, 'energy_imported': 0, 'exported': 35827, 'fault_state': 0, - 'power_average': -9.74, - 'power_exported': 9.74, + 'power_average': -12.148, + 'power_exported': 12.148, 'power_imported': 0}, - 'pv1': {'energy_exported': 0.809, + 'pv1': {'energy_exported': 1.009, 'energy_imported': 0, 'exported': 35827, 'fault_state': 0, - 'power_average': -9.74, - 'power_exported': 9.74, + 'power_average': -12.148, + 'power_exported': 12.148, 'power_imported': 0}}, 'sh': {}, + 'consumer': {'all': {'energy_exported': 0, + 'energy_imported': 0.2, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 0.2, + 'exported': 0, + 'fault_state': 0, + 'imported': 1000, + 'power_average': 2.408, + 'power_exported': 0, + 'power_imported': 2.408, + }, + 'consumer6': {'energy_exported': 0, + 'energy_imported': 0.2, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 0.2, + 'exported': 0, + 'fault_state': 0, + 'imported': 1000, + 'power_average': 2.408, + 'power_exported': 0, + 'power_imported': 2.408, + }, + }, 'timestamp': 1750767902}], 'message': '', 'names': {'bat2': 'MQTT-Speicher', @@ -152,6 +179,21 @@ def regular_daily_log_entry_processed_legacy_converted(): 'energy_imported_cp': 0, 'energy_imported_grid': 0, 'energy_imported_pv': 37.177}}, - 'pv': {'all': {'energy_exported': 809}, - 'pv1': {'energy_exported': 809}}, - 'sh': {}}} + 'pv': {'all': {'energy_exported': 1009}, + 'pv1': {'energy_exported': 1009}}, + 'sh': {}, + 'consumer': {'all': {'energy_exported': 0, + 'energy_imported': 200, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 200, + }, + 'consumer6': {'energy_exported': 0, + 'energy_imported': 200, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 200, + }, + }}} diff --git a/packages/helpermodules/constants.py b/packages/helpermodules/constants.py index e493371fd5..c364a8a948 100644 --- a/packages/helpermodules/constants.py +++ b/packages/helpermodules/constants.py @@ -9,6 +9,7 @@ class DEFAULT_COLORS(Enum): CHARGEPOINT = "#007bff" + CONSUMER = "#9C65FF" VEHICLE = "#17a2b8" INVERTER = "#28a745" COUNTER = "#dc3545" 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/graph.py b/packages/helpermodules/graph.py index 7808bda897..770a1e2044 100644 --- a/packages/helpermodules/graph.py +++ b/packages/helpermodules/graph.py @@ -48,11 +48,15 @@ def _convert_to_kW(value): return round(value/1000, 3) data_line.update({f"counter{counter.num}-power": _convert_to_kW(counter.data.get.power)}) data_line.update({"house-power": _convert_to_kW(data.data.counter_all_data.data.set.home_consumption)}) data_line.update({"charging-all": _convert_to_kW(data.data.cp_all_data.data.get.power)}) + data_line.update({"consumer-all": _convert_to_kW(data.data.consumer_all_data.data.get.power)}) if data.data.pv_all_data.data.config.configured: data_line.update({"pv-all": _convert_to_kW(data.data.pv_all_data.data.get.power)*-1}) for cp in data.data.cp_data.values(): if cp.data.get.fault_state < FaultStateLevel.ERROR: data_line.update({f"cp{cp.num}-power": _convert_to_kW(cp.data.get.power)}) + for consumer in data.data.consumer_data.values(): + if consumer.data.get.fault_state < FaultStateLevel.ERROR: + data_line.update({f"consumer{consumer.num}-power": _convert_to_kW(consumer.data.get.power)}) for ev in data.data.ev_data.values(): if ev.soc_module: data_line.update({f"ev{ev.num}-soc": ev.data.get.soc}) diff --git a/packages/helpermodules/measurement_logging/conftest.py b/packages/helpermodules/measurement_logging/conftest.py index 9cb2b90153..43c64d4f3d 100644 --- a/packages/helpermodules/measurement_logging/conftest.py +++ b/packages/helpermodules/measurement_logging/conftest.py @@ -6,12 +6,18 @@ from control.chargepoint.chargepoint_all import AllChargepoints from control import bat_all, counter, pv_all, pv from control import data +from control.consumer.consumer import Consumer +from control.consumer.consumer_all import AllConsumers +from modules.consumers.generic.mqtt.config import Mqtt @pytest.fixture(autouse=True) def data_module() -> None: data.data_init(Event()) data.data.bat_data.update({"all": bat_all.BatAll(), "bat2": Bat(2)}) + data.data.consumer_all_data = AllConsumers() + data.data.consumer_data.update({"consumer6": Consumer(6)}) + data.data.consumer_data["consumer6"].data.module = Mqtt() data.data.counter_data.update({"counter0": counter.Counter(0)}) data.data.cp_all_data = AllChargepoints() data.data.cp_data.update({"cp4": chargepoint.Chargepoint( @@ -33,7 +39,9 @@ def daily_log_sample(): "pv": {"pv1": {"exported": 804}, "all": {"exported": 804}}, "bat": {"bat2": {"imported": 2.42, "exported": 1742.135, "soc": 15}, "all": {"imported": 2.42, "exported": 1742.135, "soc": 15}}, - "sh": {"sh1": {"temp0": 300, "temp1": 300, "temp2": 300, "imported": 0.1, "exported": 0}}, + "sh": {}, + "consumer": {"all": {"imported": 0.1, "exported": 0}, + "consumer6": {"imported": 0.1, "exported": 0}}, "hc": {"all": {"imported": 100}}}, {"timestamp": 1690530060, "date": "09:40", @@ -47,7 +55,9 @@ def daily_log_sample(): "pv": {"pv1": {"exported": 930}, "all": {"exported": 930}}, "bat": {"bat2": {"imported": 2.42, "exported": 2017.569, "soc": 10}, "all": {"imported": 2.42, "exported": 2017.569, "soc": 10}}, - "sh": {"sh1": {"temp0": 300, "temp1": 300, "temp2": 300, "imported": 0.2, "exported": 0}}, + "sh": {}, + "consumer": {"all": {"imported": 0.2, "exported": 0}, + "consumer6": {"imported": 0.2, "exported": 0}}, "hc": {"all": {"imported": 110}}}, {"timestamp": 1690530360, "date": "09:45", @@ -61,7 +71,9 @@ def daily_log_sample(): "pv": {"pv1": {"exported": 1055}, "all": {"exported": 1055}}, "bat": {"bat2": {"imported": 2.42, "exported": 2292.992, "soc": 4}, "all": {"imported": 2.42, "exported": 2292.992, "soc": 4}}, - "sh": {"sh1": {"temp0": 300, "temp1": 300, "temp2": 300, "imported": 0.4, "exported": 0}}, + "sh": {}, + "consumer": {"all": {"imported": 0.4, "exported": 0}, + "consumer6": {"imported": 0.4, "exported": 0}}, "hc": {"all": {"imported": 120}}} ] @@ -77,7 +89,9 @@ def daily_log_totals(): 'cp5': {'energy_exported': 0.0, 'energy_imported': 191.928}, 'cp6': {'energy_exported': 0.0, 'energy_imported': 0}}, 'pv': {'all': {'energy_exported': 251.0}, 'pv1': {'energy_exported': 251.0}}, - "sh": {"sh1": {"energy_imported": 0.3, "energy_exported": 0.0}}, + "sh": {}, + "consumer": {"all": {"energy_exported": 0.0, "energy_imported": 0.3}, + "consumer6": {"energy_imported": 0.3, "energy_exported": 0.0}}, "hc": {"all": {"energy_imported": 20.0}}} @@ -155,16 +169,21 @@ def daily_log_entry_processed(): 'power_average': -1517.057, 'power_exported': 1517.057, 'power_imported': 0}}, - 'sh': {'sh1': {'energy_exported': 0.0, - 'energy_imported': 0.1, - 'exported': 0, - 'imported': 0.1, - 'power_average': 1.204, - 'power_exported': 0, - 'power_imported': 1.204, - 'temp0': 300, - 'temp1': 300, - 'temp2': 300}}, + 'sh': {}, + 'consumer': {'all': {'energy_exported': 0.0, + 'energy_imported': 0.1, + 'exported': 0, + 'imported': 0.1, + 'power_average': 1.204, + 'power_exported': 0, + 'power_imported': 1.204}, + 'consumer6': {'energy_exported': 0.0, + 'energy_imported': 0.1, + 'exported': 0, + 'imported': 0.1, + 'power_average': 1.204, + 'power_exported': 0, + 'power_imported': 1.204}}, 'timestamp': 1690529761} @@ -205,8 +224,13 @@ def daily_log_entry_percentage(): 'pv1': {'energy_exported': 0.126, 'energy_imported': 0.0, 'fault_state': 0}}, - 'sh': {'sh1': {'energy_exported': 0.0, - 'energy_imported': 0.0}}, + 'sh': {}, + 'consumer': {'all': {'energy_exported': 0.0, + 'energy_imported': 0.0, + 'fault_state': 0}, + 'consumer6': {'energy_exported': 0.0, + 'energy_imported': 0.0, + 'fault_state': 0}}, 'timestamp': 1690529761, } diff --git a/packages/helpermodules/measurement_logging/process_log.py b/packages/helpermodules/measurement_logging/process_log.py index 24eab8feff..46cd8daa83 100644 --- a/packages/helpermodules/measurement_logging/process_log.py +++ b/packages/helpermodules/measurement_logging/process_log.py @@ -170,7 +170,7 @@ def get_default_charge_log_columns() -> Dict: def convert_legacy_units(data: dict) -> dict: for entry in data["entries"]: - for group in ("bat", "counter", "cp", "pv", "sh", "hc"): + for group in ("bat", "consumer", "counter", "cp", "pv", "sh", "hc"): if group in entry: for module in entry[group].keys(): try: @@ -201,7 +201,7 @@ def get_totals(entries: List, process_entries: bool = True) -> Dict: """ if process_entries: entries = _process_entries(entries, CalculationType.ENERGY) - totals = {"cp": {}, "counter": {}, "pv": {}, "bat": {}, "sh": {}, "hc": {}} + totals = {"consumer": {}, "cp": {}, "counter": {}, "pv": {}, "bat": {}, "sh": {}, "hc": {}} for totals_group in totals.keys(): for entry in entries: if totals_group in entry: @@ -509,6 +509,21 @@ def calc_energy_imported_by_source(entry, names, message_key_filter: Optional[st if message_key_filter is None or message_key_filter == cp_key: message += ERROR_STATE_MESSAGE.format(f"Ladepunkt {names.get(cp_key, cp_key)}") + consumer_section = entry.get("consumer") + if isinstance(consumer_section, dict): + for consumer_key, consumer_data in consumer_section.items(): + if isinstance(consumer_data, dict): + if consumer_data.get("fault_state", 0) != 2 and "energy_imported" in consumer_data: + for source in ("grid", "pv", "bat", "cp"): + consumer_data[f"energy_imported_{source}"] = decimal_multiply( + consumer_data["energy_imported"], energy_source[source]) + else: + for source in ("grid", "pv", "bat", "cp"): + consumer_data[f"energy_imported_{source}"] = 0 + if message_key_filter is None or message_key_filter == consumer_key: + message += ERROR_STATE_MESSAGE.format( + f"Verbraucher {names.get(consumer_key, consumer_key)}") + counter_section = entry.get("counter") if isinstance(counter_section, dict): for counter_key, counter_data in counter_section.items(): @@ -530,7 +545,7 @@ def calc_energy_imported_by_source(entry, names, message_key_filter: Optional[st def analyse_percentage_totals(entries, totals): - for section in ("hc", "cp"): + for section in ("consumer", "cp", "hc"): if "all" not in totals[section].keys(): totals[section]["all"] = {} for source in ("grid", "pv", "bat", "cp"): @@ -549,6 +564,14 @@ def analyse_percentage_totals(entries, totals): add_value = entry["cp"][key][f"energy_imported_{source}"] totals["cp"][key][f"energy_imported_{source}"] = decimal_add( current_value, add_value) + for key in entry["consumer"].keys(): + if f"energy_imported_{source}" in entry["consumer"][key].keys(): + if totals["consumer"][key].get(f"energy_imported_{source}") is None: + totals["consumer"][key].update({f"energy_imported_{source}": 0}) + current_value = totals["consumer"][key][f"energy_imported_{source}"] + add_value = entry["consumer"][key][f"energy_imported_{source}"] + totals["consumer"][key][f"energy_imported_{source}"] = decimal_add( + current_value, add_value) for key, counter in entry["counter"].items(): if counter["grid"] is False: if totals["counter"][key].get(f"energy_imported_{source}") is None: @@ -565,7 +588,7 @@ def _process_entries(entries: List, calculation: CalculationType): if len(entries) == 1: # Wenn es nur einen Eintrag gibt, kann keine Differenz berechnet werden und die Werte sind 0. entry = entries[0] - for type in ("bat", "counter", "cp", "pv", "sh", "hc"): + for type in ("bat", "consumer", "counter", "cp", "pv", "sh", "hc"): if type in entry: for module in entry[type].keys(): if calculation in [CalculationType.POWER, CalculationType.ALL]: @@ -590,7 +613,7 @@ def _process_entries(entries: List, calculation: CalculationType): def process_entry(entry: dict, next_entry: dict, calculation: CalculationType): time_diff = next_entry["timestamp"] - entry["timestamp"] - for type in ("bat", "counter", "cp", "pv", "sh", "hc"): + for type in ("bat", "consumer", "counter", "cp", "pv", "sh", "hc"): if type in entry: for module in entry[type].keys(): try: diff --git a/packages/helpermodules/measurement_logging/process_log_testdata.py b/packages/helpermodules/measurement_logging/process_log_testdata.py index b31cf19fa2..94023ef518 100644 --- a/packages/helpermodules/measurement_logging/process_log_testdata.py +++ b/packages/helpermodules/measurement_logging/process_log_testdata.py @@ -29,6 +29,8 @@ def counter_jumps_forward(): 'prices': {'bat': 0.0002, 'grid': 0.0003, 'pv': 0.00015}, 'pv': {'all': {'exported': 35827, 'fault_state': 0}, 'pv1': {'exported': 35827, 'fault_state': 0}}, + 'consumer': {'all': {'exported': 0, 'imported': 1000, 'fault_state': 0}, + 'consumer6': {'exported': 0, 'imported': 1000, 'fault_state': 0}}, 'sh': {}, 'timestamp': 1750767902}, {'bat': {'all': {'exported': 3195.13, @@ -55,8 +57,10 @@ def counter_jumps_forward(): 'ev': {'ev0': {'soc': None, 'fault_state': 0}}, 'hc': {'all': {'imported': 2361.178611303703, 'fault_state': 0}}, 'prices': {'bat': 0.0002, 'grid': 0.0003, 'pv': 0.00015}, - 'pv': {'all': {'exported': 36636, 'fault_state': 0}, - 'pv1': {'exported': 36636, 'fault_state': 0}}, + 'pv': {'all': {'exported': 36836, 'fault_state': 0}, + 'pv1': {'exported': 36836, 'fault_state': 0}}, + 'consumer': {'all': {'exported': 0, 'imported': 1200, 'fault_state': 0}, + 'consumer6': {'exported': 0, 'imported': 1200, 'fault_state': 0}}, 'sh': {}, 'timestamp': 1750768201}], 'names': {'bat2': 'MQTT-Speicher', @@ -174,20 +178,44 @@ def counter_jumps_forward_processed(): 'power_exported': 0, 'power_imported': 447.616}}, 'prices': {'bat': 0.0002, 'grid': 0.0003, 'pv': 0.00015}, - 'pv': {'all': {'energy_exported': 809, + 'pv': {'all': {'energy_exported': 1009, 'energy_imported': 0, 'exported': 35827, 'fault_state': 0, - 'power_average': -9740.468, - 'power_exported': 9740.468, + 'power_average': -12148.495, + 'power_exported': 12148.495, 'power_imported': 0}, - 'pv1': {'energy_exported': 809, + 'pv1': {'energy_exported': 1009, 'energy_imported': 0, 'exported': 35827, 'fault_state': 0, - 'power_average': -9740.468, - 'power_exported': 9740.468, + 'power_average': -12148.495, + 'power_exported': 12148.495, 'power_imported': 0}}, + 'consumer': {'all': {'energy_exported': 0, + 'energy_imported': 200, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 200, + 'exported': 0, + 'fault_state': 0, + 'imported': 1000, + 'power_average': 2408.027, + 'power_exported': 0, + 'power_imported': 2408.027}, + 'consumer6': {'energy_exported': 0, + 'energy_imported': 200, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 200, + 'exported': 0, + 'fault_state': 0, + 'imported': 1000, + 'power_average': 2408.027, + 'power_exported': 0, + 'power_imported': 2408.027}}, 'sh': {}, 'timestamp': 1750767902}], 'message': '', @@ -240,9 +268,21 @@ def counter_jumps_forward_processed(): 'energy_imported_cp': 0, 'energy_imported_grid': 0, 'energy_imported_pv': 37.177}}, - 'pv': {'all': {'energy_exported': 809}, - 'pv1': {'energy_exported': 809}}, - 'sh': {}}} + 'pv': {'all': {'energy_exported': 1009}, + 'pv1': {'energy_exported': 1009}}, + 'sh': {}, + 'consumer': {'all': {'energy_exported': 0, + 'energy_imported': 200, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 200}, + 'consumer6': {'energy_exported': 0, + 'energy_imported': 200, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 200}}}} @pytest.fixture() @@ -270,6 +310,8 @@ def regular_daily_log_entry(): 'pv': {'all': {'exported': 35827, 'fault_state': 0}, 'pv1': {'exported': 35827, 'fault_state': 0}}, 'sh': {}, + 'consumer': {'all': {'exported': 0, 'imported': 1000, 'fault_state': 0}, + 'consumer6': {'exported': 0, 'imported': 1000, 'fault_state': 0}}, 'timestamp': 1750767902}, {'bat': {'all': {'exported': 3195.13, 'imported': 629.37, @@ -291,9 +333,12 @@ def regular_daily_log_entry(): 'ev': {'ev0': {'soc': None, 'fault_state': 0}}, 'hc': {'all': {'imported': 2361.178611303703, 'fault_state': 0}}, 'prices': {'bat': 0.0002, 'grid': 0.0003, 'pv': 0.00015}, - 'pv': {'all': {'exported': 36636, 'fault_state': 0}, - 'pv1': {'exported': 36636, 'fault_state': 0}}, + 'pv': {'all': {'exported': 36836, 'fault_state': 0}, + 'pv1': {'exported': 36836, 'fault_state': 0}}, 'sh': {}, + + 'consumer': {'all': {'exported': 0, 'imported': 1200, 'fault_state': 0}, + 'consumer6': {'exported': 0, 'imported': 1200, 'fault_state': 0}}, 'timestamp': 1750768201}], 'names': {'bat2': 'MQTT-Speicher', 'counter0': 'MQTT-Zähler', @@ -397,21 +442,45 @@ def regular_daily_log_entry_processed(): 'power_exported': 0, 'power_imported': 447.616}}, 'prices': {'bat': 0.0002, 'grid': 0.0003, 'pv': 0.00015}, - 'pv': {'all': {'energy_exported': 809, + 'pv': {'all': {'energy_exported': 1009, 'energy_imported': 0, 'exported': 35827, 'fault_state': 0, - 'power_average': -9740.468, - 'power_exported': 9740.468, + 'power_average': -12148.495, + 'power_exported': 12148.495, 'power_imported': 0}, - 'pv1': {'energy_exported': 809, + 'pv1': {'energy_exported': 1009, 'energy_imported': 0, 'exported': 35827, 'fault_state': 0, - 'power_average': -9740.468, - 'power_exported': 9740.468, + 'power_average': -12148.495, + 'power_exported': 12148.495, 'power_imported': 0}}, 'sh': {}, + 'consumer': {'all': {'energy_exported': 0, + 'energy_imported': 200, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 200, + 'exported': 0, + 'fault_state': 0, + 'imported': 1000, + 'power_average': 2408.027, + 'power_exported': 0, + 'power_imported': 2408.027}, + 'consumer6': {'energy_exported': 0, + 'energy_imported': 200, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 200, + 'exported': 0, + 'fault_state': 0, + 'imported': 1000, + 'power_average': 2408.027, + 'power_exported': 0, + 'power_imported': 2408.027}}, 'timestamp': 1750767902}], 'message': '', 'names': {'bat2': 'MQTT-Speicher', @@ -456,6 +525,18 @@ def regular_daily_log_entry_processed(): 'energy_imported_cp': 0, 'energy_imported_grid': 0, 'energy_imported_pv': 37.177}}, - 'pv': {'all': {'energy_exported': 809}, - 'pv1': {'energy_exported': 809}}, - 'sh': {}}} + 'pv': {'all': {'energy_exported': 1009}, + 'pv1': {'energy_exported': 1009}}, + 'sh': {}, + 'consumer': {'all': {'energy_exported': 0, + 'energy_imported': 200, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 200}, + 'consumer6': {'energy_exported': 0, + 'energy_imported': 200, + 'energy_imported_bat': 0, + 'energy_imported_cp': 0, + 'energy_imported_grid': 0, + 'energy_imported_pv': 200}}}} diff --git a/packages/helpermodules/measurement_logging/process_log_unit_test.py b/packages/helpermodules/measurement_logging/process_log_unit_test.py index 30b56ec065..acf78cf52f 100644 --- a/packages/helpermodules/measurement_logging/process_log_unit_test.py +++ b/packages/helpermodules/measurement_logging/process_log_unit_test.py @@ -171,6 +171,7 @@ def test_analyse_percentage_totals(): totals = { "hc": {"all": {"energy_imported": 3500}}, "cp": { + "all": {"energy_imported": 58933}, "cp1": {"energy_imported": 18500}, "cp2": {"energy_imported": 29000}, "cp3": {"energy_imported": 11433} @@ -179,6 +180,11 @@ def test_analyse_percentage_totals(): "counter0": {"grid": True, "energy_imported": 45892}, "counter1": {"grid": False, "energy_imported": 10000}, "counter2": {"grid": False, "energy_imported": 8735} + }, + "consumer": { + "all": {"energy_imported": 10928}, + "consumer6": {"energy_imported": 4248}, + "consumer7": {"energy_imported": 7182} } } @@ -192,6 +198,11 @@ def test_analyse_percentage_totals(): assert result["hc"]["all"]["energy_imported_bat"] == 912 assert result["hc"]["all"]["energy_imported_cp"] == 273 + assert result["cp"]["all"]["energy_imported_grid"] == 52376 + assert result["cp"]["all"]["energy_imported_pv"] == 20302 + assert result["cp"]["all"]["energy_imported_bat"] == 8390 + assert result["cp"]["all"]["energy_imported_cp"] == 1804 + # Check cp totals (in Wh) assert result["cp"]["cp1"]["energy_imported_grid"] == 22222 assert result["cp"]["cp1"]["energy_imported_pv"] == 8354 @@ -221,6 +232,21 @@ def test_analyse_percentage_totals(): assert result["counter"]["counter2"]["energy_imported_bat"] == 1634 assert result["counter"]["counter2"]["energy_imported_cp"] == 824 + assert result["consumer"]["all"]["energy_imported_grid"] == 6858 + assert result["consumer"]["all"]["energy_imported_pv"] == 2468 + assert result["consumer"]["all"]["energy_imported_bat"] == 1134 + assert result["consumer"]["all"]["energy_imported_cp"] == 468 + + assert result["consumer"]["consumer6"]["energy_imported_grid"] == 2468 + assert result["consumer"]["consumer6"]["energy_imported_pv"] == 1134 + assert result["consumer"]["consumer6"]["energy_imported_bat"] == 468 + assert result["consumer"]["consumer6"]["energy_imported_cp"] == 178 + + assert result["consumer"]["consumer7"]["energy_imported_grid"] == 4890 + assert result["consumer"]["consumer7"]["energy_imported_pv"] == 1356 + assert result["consumer"]["consumer7"]["energy_imported_bat"] == 690 + assert result["consumer"]["consumer7"]["energy_imported_cp"] == 246 + def test_calc_energy_imported_by_source_message_filtering(): """Test message filtering when component is in fault state and name is missing.""" diff --git a/packages/helpermodules/measurement_logging/test_data_analyse_percentage_totals.json b/packages/helpermodules/measurement_logging/test_data_analyse_percentage_totals.json index 1e57d2b268..83e83a3e3f 100644 --- a/packages/helpermodules/measurement_logging/test_data_analyse_percentage_totals.json +++ b/packages/helpermodules/measurement_logging/test_data_analyse_percentage_totals.json @@ -9,6 +9,12 @@ } }, "cp": { + "all": { + "energy_imported_grid": 31067, + "energy_imported_pv": 11756, + "energy_imported_bat": 4831, + "energy_imported_cp": 548 + }, "cp1": { "energy_imported_grid": 12346, "energy_imported_pv": 4632, @@ -34,6 +40,26 @@ "energy_imported_bat": 923, "energy_imported_cp": 446 } + }, + "consumer": { + "all": { + "energy_imported_grid": 3579, + "energy_imported_pv": 1234, + "energy_imported_bat": 567, + "energy_imported_cp": 234 + }, + "consumer6": { + "energy_imported_grid": 1234, + "energy_imported_pv": 567, + "energy_imported_bat": 234, + "energy_imported_cp": 89 + }, + "consumer7": { + "energy_imported_grid": 2345, + "energy_imported_pv": 678, + "energy_imported_bat": 345, + "energy_imported_cp": 123 + } } }, { @@ -46,6 +72,12 @@ } }, "cp": { + "all": { + "energy_imported_grid": 21309, + "energy_imported_pv": 8546, + "energy_imported_bat": 3559, + "energy_imported_cp": 1256 + }, "cp1": { "energy_imported_grid": 9876, "energy_imported_pv": 3722, @@ -74,6 +106,26 @@ "energy_imported_bat": 1634, "energy_imported_cp": 824 } + }, + "consumer": { + "all": { + "energy_imported_grid": 3279, + "energy_imported_pv": 1234, + "energy_imported_bat": 567, + "energy_imported_cp": 234 + }, + "consumer6": { + "energy_imported_grid": 1234, + "energy_imported_pv": 567, + "energy_imported_bat": 234, + "energy_imported_cp": 89 + }, + "consumer7": { + "energy_imported_grid": 2545, + "energy_imported_pv": 678, + "energy_imported_bat": 345, + "energy_imported_cp": 123 + } } } ] \ No newline at end of file diff --git a/packages/helpermodules/measurement_logging/update_yields.py b/packages/helpermodules/measurement_logging/update_yields.py index 23b1a8a251..9f8f3a3139 100644 --- a/packages/helpermodules/measurement_logging/update_yields.py +++ b/packages/helpermodules/measurement_logging/update_yields.py @@ -15,7 +15,7 @@ def update_daily_yields(entries): """ try: totals = get_totals(entries) - [update_module_yields(type, totals) for type in ("bat", "counter", "cp", "pv")] + [update_module_yields(type, totals) for type in ("bat", "consumer", "counter", "cp", "pv")] data.data.counter_all_data.data.set.daily_yield_home_consumption = totals["hc"]["all"]["energy_imported"] except Exception: log.exception("Fehler beim Veröffentlichen der Tageserträge.") diff --git a/packages/helpermodules/measurement_logging/write_log.py b/packages/helpermodules/measurement_logging/write_log.py index fd8d51aa4b..8d631b80db 100644 --- a/packages/helpermodules/measurement_logging/write_log.py +++ b/packages/helpermodules/measurement_logging/write_log.py @@ -7,7 +7,7 @@ import re import string from paho.mqtt.client import Client as MqttClient, MQTTMessage -from typing import Dict, Optional +from typing import Dict, Optional, Union from control import data from helpermodules.broker import BrokerClient @@ -78,6 +78,17 @@ # } # ... (dynamisch, je nach konfigurierter Anzahl) # } +# "consumer": { +# "all": { +# "imported": Wh, +# "exported": Wh, +# } +# "consumer0": { +# "imported": Wh, +# "exported": Wh, +# } +# ... (dynamisch, je nach konfigurierter Anzahl) +# } # "sh": { # "sh1": { # "exported": Wh, @@ -292,6 +303,22 @@ def create_entry(log_type: LogType, sh_log_data: LegacySmartHomeLogData, previou except Exception: log.exception("Fehler im Werte-Logging-Modul für Speicher "+str(bat)) + try: + consumer_dict: Dict[str, Dict[str, Union[float, int]]] = {"all": { + "imported": data.data.consumer_all_data.data.get.imported, + "exported": data.data.consumer_all_data.data.get.exported, + "fault_state": data.data.consumer_all_data.data.get.fault_state}} + except Exception: + log.exception("Fehler im Werte-Logging-Modul für Verbraucher-Daten") + consumer_dict = {} + for consumer in data.data.consumer_data: + try: + consumer_dict.update({consumer: {"imported": data.data.consumer_data[consumer].data.get.imported, + "exported": data.data.consumer_data[consumer].data.get.exported, + "fault_state": data.data.consumer_data[consumer].data.get.fault_state}}) + except Exception: + log.exception("Fehler im Werte-Logging-Modul für Verbraucher "+str(consumer)) + try: hc_dict = {"all": { "imported": data.data.counter_all_data.data.set.imported_home_consumption, @@ -308,6 +335,7 @@ def create_entry(log_type: LogType, sh_log_data: LegacySmartHomeLogData, previou "counter": counter_dict, "pv": pv_dict, "bat": bat_dict, + "consumer": consumer_dict, "sh": sh_log_data.sh_dict, "hc": hc_dict } @@ -352,7 +380,7 @@ def get_names(elements: Dict, sh_names: Dict, valid_names: Optional[Dict] = None """ names = sh_names for group in elements.items(): - if group[0] not in ("bat", "counter", "cp", "pv", "ev", "sh"): + if group[0] not in ("bat", "consumer", "counter", "cp", "pv", "ev", "sh"): continue for entry in group[1]: # valid_names wird aus update_config übergeben, da dort noch kein Zugriff auf data möglich ist @@ -368,6 +396,8 @@ def get_names(elements: Dict, sh_names: Dict, valid_names: Optional[Dict] = None try: if "ev" in entry: names.update({entry: data.data.ev_data[entry].data.name}) + elif "consumer" in entry: + names.update({entry: data.data.consumer_data[entry].data.module.name}) elif "cp" in entry: names.update({entry: data.data.cp_data[entry].data.config.name}) elif "all" != entry: @@ -388,13 +418,15 @@ def get_colors(elements: Dict) -> Dict: """ colors = {} for group in elements.items(): - if group[0] not in ("ev", "cp", "counter", "pv", "bat"): + if group[0] not in ("ev", "cp", "counter", "consumer", "pv", "bat"): continue for entry in group[1]: if "all" != entry: try: if "ev" in entry: colors.update({entry: data.data.ev_data[entry].data.color}) + elif "consumer" in entry: + colors.update({entry: data.data.consumer_data[entry].data.module.color}) elif "cp" in entry: colors.update({entry: data.data.cp_data[entry].data.config.color}) else: diff --git a/packages/helpermodules/measurement_logging/write_log_test.py b/packages/helpermodules/measurement_logging/write_log_test.py index a491716c85..11421bc76b 100644 --- a/packages/helpermodules/measurement_logging/write_log_test.py +++ b/packages/helpermodules/measurement_logging/write_log_test.py @@ -13,6 +13,7 @@ def test_get_names(daily_log_totals, monkeypatch): # evaluation assert names == {'bat2': "Speicher", + "consumer6": "MQTT-Verbraucher", 'counter0': "Zähler", 'cp3': "cp3", 'cp4': "neuer Ladepunkt", 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 7973936181..d9feab02a4 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) @@ -763,16 +772,20 @@ 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): + "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)]) @@ -845,6 +858,13 @@ 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: + 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: @@ -931,7 +951,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) @@ -1214,5 +1235,55 @@ 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]+/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): + 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/exported$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/imported$", msg.topic) is not None or + re.search("consumer/[0-9]+/get/daily_exported$", 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 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: + 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 4c59046940..edb22907f1 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 @@ -9,12 +10,16 @@ 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.consumer.consumer_data import Usage +from control.counter_all import counter_all +from control.consumer.consumer import Consumer +from control.consumer.consumer_all import AllConsumers from control.ev.charge_template import ChargeTemplate, ChargeTemplateData from control.ev import ev from control.ev.ev_template import EvTemplate, EvTemplateData @@ -74,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, @@ -139,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 @@ -199,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)) @@ -246,6 +256,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: @@ -1213,3 +1225,45 @@ 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]+/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: + var[f"consumer{index}"].data.usage = dataclass_from_dict(Usage, decode_payload(msg.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..c0a86edea6 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 +from helpermodules.abstract_plans import (AutolockPlan, ScheduledChargingPlan, ScheduledPlanConsumer, + TimeChargingPlan, TimeChargingPlanConsumer) log = logging.getLogger(__name__) @@ -44,7 +45,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]: @@ -113,8 +114,8 @@ def is_timeframe_valid(now: datetime.datetime, begin: datetime.datetime, end: da return state -def check_end_time(plan: ScheduledChargingPlan, - buffer: Optional[float]) -> Optional[float]: +def check_end_time(plan: Union[ScheduledPlanConsumer, ScheduledChargingPlan, TimeChargingPlanConsumer], + buffer: Optional[float]) -> float: """ gibt die verbleibende Zeit in Sekunden zurück. Return @@ -124,7 +125,6 @@ def check_end_time(plan: ScheduledChargingPlan, """ 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 6fe83c3caf..aafb549573 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -1,4 +1,4 @@ -from concurrent.futures import ProcessPoolExecutor +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor import copy from dataclasses import asdict import datetime @@ -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 = 138 valid_topic = [ "^openWB/bat/config/bat_control_activated$", @@ -173,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$", @@ -184,9 +187,34 @@ 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/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$", + "^openWB/consumer/[0-9]+/set/wait_for_start_state$", + "^openWB/counter/config/consider_less_charging$", "^openWB/counter/config/home_consumption_source_id$", "^openWB/counter/get/hierarchy$", + "^openWB/counter/get/loadmanagement_prios$", "^openWB/counter/set/disengageable_smarthome_power$", "^openWB/counter/set/imported_home_consumption$", "^openWB/counter/set/invalid_home_consumption$", @@ -229,7 +257,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$", @@ -243,6 +272,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$", @@ -315,6 +347,14 @@ 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/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$", @@ -500,6 +540,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$", @@ -586,8 +627,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}), @@ -605,6 +647,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), @@ -614,12 +657,15 @@ 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), ("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"), @@ -3448,3 +3494,82 @@ 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) + + 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) + + def upgrade_datastore_138(self) -> None: + def add_consumer_dict(file: str) -> None: + try: + with open(file, "r+") as jsonFile: + content_raw = jsonFile.read() + try: + content = json.loads(content_raw) + except json.JSONDecodeError: + log.warning(f"Skipping invalid log file (JSON decode failed): {file}") + return + + entries = content.get("entries") + if not isinstance(entries, list): + log.warning(f"Skipping log file without valid 'entries' list: {file}") + return + + for entry in entries: + if isinstance(entry, dict) and "consumer" not in entry: + entry.update({"consumer": {}}) + + jsonFile.seek(0) + jsonFile.write(json.dumps(content)) + jsonFile.truncate() + log.debug(f"Updated log file with consumer dict: {file}") + except OSError: + log.warning(f"Skipping log file due to I/O error: {file}") + except Exception: + log.exception(f"Skipping log file due to unexpected error: {file}") + + files = glob.glob(str(self.base_path / "data" / "daily_log") + "/*") + files.extend(glob.glob(str(self.base_path / "data" / "monthly_log") + "/*")) + files.sort() + with ThreadPoolExecutor() as executor: + executor.map(add_consumer_dict, files) + self._append_datastore_version(138) diff --git a/packages/main.py b/packages/main.py index 3385139ba9..9243b0bbcc 100755 --- a/packages/main.py +++ b/packages/main.py @@ -234,6 +234,9 @@ 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.midnight_handler() except Exception: log.exception("Fehler im Main-Modul") 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/configurable_consumer.py b/packages/modules/common/configurable_consumer.py new file mode 100644 index 0000000000..4c3ae2978e --- /dev/null +++ b/packages/modules/common/configurable_consumer.py @@ -0,0 +1,96 @@ +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 consumer_state.exported is not None else 0) + 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..c5563b69fb --- /dev/null +++ b/packages/modules/common/consumer_setup.py @@ -0,0 +1,29 @@ +from typing import Generic, Optional, TypeVar, Tuple + +from control.consumer.usage import ConsumerUsage +from helpermodules.constants import DEFAULT_COLORS + + +T = TypeVar("T") + + +class ConsumerSetup(Generic[T]): + def __init__(self, + name: str, + type: str, + id: int, + vendor: str, + configuration: T, + usage: Tuple[ConsumerUsage, ...], + color: Optional[str] = None) -> 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 + if color: + self.color = color + else: + self.color = DEFAULT_COLORS.CONSUMER.value 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/_consumer.py b/packages/modules/common/store/_consumer.py new file mode 100644 index 0000000000..e845b76acb --- /dev/null +++ b/packages/modules/common/store/_consumer.py @@ -0,0 +1,63 @@ +from control import data +from modules.common.component_state import ConsumerState +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, state: ConsumerState) -> None: + self.state = state + + def update(self) -> None: + 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 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) + 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(ValueStore[ConsumerState]): + def __init__(self, delegate: LoggingValueStore[ConsumerState]) -> 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: + try: + component = get_component_obj_by_id(extra_meter_id) + component_state = component.store.delegate.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, + )) + except Exception: + raise Exception(f"Fehler beim Auslesen des Verbrauchszählers {extra_meter_id} " + f"für Verbraucher {self.delegate.delegate.num}") + self.delegate.update() + + +def get_consumer_value_store(component_num: int) -> ValueStore[ConsumerState]: + return PurgeConsumerState(LoggingValueStore(ConsumerValueStoreBroker(component_num))) 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/_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/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/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/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..1c52a288e8 --- /dev/null +++ b/packages/modules/consumers/askoma/askoheat/config.py @@ -0,0 +1,31 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or AskoheatConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/askoma/askoheat/consumer.py b/packages/modules/consumers/askoma/askoheat/consumer.py new file mode 100644 index 0000000000..71d9728a2b --- /dev/null +++ b/packages/modules/consumers/askoma/askoheat/consumer.py @@ -0,0 +1,46 @@ +#!/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: + 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: + client.write_register(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..40c6998564 --- /dev/null +++ b/packages/modules/consumers/avm/avm/config.py @@ -0,0 +1,37 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or AvmConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/avm/avm/consumer.py b/packages/modules/consumers/avm/avm/consumer.py new file mode 100644 index 0000000000..5b28d073c3 --- /dev/null +++ b/packages/modules/consumers/avm/avm/consumer.py @@ -0,0 +1,132 @@ +#!/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: + 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: + 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/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..ac9e34cd40 --- /dev/null +++ b/packages/modules/consumers/generic/dac/config.py @@ -0,0 +1,33 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE,), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or DacConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/generic/dac/consumer.py b/packages/modules/consumers/generic/dac/consumer.py new file mode 100644 index 0000000000..654f2ab1c5 --- /dev/null +++ b/packages/modules/consumers/generic/dac/consumer.py @@ -0,0 +1,53 @@ +#!/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: + 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..6fe3eef212 --- /dev/null +++ b/packages/modules/consumers/generic/http/config.py @@ -0,0 +1,49 @@ +from typing import Tuple + +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-Verbraucher", + type: str = "http", + id: int = 0, + configuration: HttpConfiguration = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.METER_ONLY, + ConsumerUsage.CONTINUOUS, + ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.SUSPENDABLE_TUNABLE), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or HttpConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/generic/http/consumer.py b/packages/modules/consumers/generic/http/consumer.py new file mode 100644 index 0000000000..080e4983ad --- /dev/null +++ b/packages/modules/consumers/generic/http/consumer.py @@ -0,0 +1,86 @@ +#!/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: + 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(): + # Authorization? + post_switch_on(session, params={"state": True}) + + def switch_off(): + # Authorization? + post_switch_off(session, params={"state": False}) + + def set_power_limit(power_limit: int): + # 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..fda50af220 --- /dev/null +++ b/packages/modules/consumers/generic/json/config.py @@ -0,0 +1,49 @@ +from typing import Tuple + +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-Verbraucher", + type: str = "json", + id: int = 0, + configuration: JsonConfiguration = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.METER_ONLY, + ConsumerUsage.CONTINUOUS, + ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.SUSPENDABLE_TUNABLE), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or JsonConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/generic/json/consumer.py b/packages/modules/consumers/generic/json/consumer.py new file mode 100644 index 0000000000..07f0e070d3 --- /dev/null +++ b/packages/modules/consumers/generic/json/consumer.py @@ -0,0 +1,99 @@ +#!/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]: + 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: + 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(): + # Authorization? + jq_switch_on(session, params={"state": True}) + + def switch_off(): + # Authorization? + jq_switch_off(session, params={"state": False}) + + def set_power_limit(power_limit: int): + # 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..b2ab560af1 --- /dev/null +++ b/packages/modules/consumers/generic/mqtt/config.py @@ -0,0 +1,28 @@ +from typing import Tuple + +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-Verbraucher", + type: str = "mqtt", + id: int = 0, + configuration: MqttConfiguration = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.METER_ONLY, + ConsumerUsage.CONTINUOUS, + ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.SUSPENDABLE_TUNABLE), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or MqttConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/generic/mqtt/consumer.py b/packages/modules/consumers/generic/mqtt/consumer.py new file mode 100644 index 0000000000..e81794dba3 --- /dev/null +++ b/packages/modules/consumers/generic/mqtt/consumer.py @@ -0,0 +1,60 @@ +#!/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"], + exported=received_topics[f"{topic_prefix}exported"], + 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..baf3c7bcc0 --- /dev/null +++ b/packages/modules/consumers/idm/idm/config.py @@ -0,0 +1,33 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or IdmConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/idm/idm/consumer.py b/packages/modules/consumers/idm/idm/consumer.py new file mode 100644 index 0000000000..792b412626 --- /dev/null +++ b/packages/modules/consumers/idm/idm/consumer.py @@ -0,0 +1,65 @@ +#!/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: + 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) + # 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..f72ec12455 --- /dev/null +++ b/packages/modules/consumers/lambda_/lambda_/config.py @@ -0,0 +1,33 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or LambdaConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/lambda_/lambda_/consumer.py b/packages/modules/consumers/lambda_/lambda_/consumer.py new file mode 100644 index 0000000000..83063bbb0b --- /dev/null +++ b/packages/modules/consumers/lambda_/lambda_/consumer.py @@ -0,0 +1,49 @@ +#!/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: + 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: + 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..2e8f785058 --- /dev/null +++ b/packages/modules/consumers/my_pv/acthor/config.py @@ -0,0 +1,35 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ActhorConfiguration(), usage=usage, **kwargs) 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..f35b96d8dc --- /dev/null +++ b/packages/modules/consumers/my_pv/acthor/consumer.py @@ -0,0 +1,74 @@ +#!/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: + 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: + 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..c41b65881f --- /dev/null +++ b/packages/modules/consumers/my_pv/elwa_e/config.py @@ -0,0 +1,31 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ElwaConfiguration(), usage=usage, **kwargs) 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..779fb41c6b --- /dev/null +++ b/packages/modules/consumers/my_pv/elwa_e/consumer.py @@ -0,0 +1,90 @@ +#!/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 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: + 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..3532dff044 --- /dev/null +++ b/packages/modules/consumers/mystrom/mystrom/config.py @@ -0,0 +1,26 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or MyStromConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/mystrom/mystrom/consumer.py b/packages/modules/consumers/mystrom/mystrom/consumer.py new file mode 100644 index 0000000000..82b07b13ad --- /dev/null +++ b/packages/modules/consumers/mystrom/mystrom/consumer.py @@ -0,0 +1,55 @@ +#!/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: + 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: + session.get(f"http://{config.configuration.ip_address}/relay?state=1", timeout=3) + + def switch_off() -> None: + 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..5168d0de78 --- /dev/null +++ b/packages/modules/consumers/nibe/nibe_s_series/config.py @@ -0,0 +1,28 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.METER_ONLY,), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or NibeConfiguration(), usage=usage, **kwargs) 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..bf2142caf5 --- /dev/null +++ b/packages/modules/consumers/nibe/nibe_s_series/consumer.py @@ -0,0 +1,42 @@ +#!/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: + 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..4c325d0500 --- /dev/null +++ b/packages/modules/consumers/ratiotherm/ratiotherm/config.py @@ -0,0 +1,30 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE,), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or RatiothermConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/ratiotherm/ratiotherm/consumer.py b/packages/modules/consumers/ratiotherm/ratiotherm/consumer.py new file mode 100644 index 0000000000..cd8cd4f06c --- /dev/null +++ b/packages/modules/consumers/ratiotherm/ratiotherm/consumer.py @@ -0,0 +1,35 @@ +#!/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: + # 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..2d3169acfa --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_em/config.py @@ -0,0 +1,34 @@ +from typing import Optional, Tuple + +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 ShellyConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + factor: Optional[int] = -1, + phase: Optional[int] = 1, + username: Optional[str] = None, + password: Optional[str] = None) -> None: + self.ip_address = ip_address + self.factor = factor + self.phase = phase + self.username = username + self.password = password + + +@auto_str +class ShellyEM(ConsumerSetup[ShellyConfiguration]): + def __init__(self, + name: str = "Shelly EM (Messen)", + type: str = "shelly_em", + id: int = 0, + configuration: ShellyConfiguration = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.METER_ONLY,), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ShellyConfiguration(), usage=usage, **kwargs) 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..f7e83e4cc9 --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_em/consumer.py @@ -0,0 +1,46 @@ +#!/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.simcount._simcounter import SimCounterConsumer +from modules.consumers.shelly.shelly_em.config import ShellyEM +from modules.devices.shelly.shelly.status_handler import get_generation, request_status, parse_data + + +def create_consumer(config: ShellyEM): + sim_counter = None + generation = 1 + + def initializer(): + nonlocal sim_counter, generation + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + generation, _ = get_generation(config.configuration.ip_address) + + def error_handler() -> None: + initializer() + + def update() -> ConsumerState: + status = request_status(config.configuration.ip_address, generation) + powers, voltages, currents, _, power, _ = parse_data( + config.configuration.phase, config.configuration.factor, status) + imported, exported = sim_counter.sim_count(power) + consumer_state = ConsumerState( + power=power, + imported=imported, + exported=exported, + powers=powers, + ) + if voltages is not None: + consumer_state.voltages = voltages + if currents is not None: + consumer_state.currents = currents + return consumer_state + + 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..d0676e9a84 --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_pm/config.py @@ -0,0 +1,37 @@ +from typing import Optional, Tuple + +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 ShellyConfiguration: + def __init__(self, + ip_address: Optional[str] = None, + factor: Optional[int] = -1, + phase: Optional[int] = 1, + channel: int = 0, + username: Optional[str] = None, + password: Optional[str] = None) -> None: + self.ip_address = ip_address + self.factor = factor + self.phase = phase + self.channel = channel + self.username = username + self.password = password + + +@auto_str +class ShellyPM(ConsumerSetup[ShellyConfiguration]): + def __init__(self, + name: str = "Shelly PM (Messen & Schalten)", + type: str = "shelly_pm", + id: int = 0, + configuration: ShellyConfiguration = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.METER_ONLY, + ConsumerUsage.SUSPENDABLE_ONOFF), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ShellyConfiguration(), usage=usage, **kwargs) 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..db4ac15646 --- /dev/null +++ b/packages/modules/consumers/shelly/shelly_pm/consumer.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +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.shelly.shelly_pm.config import ShellyPM +from modules.devices.shelly.shelly.status_handler import get_generation, request_status, parse_data + + +def create_consumer(config: ShellyPM): + sim_counter = model = None + generation = 1 + + def initializer(): + nonlocal sim_counter, generation, model + sim_counter = SimCounterConsumer(config.id, ComponentType.CONSUMER) + generation, model = get_generation(config.configuration.ip_address) + + def error_handler() -> None: + initializer() + + def switch_on() -> None: + if generation == 1: + url = f"http://{config.configuration.ip_address}/relay/{config.configuration.channel}?turn=on" + else: + # shelly pro 3em mit add on hat fix id 100 als switch Kanal, das Device muss auf jeden fall mit separater + # Leistunsmessung erfasst werden, da die Leistung auf drei verschiedenenen Kanälen angeliefert werden kann + if "SPEM-003CE" in model: + chan = 100 + else: + chan = config.configuration.channel + # gen 2 will das als on cmd /rpc/Switch.Set?id=100&on=true + url = f"http://{config.configuration.ip_address}/rpc/Switch.Set?id={chan}&on=true" + if config.configuration.username and config.configuration.password: + auth = (config.configuration.username, config.configuration.password) + else: + auth = None + req.get_http_session().get(url, auth=auth, timeout=3) + + def switch_off() -> None: + if generation == 1: + url = f"http://{config.configuration.ip_address}/relay/{config.configuration.channel}?turn=off" + else: + # shelly pro 3em mit add on hat fix id 100 als switch Kanal, das Device muss auf jeden fall mit separater + # Leistunsmessung erfasst werden, da die Leistung auf drei verschiedenenen Kanälen angeliefert werden kann + if "SPEM-003CE" in model: + chan = 100 + else: + chan = config.configuration.channel + # gen 2 will das als on cmd /rpc/Switch.Set?id=100&on=true + url = f"http://{config.configuration.ip_address}/rpc/Switch.Set?id={chan}&on=false" + if config.configuration.username and config.configuration.password: + auth = (config.configuration.username, config.configuration.password) + else: + auth = None + req.get_http_session().get(url, auth=auth, timeout=3) + + def update() -> ConsumerState: + status = request_status(config.configuration.ip_address, generation) + powers, voltages, currents, _, power, _ = parse_data( + config.configuration.phase, config.configuration.factor, status) + imported, exported = sim_counter.sim_count(power) + return ConsumerState( + power=power, + imported=imported, + exported=exported, + voltages=voltages, + currents=currents, + powers=powers, + ) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + switch_on=switch_on, + switch_off=switch_off, + update=update,) + + +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..564832c8da --- /dev/null +++ b/packages/modules/consumers/solvis/solvis/config.py @@ -0,0 +1,27 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.METER_ONLY,), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or SolvisConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/solvis/solvis/consumer.py b/packages/modules/consumers/solvis/solvis/consumer.py new file mode 100644 index 0000000000..3830a9d2f4 --- /dev/null +++ b/packages/modules/consumers/solvis/solvis/consumer.py @@ -0,0 +1,38 @@ +#!/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: + 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..aa940eef11 --- /dev/null +++ b/packages/modules/consumers/stiebel/stiebel/config.py @@ -0,0 +1,30 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF,), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or StiebelConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/stiebel/stiebel/consumer.py b/packages/modules/consumers/stiebel/stiebel/consumer.py new file mode 100644 index 0000000000..c7d39a3a1e --- /dev/null +++ b/packages/modules/consumers/stiebel/stiebel/consumer.py @@ -0,0 +1,39 @@ +#!/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: + client.write_register(4001, 1, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + + def switch_off() -> None: + 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..6274b60698 --- /dev/null +++ b/packages/modules/consumers/viessmann/viessmann_heat_pump/config.py @@ -0,0 +1,27 @@ +from typing import Optional, Tuple + +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: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_ONOFF,), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or ViessmannConfiguration(), usage=usage, **kwargs) 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..cac0876d05 --- /dev/null +++ b/packages/modules/consumers/viessmann/viessmann_heat_pump/consumer.py @@ -0,0 +1,31 @@ +#!/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: + client.write_single_coil(16, True, unit=config.configuration.modbus_id) + + def switch_off() -> None: + 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) diff --git a/packages/modules/devices/generic/http/api.py b/packages/modules/devices/generic/http/api.py index ac3e7526bb..076fca8532 100644 --- a/packages/modules/devices/generic/http/api.py +++ b/packages/modules/devices/generic/http/api.py @@ -20,6 +20,15 @@ def create_request_function(url: str, path: Optional[str]) -> RequestFunction[Op return functools.partial(_request_value, url + path) +def create_post_function(url: str, path: Optional[str]) -> Callable[[Session, dict], None]: + if path is None: + return lambda _session, _params: None + else: + def post_function(session: Session, params: dict): + session.post(url + path, params=params, timeout=5) + return post_function + + def create_request_function_array(url: str, paths: Iterable[Optional[str]]) -> RequestFunction[Optional[List[float]]]: functions = [] # type: List[RequestFunction[float]] for path in paths: 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 diff --git a/packages/modules/devices/shelly/shelly/bat.py b/packages/modules/devices/shelly/shelly/bat.py index 3e48506b90..3221e8a8ac 100644 --- a/packages/modules/devices/shelly/shelly/bat.py +++ b/packages/modules/devices/shelly/shelly/bat.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import logging from typing import Optional, TypedDict, Any -from modules.common import req from modules.common.abstract_device import AbstractBat from modules.common.component_state import BatState from modules.common.component_type import ComponentDescriptor @@ -9,9 +8,9 @@ from modules.common.store import get_component_value_store from modules.common.simcount._simcounter import SimCounter from modules.devices.shelly.shelly.config import ShellyBatSetup -from modules.devices.shelly.shelly.constants import ALPHABETICAL_INDEX from modules.common.utils.peak_filter import PeakFilter from modules.common.component_type import ComponentType +from modules.devices.shelly.shelly.status_handler import parse_data, request_status log = logging.getLogger(__name__) @@ -41,64 +40,18 @@ def initialize(self) -> None: self.peak_filter = PeakFilter(ComponentType.BAT, self.component_config.id, self.fault_state) def update(self) -> None: - if self.generation == 1: - status_url = "http://" + self.address + "/status" - else: - status_url = "http://" + self.address + "/rpc/Shelly.GetStatus" - status = req.get_http_session().get(status_url, timeout=3).json() - - try: - power = 0 - currents = [0.0, 0.0, 0.0] - # GEN 1 - if "meters" in status: - meters = status['meters'] # einphasiger shelly? - for i in range(0, min(3, len(meters))): - currents[(i+self.phase-1) % 3] = (float(meters[i].get('power', 0)) * self.factor) / 230 - power = power + (float(meters[i].get('power', 0)) * self.factor) - elif "emeters" in status: - meters = status['emeters'] # shellyEM & shelly3EM - # shellyEM has one meter, shelly3EM has three meters - for i in range(0, min(3, len(meters))): - currents[(i+self.phase-1) % 3] = float(meters[i].get('current', 0)) * self.factor - power = power + float(meters[i].get('power', 0)) * self.factor - # GEN 2+ - # shelly Pro3EM - elif "em:0" in status: - meters = status['em:0'] - for i, alphabetical_index in enumerate(ALPHABETICAL_INDEX): - if meters.get(f'{alphabetical_index}_current') is None: - continue - currents[(i+self.phase-1) % 3] = float(meters.get(f'{alphabetical_index}_current', 0)) * self.factor - power = float(meters.get('total_act_power', 0)) * self.factor - # Shelly MiniPM G3 - elif "pm1:0" in status: - log.debug("single phase shelly") - meters = status['pm1:0'] - currents[self.phase-1] = meters['current'] * self.factor - power = meters['apower'] * self.factor - elif 'switch:0' in status and 'apower' in status['switch:0']: - log.debug("single phase shelly") - meters = status['switch:0'] - currents[self.phase-1] = meters['current'] * self.factor - power = meters['apower'] * self.factor - else: - log.debug("single phase shelly") - meters = status['em1:0'] - currents[self.phase-1] = meters['current'] * self.factor - power = meters['act_power'] * self.factor # shelly Pro EM Gen 2 - self.peak_filter.check_values(power) - imported, exported = self.sim_counter.sim_count(power) - - bat_state = BatState( - power=power, - currents=currents, - imported=imported, - exported=exported - ) - self.store.set(bat_state) - except KeyError: - log.exception("unsupported shelly device.") + status = request_status(self.address, self.generation) + _, _, currents, _, power, _ = parse_data(self.phase, self.factor, status) + self.peak_filter.check_values(power) + imported, exported = self.sim_counter.sim_count(power) + + bat_state = BatState( + power=power, + currents=currents, + imported=imported, + exported=exported + ) + self.store.set(bat_state) component_descriptor = ComponentDescriptor(configuration_factory=ShellyBatSetup) diff --git a/packages/modules/devices/shelly/shelly/constants.py b/packages/modules/devices/shelly/shelly/constants.py deleted file mode 100644 index 4af0561042..0000000000 --- a/packages/modules/devices/shelly/shelly/constants.py +++ /dev/null @@ -1 +0,0 @@ -ALPHABETICAL_INDEX = ['a', 'b', 'c'] diff --git a/packages/modules/devices/shelly/shelly/counter.py b/packages/modules/devices/shelly/shelly/counter.py index 855420784f..89c33adcea 100644 --- a/packages/modules/devices/shelly/shelly/counter.py +++ b/packages/modules/devices/shelly/shelly/counter.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import logging from typing import Optional, TypedDict, Any -from modules.common import req from modules.common.abstract_device import AbstractCounter from modules.common.component_state import CounterState from modules.common.component_type import ComponentDescriptor @@ -9,9 +8,9 @@ from modules.common.store import get_component_value_store from modules.common.simcount._simcounter import SimCounter from modules.devices.shelly.shelly.config import ShellyCounterSetup -from modules.devices.shelly.shelly.constants import ALPHABETICAL_INDEX from modules.common.utils.peak_filter import PeakFilter from modules.common.component_type import ComponentType +from modules.devices.shelly.shelly.status_handler import parse_data, request_status log = logging.getLogger(__name__) @@ -42,114 +41,26 @@ def initialize(self) -> None: def update(self) -> None: power = 0 - if self.generation == 1: - status_url = "http://" + self.address + "/status" - else: - status_url = "http://" + self.address + "/rpc/Shelly.GetStatus" - status = req.get_http_session().get(status_url, timeout=3).json() - try: - # GEN 1 - if "meters" in status: - powers = [0.0, 0.0, 0.0] - voltages = [0.0, 0.0, 0.0] - meters = status['meters'] # einphasiger shelly? - for i in range(0, min(3, len(meters))): - powers[(i+self.phase-1) % 3] = float(meters[i].get('power', 0)) * self.factor - voltages[(i+self.phase-1) % 3] = 230 - power = sum(powers) - elif "emeters" in status: - powers = [0.0, 0.0, 0.0] - currents = [0.0, 0.0, 0.0] - voltages = [0.0, 0.0, 0.0] - power_factors = [0.0, 0.0, 0.0] - meters = status['emeters'] # shellyEM & shelly3EM - # shellyEM has one meter, shelly3EM has three meters - for i in range(0, min(3, len(meters))): - powers[(i+self.phase-1) % 3] = float(meters[i].get('power', 0)) * self.factor - currents[(i+self.phase-1) % 3] = float(meters[i].get('current', 0)) * self.factor - voltages[(i+self.phase-1) % 3] = float(meters[i].get('voltage', 0)) - power_factors[(i+self.phase-1) % 3] = float(meters[i].get('pf', 0)) - power = sum(powers) + status = request_status(self.address, self.generation) + powers, voltages, currents, power_factors, power, frequency = parse_data(self.phase, self.factor, status) + self.peak_filter.check_values(power) + imported, exported = self.sim_counter.sim_count(power) - # GEN 2+ - # shelly Pro3EM - elif "em:0" in status: - powers = [0.0, 0.0, 0.0] - currents = [0.0, 0.0, 0.0] - voltages = [0.0, 0.0, 0.0] - power_factors = [0.0, 0.0, 0.0] - meters = status['em:0'] - for i, alphabetical_index in enumerate(ALPHABETICAL_INDEX): - if meters.get(f'{alphabetical_index}_act_power') is None: - continue - powers[(i+self.phase-1) % 3] = float(meters.get(f'{alphabetical_index}_act_power', 0)) * self.factor - voltages[(i+self.phase-1) % 3] = float(meters.get(f'{alphabetical_index}_voltage', 0)) - currents[(i+self.phase-1) % 3] = float(meters.get(f'{alphabetical_index}_current', 0)) * self.factor - power_factors[(i+self.phase-1) % 3] = float(meters.get(f'{alphabetical_index}_pf', 0)) - power = float(meters.get('total_act_power', 0)) * self.factor - # Shelly MiniPM G3 - elif "pm1:0" in status: - log.debug("single phase shelly") - powers = [0.0, 0.0, 0.0] - currents = [0.0, 0.0, 0.0] - voltages = [0.0, 0.0, 0.0] - power_factors = [0.0, 0.0, 0.0] - meters = status['pm1:0'] - powers[self.phase-1] = meters['apower'] * self.factor - voltages[self.phase-1] = meters['voltage'] - currents[self.phase-1] = meters['current'] * self.factor - power_factors[self.phase-1] = meters.get('pf', 0) - power = meters['apower'] * self.factor - frequency = meters['freq'] - elif 'switch:0' in status and 'apower' in status['switch:0']: - log.debug("single phase shelly") - powers = [0.0, 0.0, 0.0] - currents = [0.0, 0.0, 0.0] - voltages = [0.0, 0.0, 0.0] - power_factors = [0.0, 0.0, 0.0] - meters = status['switch:0'] - powers[self.phase-1] = meters['apower'] * self.factor - voltages[self.phase-1] = meters['voltage'] - currents[self.phase-1] = meters['current'] * self.factor - if 'pf' in meters: - power_factors[self.phase-1] = meters['pf'] - power = meters['apower'] * self.factor - if 'freq' in meters: - frequency = meters['freq'] - else: - log.debug("single phase shelly") - powers = [0.0, 0.0, 0.0] - currents = [0.0, 0.0, 0.0] - voltages = [0.0, 0.0, 0.0] - power_factors = [0.0, 0.0, 0.0] - meters = status['em1:0'] - powers[self.phase-1] = meters['act_power'] - voltages[self.phase-1] = meters['voltage'] - currents[self.phase-1] = meters['current'] * self.factor - power_factors[self.phase-1] = meters['pf'] - power = meters['act_power'] # shelly Pro EM Gen 2 - frequency = meters['freq'] - - self.peak_filter.check_values(power) - imported, exported = self.sim_counter.sim_count(power) - - counter_state = CounterState( - imported=imported, - exported=exported, - powers=powers, - power=power - ) - if 'frequency' in locals(): - counter_state.frequency = frequency - if "power_factors" in locals(): - counter_state.power_factors = power_factors - if "voltages" in locals(): - counter_state.voltages = voltages - if "currents" in locals(): - counter_state.currents = currents - self.store.set(counter_state) - except KeyError: - log.exception("unsupported shelly device?") + counter_state = CounterState( + imported=imported, + exported=exported, + powers=powers, + power=power + ) + if frequency is not None: + counter_state.frequency = frequency + if power_factors is not None: + counter_state.power_factors = power_factors + if voltages is not None: + counter_state.voltages = voltages + if currents is not None: + counter_state.currents = currents + self.store.set(counter_state) component_descriptor = ComponentDescriptor(configuration_factory=ShellyCounterSetup) diff --git a/packages/modules/devices/shelly/shelly/device.py b/packages/modules/devices/shelly/shelly/device.py index de3cb54fd0..fecd835c81 100644 --- a/packages/modules/devices/shelly/shelly/device.py +++ b/packages/modules/devices/shelly/shelly/device.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 import logging -from modules.common import req from modules.common.abstract_device import DeviceDescriptor from modules.common.configurable_device import ConfigurableDevice, ComponentFactoryByType, IndependentComponentUpdater @@ -8,6 +7,7 @@ from modules.devices.shelly.shelly.bat import ShellyBat from modules.devices.shelly.shelly.counter import ShellyCounter from modules.devices.shelly.shelly.config import Shelly, ShellyInverterSetup, ShellyBatSetup, ShellyCounterSetup +from modules.devices.shelly.shelly.status_handler import get_generation log = logging.getLogger(__name__) @@ -42,10 +42,7 @@ def create_bat_component(component_config: ShellyBatSetup) -> ShellyBat: def initializer() -> None: nonlocal generation - device_info = req.get_http_session().get( - f"http://{device_config.configuration.ip_address}/shelly", timeout=3).json() - if 'gen' in device_info: # gen 2+ - generation = int(device_info['gen']) + generation, _ = get_generation(device_config.configuration.ip_address) return ConfigurableDevice( device_config=device_config, diff --git a/packages/modules/devices/shelly/shelly/inverter.py b/packages/modules/devices/shelly/shelly/inverter.py index 3b27e03cce..7aca74a6b5 100644 --- a/packages/modules/devices/shelly/shelly/inverter.py +++ b/packages/modules/devices/shelly/shelly/inverter.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import logging from typing import Optional, TypedDict, Any -from modules.common import req from modules.common.abstract_device import AbstractInverter from modules.common.component_state import InverterState from modules.common.component_type import ComponentDescriptor @@ -9,9 +8,9 @@ from modules.common.store import get_component_value_store from modules.common.simcount._simcounter import SimCounter from modules.devices.shelly.shelly.config import ShellyInverterSetup -from modules.devices.shelly.shelly.constants import ALPHABETICAL_INDEX from modules.common.utils.peak_filter import PeakFilter from modules.common.component_type import ComponentType +from modules.devices.shelly.shelly.status_handler import parse_data, request_status log = logging.getLogger(__name__) @@ -42,62 +41,18 @@ def initialize(self) -> None: def update(self) -> None: power = 0 - if self.generation == 1: - status_url = "http://" + self.address + "/status" - else: - status_url = "http://" + self.address + "/rpc/Shelly.GetStatus" - status = req.get_http_session().get(status_url, timeout=3).json() - try: - currents = [0.0, 0.0, 0.0] - # GEN 1 - if "meters" in status: - meters = status['meters'] # einphasiger shelly? - for i in range(0, min(3, len(meters))): - currents[(i+self.phase-1) % 3] += (float(meters[i].get('power', 0)) * self.factor) / 230 - power = power + float(meters[i].get('power', 0)) * self.factor - elif "emeters" in status: - meters = status['emeters'] # shellyEM & shelly3EM - # shellyEM has one meter, shelly3EM has three meters - for i in range(0, min(3, len(meters))): - currents[(i+self.phase-1) % 3] = float(meters[i].get('current', 0)) * self.factor - power = power + float(meters[i].get('power', 0)) * self.factor - # GEN 2+ - # shelly Pro3EM - elif "em:0" in status: - meters = status['em:0'] - for i, alphabetical_index in enumerate(ALPHABETICAL_INDEX): - if meters.get(f'{alphabetical_index}_current') is None: - continue - currents[(i+self.phase-1) % 3] = float(meters.get(f'{alphabetical_index}_current', 0)) * self.factor - power = float(meters.get('total_act_power', 0)) * self.factor - # Shelly MiniPM G3 - elif "pm1:0" in status: - log.debug("single phase shelly") - meters = status['pm1:0'] - currents[self.phase-1] = meters['current'] * self.factor - power = meters['apower'] * self.factor - elif 'switch:0' in status and 'apower' in status['switch:0']: - log.debug("single phase shelly") - meters = status['switch:0'] - currents[self.phase-1] = meters['current'] * self.factor - power = meters['apower'] * self.factor - else: - log.debug("single phase shelly") - meters = status['em1:0'] - currents[self.phase-1] = meters['current'] * self.factor - power = meters['act_power'] * self.factor # shelly Pro EM Gen 2 - - self.peak_filter.check_values(power) - _, exported = self.sim_counter.sim_count(power) - - inverter_state = InverterState( - power=power, - currents=currents, - exported=exported - ) - self.store.set(inverter_state) - except KeyError: - log.exception("unsupported shelly device.") + status = request_status(self.address, self.generation) + _, _, currents, _, power, _ = parse_data(self.phase, self.factor, status) + + self.peak_filter.check_values(power) + _, exported = self.sim_counter.sim_count(power) + + inverter_state = InverterState( + power=power, + currents=currents, + exported=exported + ) + self.store.set(inverter_state) component_descriptor = ComponentDescriptor(configuration_factory=ShellyInverterSetup) diff --git a/packages/modules/devices/shelly/shelly/status_handler.py b/packages/modules/devices/shelly/shelly/status_handler.py new file mode 100644 index 0000000000..5ea1db2154 --- /dev/null +++ b/packages/modules/devices/shelly/shelly/status_handler.py @@ -0,0 +1,128 @@ +import logging +from typing import Dict +from typing import Tuple, List, Optional + +from modules.common import req + +log = logging.getLogger(__name__) + +ALPHABETICAL_INDEX = ['a', 'b', 'c'] + + +def get_generation(address: str) -> Tuple[Optional[int], str]: + device_info = req.get_http_session().get(f"http://{address}/shelly", timeout=3).json() + generation = 1 # default to gen 1 + model = "unknown" + if 'gen' in device_info: # gen 2+ + generation = int(device_info['gen']) + if 'model' in device_info: + model = str(device_info['model']) + elif 'type' in device_info: + model = str(device_info['type']) + log.debug(f"Device {model} at {address} is generation {generation}") + return generation, model + + +def request_status(address: str, generation: Optional[int]) -> Dict: + if generation == 1: + status_url = "http://" + address + "/status" + else: + status_url = "http://" + address + "/rpc/Shelly.GetStatus" + return req.get_http_session().get(status_url, timeout=3).json() + + +def parse_data(phase: int, factor: float, status: Dict) -> Tuple[ + List[float], Optional[List[float]], Optional[List[float]], Optional[List[float]], float, Optional[float]]: + try: + currents: Optional[List[float]] = None + voltages: Optional[List[float]] = None + power_factors: Optional[List[float]] = None + frequency: Optional[float] = None + + # GEN 1 + if "meters" in status: + currents = [0.0, 0.0, 0.0] + powers = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + meters = status['meters'] # einphasiger shelly? + for i in range(0, min(3, len(meters))): + powers[(i+phase-1) % 3] = float(meters[i].get('power', 0)) * factor + currents[(i+phase-1) % 3] = (float(meters[i].get('power', 0)) * factor) / 230 + voltages[(i+phase-1) % 3] = 230 + power = sum(powers) + elif "emeters" in status: + powers = [0.0, 0.0, 0.0] + currents = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + power_factors = [0.0, 0.0, 0.0] + meters = status['emeters'] # shellyEM & shelly3EM + # shellyEM has one meter, shelly3EM has three meters + for i in range(0, min(3, len(meters))): + powers[(i+phase-1) % 3] = float(meters[i].get('power', 0)) * factor + currents[(i+phase-1) % 3] = float(meters[i].get('current', 0)) * factor + voltages[(i+phase-1) % 3] = float(meters[i].get('voltage', 0)) + power_factors[(i+phase-1) % 3] = float(meters[i].get('pf', 0)) + power = sum(powers) + + # GEN 2+ + # shelly Pro3EM + elif "em:0" in status: + powers = [0.0, 0.0, 0.0] + currents = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + power_factors = [0.0, 0.0, 0.0] + meters = status['em:0'] + for i, alphabetical_index in enumerate(ALPHABETICAL_INDEX): + if meters.get(f'{alphabetical_index}_act_power') is None: + continue + powers[(i+phase-1) % 3] = float(meters.get(f'{alphabetical_index}_act_power', 0)) * factor + voltages[(i+phase-1) % 3] = float(meters.get(f'{alphabetical_index}_voltage', 0)) + currents[(i+phase-1) % 3] = float(meters.get(f'{alphabetical_index}_current', 0)) * factor + power_factors[(i+phase-1) % 3] = float(meters.get(f'{alphabetical_index}_pf', 0)) + power = float(meters.get('total_act_power', 0)) * factor + # Shelly MiniPM G3 + elif "pm1:0" in status: + log.debug("single phase shelly") + powers = [0.0, 0.0, 0.0] + currents = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + power_factors = [0.0, 0.0, 0.0] + meters = status['pm1:0'] + powers[phase-1] = meters['apower'] * factor + voltages[phase-1] = meters['voltage'] + currents[phase-1] = meters['current'] * factor + power_factors[phase-1] = meters.get('pf', 0) + power = meters['apower'] * factor + frequency = meters['freq'] + elif 'switch:0' in status and 'apower' in status['switch:0']: + log.debug("single phase shelly") + powers = [0.0, 0.0, 0.0] + currents = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + power_factors = [0.0, 0.0, 0.0] + meters = status['switch:0'] + powers[phase-1] = meters['apower'] * factor + voltages[phase-1] = meters['voltage'] + currents[phase-1] = meters['current'] * factor + if 'pf' in meters: + power_factors[phase-1] = meters['pf'] + power = meters['apower'] * factor + if 'freq' in meters: + frequency = meters['freq'] + else: + log.debug("single phase shelly") + powers = [0.0, 0.0, 0.0] + currents = [0.0, 0.0, 0.0] + voltages = [0.0, 0.0, 0.0] + power_factors = [0.0, 0.0, 0.0] + meters = status['em1:0'] + powers[phase-1] = meters['act_power'] + voltages[phase-1] = meters['voltage'] + currents[phase-1] = meters['current'] * factor + power_factors[phase-1] = meters['pf'] + power = meters['act_power'] * factor # shelly Pro EM Gen 2 + frequency = meters['freq'] + + return powers, voltages, currents, power_factors, power, frequency + except KeyError: + raise Exception("unsupported shelly device?") diff --git a/packages/modules/display_themes/cards/source/src/App.vue b/packages/modules/display_themes/cards/source/src/App.vue index 6058e5320e..4e39aaac5d 100644 --- a/packages/modules/display_themes/cards/source/src/App.vue +++ b/packages/modules/display_themes/cards/source/src/App.vue @@ -60,6 +60,7 @@ export default { "openWB/chargepoint/+/set/log", "openWB/chargepoint/+/set/rfid", "openWB/chargepoint/get/power", + "openWB/consumer/get/power", "openWB/counter/+/get/power", "openWB/counter/get/hierarchy", "openWB/counter/set/home_consumption", diff --git a/packages/modules/display_themes/cards/source/src/assets/icons/owbConsumer.svg b/packages/modules/display_themes/cards/source/src/assets/icons/owbConsumer.svg new file mode 100644 index 0000000000..ee05b02183 --- /dev/null +++ b/packages/modules/display_themes/cards/source/src/assets/icons/owbConsumer.svg @@ -0,0 +1 @@ + diff --git a/packages/modules/display_themes/cards/source/src/components/Dashboard/FlowCard.vue b/packages/modules/display_themes/cards/source/src/components/Dashboard/FlowCard.vue index 2c00091667..cccbf1afcb 100644 --- a/packages/modules/display_themes/cards/source/src/components/Dashboard/FlowCard.vue +++ b/packages/modules/display_themes/cards/source/src/components/Dashboard/FlowCard.vue @@ -10,6 +10,7 @@ import PvIcon from "../../assets/icons/owbPV.svg?component"; import HouseIcon from "../../assets/icons/owbHouse.svg?component"; import VehicleIcon from "../../assets/icons/owbVehicle.svg?component"; import ChargePointIcon from "../../assets/icons/owbChargePoint.svg?component"; +import ConsumerIcon from "../../assets/icons/owbConsumer.svg?component"; export default { name: "DashboardFlowCard", @@ -84,6 +85,12 @@ export default { showHomePower() { return this.homePower.value !== undefined; }, + consumerPower() { + return this.mqttStore.getConsumerSumPower("object"); + }, + showConsumerPower() { + return this.consumerPower.value !== undefined; + }, batteryPower() { return this.mqttStore.getBatteryPower("object"); }, @@ -255,6 +262,9 @@ export default { homeProduction() { return this.homePower.value < 0; }, + consumerConsuming() { + return this.consumerPower.value > 0; + }, pvProduction() { return this.pvPower.value < 0; }, @@ -355,6 +365,25 @@ export default { iconColor: "var(--color--light)", }); } + // add consumer sum component + if (this.showConsumerPower) { + components.push({ + id: "consumer", + class: { + base: "consumer", + valueLabel: "", + animated: false, + animatedReverse: this.consumerConsuming, + }, + position: { + row: 0, + column: 1, + }, + label: ["Verbraucher", this.absoluteValue(this.consumerPower).textValue], + iconComponent: ConsumerIcon, + iconColor: "var(--color--consumer)", + }); + } // add pv sum component if ( this.mqttStore.getPvConfigured && @@ -970,6 +999,11 @@ path.animatedReverse.grid { stroke: var(--color--success); } +path.animated.consumer, +path.animatedReverse.consumer { + stroke: var(--color--consumer); +} + path.animated.pv, path.animatedReverse.pv { stroke: var(--color--success); diff --git a/packages/modules/display_themes/cards/source/src/main.scss b/packages/modules/display_themes/cards/source/src/main.scss index 815184bfc9..7126d07f2f 100644 --- a/packages/modules/display_themes/cards/source/src/main.scss +++ b/packages/modules/display_themes/cards/source/src/main.scss @@ -4,6 +4,7 @@ :root { --spacing: 0.5rem; --body--background--dark: #242526; + --color--consumer: #7d42f5; } /* prevent touch gestures */ diff --git a/packages/modules/display_themes/cards/source/src/stores/mqtt.js b/packages/modules/display_themes/cards/source/src/stores/mqtt.js index aa4c0218ba..6d33f908e0 100644 --- a/packages/modules/display_themes/cards/source/src/stores/mqtt.js +++ b/packages/modules/display_themes/cards/source/src/stores/mqtt.js @@ -424,6 +424,21 @@ export const useMqttStore = defineStore("mqtt", { }); }, + /* consumer getters */ + + getConsumerSumPower(state) { + return (returnType = "textValue") => { + var power = state.getValueString("openWB/consumer/get/power", "W"); + if (Object.hasOwnProperty.call(power, returnType)) { + return power[returnType]; + } + if (returnType == "object") { + return power; + } + console.error("returnType not found!", returnType, power); + }; + }, + /* charge point getters */ getChargePointSumPower(state) { 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: diff --git a/packages/modules/web_themes/koala/source/public/icons/icon_Data.md b/packages/modules/web_themes/koala/source/src/assets/icons/icon_Data.md similarity index 100% rename from packages/modules/web_themes/koala/source/public/icons/icon_Data.md rename to packages/modules/web_themes/koala/source/src/assets/icons/icon_Data.md diff --git a/packages/modules/web_themes/koala/source/src/assets/icons/owbConsumer.svg b/packages/modules/web_themes/koala/source/src/assets/icons/owbConsumer.svg new file mode 100644 index 0000000000..ee05b02183 --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/assets/icons/owbConsumer.svg @@ -0,0 +1 @@ + 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..5ec53f7e33 --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue @@ -0,0 +1,166 @@ + + + + + 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..ef3307562f --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerInformation.vue @@ -0,0 +1,56 @@ + + + + + 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..92a834622f --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerModeButtons.vue @@ -0,0 +1,75 @@ + + + + + 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..862f136495 --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/packages/modules/web_themes/koala/source/src/components/charts/dailyTotals/DailyTotals.vue b/packages/modules/web_themes/koala/source/src/components/charts/dailyTotals/DailyTotals.vue index aaa86d7cd1..5e50d59cc7 100644 --- a/packages/modules/web_themes/koala/source/src/components/charts/dailyTotals/DailyTotals.vue +++ b/packages/modules/web_themes/koala/source/src/components/charts/dailyTotals/DailyTotals.vue @@ -89,16 +89,20 @@ -