diff --git a/data/config/mosquitto/local/openwb_local.conf b/data/config/mosquitto/local/openwb_local.conf index f540b3dcf1..e96e2071d2 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:21 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 10ed378e2f..8e57c0ab0f 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": [] @@ -1045,49 +1045,73 @@ "acls": [ { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/control_range", + "topic": "openWB/set/general/chargemode_config/surplus/control_range", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/feed_in_yield", + "topic": "openWB/set/general/chargemode_config/surplus/feed_in_limit", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/phase_switch_delay", + "topic": "openWB/set/general/chargemode_config/surplus/feed_in_yield", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/switch_on_threshold", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/phase_switch_delay", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/retry_failed_phase_switches", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/switch_on_threshold", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/switch_on_delay", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/retry_failed_phase_switches", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/switch_off_threshold", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/switch_on_delay", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/switch_off_delay", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/switch_off_threshold", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/chargemode_config/surplus/vehicle/switch_off_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/chargemode_config/surplus/consumer/switch_on_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/chargemode_config/surplus/consumer/switch_off_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientSend", + "topic": "openWB/set/general/chargemode_config/surplus/consumer/switch_off_threshold", "priority": 0, "allow": true }, @@ -1099,49 +1123,73 @@ }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/control_range", + "topic": "openWB/general/chargemode_config/surplus/control_range", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/chargemode_config/surplus/feed_in_limit", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/chargemode_config/surplus/feed_in_yield", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/chargemode_config/surplus/vehicle/phase_switch_delay", + "priority": 0, + "allow": true + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/general/chargemode_config/surplus/vehicle/switch_on_threshold", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/feed_in_yield", + "topic": "openWB/general/chargemode_config/surplus/vehicle/retry_failed_phase_switches", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/phase_switch_delay", + "topic": "openWB/general/chargemode_config/surplus/vehicle/switch_on_delay", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/switch_on_threshold", + "topic": "openWB/general/chargemode_config/surplus/vehicle/switch_off_threshold", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/retry_failed_phase_switches", + "topic": "openWB/general/chargemode_config/surplus/vehicle/switch_off_delay", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/switch_on_delay", + "topic": "openWB/general/chargemode_config/surplus/consumer/switch_on_delay", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/switch_off_threshold", + "topic": "openWB/general/chargemode_config/surplus/consumer/switch_off_delay", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/switch_off_delay", + "topic": "openWB/general/chargemode_config/surplus/consumer/switch_off_threshold", "priority": 0, "allow": true } @@ -1226,43 +1274,43 @@ }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/bat_mode", + "topic": "openWB/set/general/chargemode_config/bat/mode", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/bat_power_discharge", + "topic": "openWB/set/general/chargemode_config/bat/power_discharge", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/bat_power_discharge_active", + "topic": "openWB/set/general/chargemode_config/bat/power_discharge_active", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/bat_power_reserve", + "topic": "openWB/set/general/chargemode_config/bat/power_reserve", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/bat_power_reserve_active", + "topic": "openWB/set/general/chargemode_config/bat/power_reserve_active", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/max_bat_soc", + "topic": "openWB/set/general/chargemode_config/bat/max_soc", "priority": 0, "allow": true }, { "acltype": "publishClientSend", - "topic": "openWB/set/general/chargemode_config/pv_charging/min_bat_soc", + "topic": "openWB/set/general/chargemode_config/bat/min_soc", "priority": 0, "allow": true }, @@ -1358,43 +1406,43 @@ }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_mode", + "topic": "openWB/general/chargemode_config/bat/mode", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_power_discharge", + "topic": "openWB/general/chargemode_config/bat/power_discharge", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_power_discharge_active", + "topic": "openWB/general/chargemode_config/bat/power_discharge_active", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_power_reserve", + "topic": "openWB/general/chargemode_config/bat/power_reserve", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_power_reserve_active", + "topic": "openWB/general/chargemode_config/bat/power_reserve_active", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/max_bat_soc", + "topic": "openWB/general/chargemode_config/bat/max_soc", "priority": 0, "allow": true }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/min_bat_soc", + "topic": "openWB/general/chargemode_config/bat/min_soc", "priority": 0, "allow": true }, @@ -1514,6 +1562,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", @@ -1568,6 +1622,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", @@ -1709,6 +1769,61 @@ } ] }, + { + "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 + }, + { + "acltype": "publishClientReceive", + "topic": "openWB/optional/ep/configured", + "priority": 0, + "allow": true + } + ] + }, { "rolename": "vehicle-configuration-access", "textname": "Zugang zu den Fahrzeugeinstellungen", @@ -2794,7 +2909,7 @@ }, { "acltype": "publishClientReceive", - "topic": "openWB/general/chargemode_config/pv_charging/bat_mode", + "topic": "openWB/general/chargemode_config/bat/mode", "priority": 0, "allow": true } @@ -2813,6 +2928,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..5d7939788d --- /dev/null +++ b/docs/samples/sample_consumer/sample_consumer/consumer.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +from enum import IntEnum +import logging +from modules.common.abstract_consumer import CurrentValues +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, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.sample.sample.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] + ) + + # ConsumerUsage.SUSPENDABLE_TUNABLE + def set_power_limit(power_limit: float, data: SetLimitData) -> None: + nonlocal client + client.write_registers(1000, power_limit, unit=config.configuration.modbus_id) + + # ODER + # ConsumerUsage.SUSPENDABLE_ONOFF + # ConsumerUsage.CONTINUOUS + 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) + + # ODER + # ConsumerUsage.SELF_CONTROLLED + def send_values(values: CurrentValues) -> None: + client.write_registers(1000, values.evu_power, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + send_values=send_values, + set_power_limit=set_power_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..e2965acb05 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,46 @@ 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) + if len(preferenced_loads) > 0: + 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/algorithm.py b/packages/control/algorithm/algorithm.py index 2bfbeeb2a5..fa4b01ed8c 100644 --- a/packages/control/algorithm/algorithm.py +++ b/packages/control/algorithm/algorithm.py @@ -68,7 +68,6 @@ def _check_auto_phase_switch_delay(self) -> None: # wurden, wieder zurückgegeben. log.debug(f"Ladepunkt {cp.num}: Prüfen, ob Phasenumschaltung durchgeführt werden soll.") phases, current, message = charging_ev.auto_phase_switch( - cp.data.set.charge_template, cp.data.control_parameter, cp.num, cp.data.get.evse_current, 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 b6a78f451e..9027c97259 100644 --- a/packages/control/algorithm/common.py +++ b/packages/control/algorithm/common.py @@ -2,10 +2,12 @@ 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 import get_load_str +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 +21,39 @@ 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 {get_load_str(load)}") -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,30 +63,31 @@ 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 + required_currents = load.data.control_parameter.required_currents considered_current = consider_less_charging_chargepoint_in_loadmanagement( - chargepoint, current) + load, 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) data.data.io_actions.set_limit_loadmanager(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"{get_load_str(load)}: Stromstärke {current}A") # tested @@ -105,88 +107,97 @@ 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) + 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) max_target_set_current = consider_less_charging_chargepoint_in_loadmanagement( - chargepoint, max_target_set_current) + 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) data.data.io_actions.set_limit_loadmanager(sum(diffs)*230) -def consider_less_charging_chargepoint_in_loadmanagement(cp: Chargepoint, set_current: float) -> bool: - if (data.data.counter_all_data.data.config.consider_less_charging is False and - ((set_current - - cp.data.set.charging_ev_data.ev_template.data.nominal_difference) > get_medium_charging_current( - cp.data.get.currents) and - cp.data.control_parameter.timestamp_charge_start is not None and - check_timestamp(cp.data.control_parameter.timestamp_charge_start, LESS_CHARGING_TIMEOUT) is False)): - log.debug( - f"LP {cp.num} lädt deutlich unter dem Sollstrom und wird nur mit {cp.data.get.currents}A berücksichtigt.") - return get_medium_charging_current(cp.data.get.currents) +def consider_less_charging_chargepoint_in_loadmanagement(load: Load, set_current: float) -> float: + if isinstance(load, Chargepoint): + expected_current = set_current - load.data.set.charging_ev_data.ev_template.data.nominal_difference else: - return set_current + expected_current = set_current - 1 + expected_current = max(expected_current, 0) + if data.data.counter_all_data.data.config.consider_less_charging is False: + if expected_current >= get_medium_charging_current(load.data.get.currents): + if load.data.control_parameter.timestamp_charge_start is not None: + if check_timestamp(load.data.control_parameter.timestamp_charge_start, LESS_CHARGING_TIMEOUT) is False: + log.debug(f"{get_load_str(load)} lädt deutlich unter dem Sollstrom und " + f"wird nur mit {load.data.get.currents}A berücksichtigt.") + return get_medium_charging_current(load.data.get.currents) + elif load.data.set.current_prev != 0: + log.debug( + f"{get_load_str(load)} lädt nicht und wird nur mit {load.data.get.currents}A berücksichtigt.") + return get_medium_charging_current(load.data.get.currents) + return set_current # 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 @@ -196,8 +207,8 @@ 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 {get_load_str(load)}") diff --git a/packages/control/algorithm/common_test.py b/packages/control/algorithm/common_test.py index 707d88c0a2..ff9a584c20 100644 --- a/packages/control/algorithm/common_test.py +++ b/packages/control/algorithm/common_test.py @@ -8,7 +8,8 @@ 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.consumer.consumer import Consumer from control.io_device import IoActions @@ -123,7 +124,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 @@ -176,3 +177,32 @@ def test_consider_less_charging_chargepoint_in_loadmanagement(consider_less_char # assertion assert considered == expected_considered + + +@pytest.mark.parametrize( + "consider_less_charging, set_current, get_currents, current_prev, expected_considered", + [ + pytest.param(False, 10, [2]*3, 1, 2, + id="consumer less charging considered via timestamp none and current_prev"), + pytest.param(False, 10, [10]*3, 1, 10, + id="consumer expected_current threshold not reached"), + pytest.param(True, 10, [0]*3, 10, 10, + id="consider_less_charging"), + ]) +def test_consider_less_charging_consumer_in_loadmanagement(consider_less_charging: bool, + set_current: float, + get_currents: List[float], + current_prev: float, + expected_considered: float): + # setup + consumer = Consumer(7) + consumer.data.get.currents = get_currents + consumer.data.set.current_prev = current_prev + consumer.data.control_parameter.timestamp_charge_start = 1652683152 + data.data.counter_all_data.data.config.consider_less_charging = consider_less_charging + + # evaluation + considered = common.consider_less_charging_chargepoint_in_loadmanagement(consumer, set_current) + + # assertion + assert considered == expected_considered diff --git a/packages/control/algorithm/filter_chargepoints.py b/packages/control/algorithm/filter_chargepoints.py index f523b1d399..7454fe9a83 100644 --- a/packages/control/algorithm/filter_chargepoints.py +++ b/packages/control/algorithm/filter_chargepoints.py @@ -1,117 +1,105 @@ # tested import logging -from typing import List, Optional, Tuple, Union +import re +from typing import List, Optional, Tuple from control import data -from control.chargepoint.chargepoint import Chargepoint +from control.chargemode import Chargemode +from control.consumer.consumer import Consumer +from control.consumer.usage import ConsumerUsage +from control.load import get_load_str +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"{get_load_str(load)}: " + 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"{get_load_str(load)}: 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([get_load_str(load) for load in loads]) diff --git a/packages/control/algorithm/filter_chargepoints_test.py b/packages/control/algorithm/filter_chargepoints_test.py index def2524811..1cd469a374 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_loads_by_chargemodes( + 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..6c48750fbc 100644 --- a/packages/control/algorithm/integration_test/conftest.py +++ b/packages/control/algorithm/integration_test/conftest.py @@ -8,18 +8,20 @@ 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.consumer.consumer import Consumer +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 from control.pv import Pv from control.chargepoint.chargepoint_state import ChargepointState -from test_utils.default_hierarchies import NESTED_HIERARCHY @pytest.fixture(autouse=True) def data_() -> None: data.data_init(Mock()) + data.data.consumer_data.update({"consumer7": Consumer(7)}) + data.data.consumer_data["consumer7"].data.get.voltages = [230]*3 data.data.cp_data = { "cp3": Chargepoint(3, None), "cp4": Chargepoint(4, None), @@ -27,6 +29,7 @@ def data_() -> None: for i in range(3, 6): data.data.cp_data[f"cp{i}"].template = CpTemplate() data.data.cp_data[f"cp{i}"].data.config.phase_1 = i-2 + data.data.cp_data[f"cp{i}"].data.config.ev = i data.data.cp_data[f"cp{i}"].data.set.charging_ev_data = Ev(i) data.data.cp_data[f"cp{i}"].data.set.charging_ev_data.ev_template.data.max_current_single_phase = 32 data.data.cp_data[f"cp{i}"].data.get.plug_state = True @@ -49,10 +52,27 @@ def data_() -> None: data.data.counter_data["counter6"].data.config.max_currents = [16]*3 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.hierarchy = [{"id": 0, "type": "counter", + "children": [ + {"id": 3, "type": "cp", "children": []}, + {"id": 7, "type": "consumer", "children": []}, + {"id": 6, "type": "counter", + "children": [ + {"id": 4, "type": "cp", "children": []}, + {"id": 5, "type": "cp", "children": []}]}, + {"id": 1, "type": "inverter", "children": []}, + {"id": 2, "type": "bat", "children": []}]}] + 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}, {"type": "consumer", "id": 7} + ] + }] 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 + data.data.general_data.data.chargemode_config.surplus.vehicle.retry_failed_phase_switches = True @dataclass @@ -60,11 +80,13 @@ class ParamsExpectedSetCurrent: expected_current_cp3: float = 0 expected_current_cp4: float = 0 expected_current_cp5: float = 0 + expected_current_consumer7: float = 0 def assert_expected_current(params: ParamsExpectedSetCurrent): for i in range(3, 6): assert data.data.cp_data[f"cp{i}"].data.set.current == getattr(params, f"expected_current_cp{i}") + assert data.data.consumer_data["consumer7"].data.set.current == params.expected_current_consumer7 @pytest.fixture() @@ -73,3 +95,6 @@ def all_cp_not_charging(): data.data.cp_data[f"cp{i}"].data.get.currents = [0]*3 data.data.cp_data[f"cp{i}"].data.get.charge_state = False data.data.cp_data[f"cp{i}"].data.control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED + data.data.consumer_data["consumer7"].data.get.currents = [0]*3 + data.data.consumer_data["consumer7"].data.get.charge_state = False + data.data.consumer_data["consumer7"].data.control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED diff --git a/packages/control/algorithm/integration_test/instant_charging_test.py b/packages/control/algorithm/integration_test/instant_charging_test.py index afc9b23551..e2f23cf8a0 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() @@ -22,6 +23,12 @@ def all_cp_instant_charging_1p(): control_parameter.required_current = 16 control_parameter.chargemode = Chargemode.INSTANT_CHARGING control_parameter.submode = Chargemode.INSTANT_CHARGING + control_parameter = data.data.consumer_data["consumer7"].data.control_parameter + control_parameter.min_current = 0.5 + control_parameter.required_current = 0.5 + control_parameter.required_currents = [0.5, 0, 0] + control_parameter.chargemode = Chargemode.INSTANT_CHARGING + control_parameter.submode = Chargemode.INSTANT_CHARGING @pytest.fixture() @@ -43,6 +50,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 +69,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() @@ -72,8 +86,9 @@ def test_start_instant_charging(all_cp_instant_charging_1p, all_cp_not_charging, assert data.data.cp_data["cp3"].data.set.current == 10 assert data.data.cp_data["cp4"].data.set.current == 6 assert data.data.cp_data["cp5"].data.set.current == 6 - assert data.data.counter_data["counter0"].data.set.raw_power_left == 16250 - assert data.data.counter_data["counter0"].data.set.raw_currents_left == [22, 24, 25] + assert data.data.consumer_data["consumer7"].data.set.current == 0.5 + assert data.data.counter_data["counter0"].data.set.raw_power_left == 16135 + assert data.data.counter_data["counter0"].data.set.raw_currents_left == [21.5, 24, 25] assert data.data.counter_data["counter6"].data.set.raw_currents_left == [16, 6, 8] @@ -93,9 +108,10 @@ class ParamsLimit(ParamsExpectedSetCurrent, ParamsExpectedCounterSet): raw_currents_left_counter6=[16, 12, 14], expected_state_str=(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" f"{LimitingValue.CURRENT.value.format('Garage')}"), - expected_current_cp3=14, + expected_current_cp3=13.5, expected_current_cp4=12, expected_current_cp5=14, + expected_current_consumer7=0.5, expected_raw_power_left=12110, expected_raw_currents_left_counter0=[0, 18, 17], expected_raw_currents_left_counter6=[16, 0, 0]), @@ -105,12 +121,14 @@ class ParamsLimit(ParamsExpectedSetCurrent, ParamsExpectedCounterSet): raw_currents_left_counter6=[16]*3, expected_state_str=(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" f"{LimitingValue.POWER.value.format('Garage')}"), - expected_current_cp3=10.461538461538462, - expected_current_cp4=6.769230769230769, - expected_current_cp5=6.769230769230769, + expected_current_cp3=10.346153846153847, + expected_current_cp4=6.576923076923077, + expected_current_cp5=6.576923076923077, + expected_current_consumer7=0.5, 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]), + expected_raw_currents_left_counter0=[21.153846153846153, 25.423076923076923, 25.423076923076923], + expected_raw_currents_left_counter6=[16, 9.423076923076923, 9.423076923076923]), + # limit by unbalanced load ] @@ -128,6 +146,7 @@ def test_instant_charging_limit(params: ParamsLimit, all_cp_instant_charging_1p, # evaluation assert_expected_current(params) + assert data.data.consumer_data["consumer7"].data.set.current == 0.5 for i in range(3, 6): assert data.data.cp_data[ f"cp{i}"].data.get.state_str.replace("\n", "") == params.expected_state_str.replace("\n", "") @@ -137,19 +156,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 +180,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..8e53d7c591 100644 --- a/packages/control/algorithm/integration_test/pv_charging_test.py +++ b/packages/control/algorithm/integration_test/pv_charging_test.py @@ -25,6 +25,14 @@ def all_cp_pv_charging_3p(): control_parameter.chargemode = Chargemode.PV_CHARGING control_parameter.submode = Chargemode.PV_CHARGING control_parameter.phases = 3 + control_parameter = data.data.consumer_data["consumer7"].data.control_parameter + control_parameter.min_current = 0.5 + control_parameter.required_current = 0.5 + control_parameter.required_currents = [0.5, 0, 0] + control_parameter.phases = 1 + control_parameter.chargemode = Chargemode.PV_CHARGING + control_parameter.submode = Chargemode.PV_CHARGING + control_parameter.state = ChargepointState.CHARGING_ALLOWED @pytest.fixture() @@ -35,6 +43,10 @@ def all_cp_charging_3p(): data.data.cp_data["cp3"].data.get.power = 6900 data.data.cp_data["cp4"].data.get.power = 5520 data.data.cp_data["cp5"].data.get.power = 5520 + data.data.consumer_data["consumer7"].data.get.charge_state = True + data.data.consumer_data["consumer7"].data.get.currents = [0.5, 0, 0] + data.data.consumer_data["consumer7"].data.get.power = 115 + data.data.consumer_data["consumer7"].data.set.current = 0.5 for i in range(3, 6): charging_ev_data = data.data.cp_data[f"cp{i}"].data.set.charging_ev_data @@ -115,7 +127,7 @@ def assert_counter_set(params: ParamsExpectedCounterSet): def test_start_pv_delay(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatch): # alle 3 im PV-laden, keine Ladung -> bei zwei die Verzögerung starten, für den 3. reicht es nicht # setup - data.data.counter_data["counter0"].data.set.raw_power_left = 31975 + data.data.counter_data["counter0"].data.set.raw_power_left = 32090 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] data.data.counter_data["counter0"].data.set.reserved_surplus = 0 @@ -126,15 +138,18 @@ def test_start_pv_delay(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatch) # evaluation for i in range(3, 6): assert data.data.cp_data[f"cp{i}"].data.set.current == 0 + assert data.data.consumer_data["consumer7"].data.set.current == 0 assert data.data.cp_data[ "cp3"].data.control_parameter.timestamp_switch_on_off is None assert data.data.cp_data[ "cp4"].data.control_parameter.timestamp_switch_on_off == 1652683252.0 assert data.data.cp_data[ "cp5"].data.control_parameter.timestamp_switch_on_off == 1652683252.0 - assert data.data.counter_data["counter0"].data.set.raw_power_left == 31975 + assert data.data.consumer_data[ + "consumer7"].data.control_parameter.timestamp_switch_on_off == 1652683252.0 + assert data.data.counter_data["counter0"].data.set.raw_power_left == 32090 assert data.data.counter_data["counter0"].data.set.surplus_power_left == -690 - assert data.data.counter_data["counter0"].data.set.reserved_surplus == 9000 + assert data.data.counter_data["counter0"].data.set.reserved_surplus == 9115 def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatch): @@ -143,7 +158,7 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc data.data.counter_data["counter0"].data.set.raw_power_left = 31200 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] - data.data.counter_data["counter0"].data.set.reserved_surplus = 9000 + data.data.counter_data["counter0"].data.set.reserved_surplus = 9115 data.data.cp_data[ "cp3"].data.control_parameter.timestamp_switch_on_off = 1652683185.0 data.data.cp_data[ @@ -155,6 +170,10 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc "cp4"].data.control_parameter.state = ChargepointState.SWITCH_ON_DELAY data.data.cp_data[ "cp5"].data.control_parameter.timestamp_switch_on_off = None + data.data.consumer_data[ + "consumer7"].data.control_parameter.timestamp_switch_on_off = 1652683185.0 + data.data.consumer_data[ + "consumer7"].data.control_parameter.state = ChargepointState.SWITCH_ON_DELAY # execution Algorithm().calc_current() @@ -169,7 +188,7 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc "cp4"].data.control_parameter.timestamp_switch_on_off is None assert data.data.cp_data[ "cp5"].data.control_parameter.timestamp_switch_on_off is None - assert data.data.counter_data["counter0"].data.set.raw_power_left == 24300 + assert data.data.counter_data["counter0"].data.set.raw_power_left == 24185 assert data.data.counter_data["counter0"].data.set.surplus_power_left == -690 assert data.data.counter_data["counter0"].data.set.reserved_surplus == 0 @@ -180,10 +199,11 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc raw_power_left=50000, raw_currents_left_counter0=[40]*3, raw_currents_left_counter6=[16]*3, - expected_current_cp3=16, + expected_current_cp3=14.375, expected_current_cp4=8, expected_current_cp5=8, - expected_raw_power_left=34820, + expected_current_consumer7=5.5, + expected_raw_power_left=34705, expected_surplus_power_left=1090, expected_reserved_surplus=0, expected_released_surplus=0), @@ -192,10 +212,11 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc raw_power_left=39650, raw_currents_left_counter0=[40]*3, raw_currents_left_counter6=[16]*3, - expected_current_cp3=11.248792270531402, - expected_current_cp4=7.248792270531401, - expected_current_cp5=7.2487922705314, - expected_raw_power_left=24470, + expected_current_cp3=10.878199918732223, + expected_current_cp4=6.8827997554740445, + expected_current_cp5=7.201405373552686, + expected_current_consumer7=2.3519152915057484, + expected_raw_power_left=24355, expected_surplus_power_left=1090, expected_reserved_surplus=0, expected_released_surplus=0), @@ -207,7 +228,8 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc expected_current_cp3=10, expected_current_cp4=6, expected_current_cp5=6, - expected_raw_power_left=5635, + expected_current_consumer7=0.5, + expected_raw_power_left=5520.0, expected_surplus_power_left=-8200, expected_reserved_surplus=0, expected_released_surplus=11040), @@ -246,7 +268,8 @@ def test_surplus(params: ParamsSurplus, all_cp_pv_charging_3p, all_cp_charging_3 expected_current_cp3=10, expected_current_cp4=6, expected_current_cp5=6, - expected_raw_power_left=17400, + expected_current_consumer7=0.5, + expected_raw_power_left=17285, expected_surplus_power_left=-690, expected_reserved_surplus=0, expected_released_surplus=0), @@ -260,6 +283,7 @@ def test_surplus(params: ParamsSurplus, all_cp_pv_charging_3p, all_cp_charging_3 expected_current_cp3=32, expected_current_cp4=6, expected_current_cp5=6, + expected_current_consumer7=0.5, expected_raw_power_left=37520.0, expected_surplus_power_left=3000, expected_reserved_surplus=0, @@ -289,7 +313,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 +330,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..50f789dd0c 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,43 @@ 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: + if len(preferenced_loads) > 0: + 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..518890bcaf 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,85 @@ 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}")) + self._set(preferenced_loads_groups, 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]], 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: + if len(loads) > 0: + 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 + ) + # 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: + surplus_config = data.data.general_data.data.chargemode_config.surplus + 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 ((surplus_config.control_range[1] - surplus_config.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.surplus.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.surplus.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 +132,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 +152,72 @@ 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 - - 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 - - control_parameter.required_currents = [ - max_current if required_currents[i] != 0 else 0 for i in range(3)] - control_parameter.required_current = max_current + control_parameter = load.data.control_parameter + if control_parameter.required_current != 0: + required_currents = control_parameter.required_currents + if isinstance(load, Chargepoint): + charging_ev_data = load.data.set.charging_ev_data + + 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 / \ + voltages_mean(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 + (isinstance(load, Chargepoint) and 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..3c537130b8 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,9 +12,15 @@ 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 +@pytest.fixture(autouse=True) +def init_data_store(): + data.data_init(Mock()) + + @pytest.fixture(autouse=True) def mock_cp1() -> Chargepoint: return Chargepoint(1, None) @@ -30,27 +36,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", @@ -77,19 +66,22 @@ def test_limit_adjust_current(new_current: float, expected_current: float, monke pytest.param(1, [10, 0, 0], [16, 0, 0]), pytest.param(1, [0, 15, 0], [0, 16, 0]), pytest.param(3, [10]*3, [16]*3), + pytest.param(3, [0]*3, [0]*3), ]) 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), control_parameter=ControlParameter(phases=phases, + required_current=max(required_currents), 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 +126,35 @@ def test_add_unused_evse_current(evse_current: float, @pytest.mark.parametrize( - "submode_1, submode_2, expected_chargepoints", + "submode_1, submode_2, expected_cp_keys", [ - 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, ["cp1", "cp2"]), + pytest.param(Chargemode.INSTANT_CHARGING, Chargemode.PV_CHARGING, ["cp2"]), 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_keys: List[str], + mock_cp1: Chargepoint, + mock_cp2: Chargepoint): # setup def setup_cp(cp: Chargepoint, submode: str) -> Chargepoint: - cp.data.set.charging_ev_data = Ev(0) + cp.data = ChargepointData() + 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), + data.data.cp_data = {"cp1": setup_cp(mock_cp1, submode_1), "cp2": setup_cp(mock_cp2, submode_2)} + expected_chargepoints = [data.data.cp_data[key] for key in expected_cp_keys] + 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/auto_phase_switch_test.py b/packages/control/auto_phase_switch_test.py index 1c2a887b2c..d69eebad74 100644 --- a/packages/control/auto_phase_switch_test.py +++ b/packages/control/auto_phase_switch_test.py @@ -6,7 +6,6 @@ from control.counter import Counter, CounterData, Set from control.limiting_value import LimitingValue, LoadmanagementLimit -from control.ev.charge_template import ChargeTemplate from control.pv_all import PvAll from control.bat_all import BatAll from control.general import General @@ -135,8 +134,7 @@ def test_auto_phase_switch(monkeypatch: pytest.MonkeyPatch, vehicle: Ev, params: control_parameter.state = params.state # execution - phases_to_use, current, message = vehicle.auto_phase_switch(ChargeTemplate(), - control_parameter, + phases_to_use, current, message = vehicle.auto_phase_switch(control_parameter, 0, max(params.get_currents), params.get_currents, diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index d553be7509..ba0b0c86b2 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 helpermodules.constants import NO_ERROR from modules.common.abstract_device import AbstractDevice from modules.common.component_context import SingleComponentUpdateContext @@ -75,12 +73,12 @@ class CurrentState(Enum): class Config: configured: bool = field(default=False, metadata={"topic": "config/configured"}) bat_control_activated: bool = field(default=False, metadata={"topic": "config/bat_control_activated"}) - power_limit_mode: str = field(default=BatPowerLimitMode.MODE_NO_DISCHARGE.value, - metadata={"topic": "config/power_limit_mode"}) - power_limit_condition: str = field(default=BatPowerLimitCondition.VEHICLE_CHARGING.value, - metadata={"topic": "config/power_limit_condition"}) - manual_mode: str = field(default=ManualMode.MANUAL_DISABLE.value, - metadata={"topic": "config/manual_mode"}) + power_limit_mode: BatPowerLimitMode = field(default=BatPowerLimitMode.MODE_NO_DISCHARGE, + metadata={"topic": "config/power_limit_mode"}) + power_limit_condition: BatPowerLimitCondition = field(default=BatPowerLimitCondition.VEHICLE_CHARGING, + metadata={"topic": "config/power_limit_condition"}) + manual_mode: ManualMode = field(default=ManualMode.MANUAL_DISABLE, + metadata={"topic": "config/manual_mode"}) bat_control_min_soc: int = field(default=10, metadata={"topic": "config/bat_control_min_soc"}) bat_control_max_soc: int = field(default=90, metadata={"topic": "config/bat_control_max_soc"}) price_limit_activated: bool = field(default=False, metadata={"topic": "config/price_limit_activated"}) @@ -116,7 +114,7 @@ class Set: power_limit: Optional[float] = field(default=None, metadata={"topic": "set/power_limit"}) regulate_up: bool = field(default=False, metadata={"topic": "set/regulate_up"}) hysteresis_discharge: bool = field(default=False, metadata={"topic": "set/hysteresis_discharge"}) - current_state: str = field(default=CurrentState.STARTUP.value, metadata={"topic": "set/current_state"}) + current_state: CurrentState = field(default=CurrentState.STARTUP, metadata={"topic": "set/current_state"}) set_limit: bool = False @@ -325,10 +323,10 @@ def get_charging_power_left_diff(self): die zum Laden der EV verwendet werden darf. """ try: - config = data.data.general_data.data.chargemode_config.pv_charging + config = data.data.general_data.data.chargemode_config.bat self.data.set.regulate_up = False - if config.bat_mode == BatConsiderationMode.BAT_MODE.value: + if config.mode == BatConsiderationMode.BAT_MODE: if self.data.get.power < 0: # Wenn der Speicher entladen wird, darf diese Leistung nicht zum Laden der Fahrzeuge genutzt werden. # Wenn der Speicher schneller regelt als die LP, würde sonst der Speicher reduziert werden. @@ -337,19 +335,19 @@ def get_charging_power_left_diff(self): charging_power_left = 0 self.data.set.regulate_up = True if self.data.get.soc < 100 else False # ev wird nach Speicher geladen - elif config.bat_mode == BatConsiderationMode.EV_MODE.value: + elif config.mode == BatConsiderationMode.EV_MODE: # Speicher sollte weder ge- noch entladen werden. # wenn aktive Speichersteuerung in Höhe PV-Leistung lädt # hat Speicher Priorität vor EV-Ladung if (self.data.config.bat_control_activated and - self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value): + self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION): charging_power_left = 0 else: charging_power_left = self.data.get.power else: absolute_bat_discharge_power = self._absolute_bat_discharge_power() # Speicher soll geladen werden um min SoC zu erreichen - if self.data.get.soc < config.min_bat_soc: + if self.data.get.soc < config.min_soc: self.data.set.hysteresis_discharge = False if self.data.get.power < 0: # Wenn der Speicher entladen wird, darf diese Leistung nicht zum Laden der Fahrzeuge @@ -359,10 +357,10 @@ def get_charging_power_left_diff(self): self.data.set.regulate_up = True else: # Speicher-Vorrang bis zum Min-Soc - if config.bat_power_reserve_active: + if config.power_reserve_active: # Die Differenz zwischen aktueller Batterie-Leistung und Reserveleistung bestimmt, # was fuer EV-Ladung verbleibt (positiv) oder zusaetzlich benoetigt wird (negativ). - charging_power_left = self.data.get.power - config.bat_power_reserve + charging_power_left = self.data.get.power - config.power_reserve if charging_power_left < 0: self.data.set.regulate_up = True else: @@ -370,18 +368,18 @@ def get_charging_power_left_diff(self): charging_power_left = 0 self.data.set.regulate_up = True # Speicher zwischen min und max SoC - elif int(self.data.get.soc) >= config.min_bat_soc and int(self.data.get.soc) < config.max_bat_soc: + elif int(self.data.get.soc) >= config.min_soc and int(self.data.get.soc) < config.max_soc: # Speicher soll aktiv weder ge- noch entladen werden. # Mindest-SoC wird gehalten oder der Speicher mit weiterem vorhanden Überschuss geladen. if self.data.set.hysteresis_discharge is False: charging_power_left = self.data.get.power - # Speicher darf wegen Hysterese bis min_bat_soc entladen werden. + # Speicher darf wegen Hysterese bis min_soc entladen werden. else: if self.data.set.power_limit is None: # set allowed power if (self.data.config.bat_control_activated and self.data.config.power_limit_mode == - BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value): + BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION): base_power = 0 else: base_power = self.data.get.power @@ -391,12 +389,12 @@ def get_charging_power_left_diff(self): # -> dann erlaubte Speicherentladeleistung addieren power_discharge_allowed = (self.data.config.bat_control_activated is False or (self.data.config.power_limit_condition == - BatPowerLimitCondition.PRICE_LIMIT.value and + BatPowerLimitCondition.PRICE_LIMIT and self.data.set.power_limit is None)) - if config.bat_power_discharge_active and power_discharge_allowed: + if config.power_discharge_active and power_discharge_allowed: # max Entladeleistung auf max Ausgangsleistung des WR begrenzen required_absolut_discharge_power = min( - config.bat_power_discharge, absolute_bat_discharge_power) + config.power_discharge, absolute_bat_discharge_power) # Differenz zwischen erlaubter Entladeleistung und aktueller Speicherleistung bestimmen. # Wenn der Speicher lädt, darf die freigegebene Entladeleistung nicht die max # Ausgangsleistung des WR überschreiten. @@ -418,7 +416,7 @@ def get_charging_power_left_diff(self): if self.data.set.power_limit is None: # set allowed power if (self.data.config.bat_control_activated and - self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value): + self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION): base_power = 0 else: base_power = self.data.get.power @@ -428,12 +426,12 @@ def get_charging_power_left_diff(self): # -> dann erlaubte Speicherentladeleistung addieren power_discharge_allowed = (self.data.config.bat_control_activated is False or (self.data.config.power_limit_condition == - BatPowerLimitCondition.PRICE_LIMIT.value and + BatPowerLimitCondition.PRICE_LIMIT and self.data.set.power_limit is None)) - if config.bat_power_discharge_active and power_discharge_allowed: + if config.power_discharge_active and power_discharge_allowed: # max Entladeleistung auf max Ausgangsleistung des WR begrenzen required_absolut_discharge_power = min( - config.bat_power_discharge, absolute_bat_discharge_power) + config.power_discharge, absolute_bat_discharge_power) # Differenz zwischen erlaubter Entladeleistung und aktueller Speicherleistung bestimmen. # Wenn der Speicher lädt, darf die freigegebene Entladeleistung nicht die max # Ausgangsleistung des WR überschreiten. @@ -490,18 +488,15 @@ 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 - self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value)) + self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION)) # EVU Bezug vorhanden oder gewollte PV-Ladung aktiv evu_power_valid = (data.data.counter_all_data.get_evu_counter().data.get.power >= -100 or - self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value) + self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION) if ( vehicle_charging and @@ -514,12 +509,10 @@ def get_charge_mode_vehicle_charge(self): charge_mode = BatChargeMode.BAT_SELF_REGULATION # Debug Informationen - 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_low = data.data.general_data.data.chargemode_config.surplus.control_range[0] + control_range_high = data.data.general_data.data.chargemode_config.surplus.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.") @@ -535,11 +528,11 @@ def get_charge_mode_manual_charge(self): # EVU Bezug vorhanden oder gewollte PV-Ladung aktiv evu_power_valid = data.data.counter_all_data.get_evu_counter().data.get.power >= -100 bat_power_valid = self.data.get.power <= 0 - if self.data.config.manual_mode == ManualMode.MANUAL_CHARGE.value: + if self.data.config.manual_mode == ManualMode.MANUAL_CHARGE: log.debug("Aktive Speichersteuerung: Manueller Modus - Speicher laden.") return BatChargeMode.BAT_FORCE_CHARGE - elif self.data.config.manual_mode == ManualMode.MANUAL_LIMIT.value: - if (self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value or + elif self.data.config.manual_mode == ManualMode.MANUAL_LIMIT: + if (self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION or (evu_power_valid and bat_power_valid)): # Limit anwenden wenn kein Überschuss vorhanden oder der Speicher nicht lädt log.debug("Aktive Speichersteuerung: Manueller Modus - Regellimit anwenden.") @@ -547,7 +540,7 @@ def get_charge_mode_manual_charge(self): else: log.debug("Aktive Speichersteuerung: Manueller Modus - Kein Limit da Speicher lädt") return BatChargeMode.BAT_SELF_REGULATION - elif self.data.config.manual_mode == ManualMode.MANUAL_DISCHARGE.value: + elif self.data.config.manual_mode == ManualMode.MANUAL_DISCHARGE: log.debug("Aktive Speichersteuerung: Manueller Modus - " "Eigenregelung da aktive Speicherentladung nicht erlaubt ist.") return BatChargeMode.BAT_SELF_REGULATION @@ -589,16 +582,16 @@ def get_power_limit(self): log.debug("Speicher-Leistung nicht begrenzen, da aktive Speichersteuerung deaktiviert ist.") else: charge_mode = BatChargeMode.BAT_SELF_REGULATION - if self.data.config.power_limit_condition == BatPowerLimitCondition.MANUAL.value: + if self.data.config.power_limit_condition == BatPowerLimitCondition.MANUAL: log.debug("Aktive Speichersteuerung: Manueller Modus.") charge_mode = self.get_charge_mode_manual_charge() - elif self.data.config.power_limit_condition == BatPowerLimitCondition.VEHICLE_CHARGING.value: + elif self.data.config.power_limit_condition == BatPowerLimitCondition.VEHICLE_CHARGING: log.debug("Aktive Speichersteuerung: Wenn Fahrzeuge laden.") charge_mode = self.get_charge_mode_vehicle_charge() - elif self.data.config.power_limit_condition == BatPowerLimitCondition.PRICE_LIMIT.value: + elif self.data.config.power_limit_condition == BatPowerLimitCondition.PRICE_LIMIT: log.debug("Aktive Speichersteuerung: Strompreisbasiert.") charge_mode = self.get_charge_mode_electricity_tariff() - elif self.data.config.power_limit_condition == BatPowerLimitCondition.SCHEDULED.value: + elif self.data.config.power_limit_condition == BatPowerLimitCondition.SCHEDULED: log.debug("Aktive Speichersteuerung: Vorhersagebasiertes Zielladen.") charge_mode = self.get_charge_mode_scheduled() @@ -608,13 +601,13 @@ def get_power_limit(self): self.data.set.power_limit = None log.debug("Speicher-Leistung nicht begrenzen") elif charge_mode == BatChargeMode.BAT_USE_LIMIT: - if self.data.config.power_limit_mode == BatPowerLimitMode.MODE_NO_DISCHARGE.value: + if self.data.config.power_limit_mode == BatPowerLimitMode.MODE_NO_DISCHARGE: self.data.set.power_limit = 0 log.debug("Speicher-Leistung begrenzen auf 0kW") - elif self.data.config.power_limit_mode == BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION.value: + elif self.data.config.power_limit_mode == BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION: self.data.set.power_limit = data.data.counter_all_data.data.set.home_consumption * -1 log.debug(f"Speicher-Leistung begrenzen auf {self.data.set.power_limit/1000}kW") - elif self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value: + elif self.data.config.power_limit_mode == BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION: # PV-Überschuss abzüglich Hausverbrauch als Ladeleistung des Speichers nutzen. # Bei geringem Überschuss wird Hausverbrauch durch Speicher ausgeglichen pv_power = min(data.data.pv_all_data.data.get.power, 0) @@ -639,27 +632,27 @@ def get_power_limit(self): log.debug("Speicher-Leistung nicht begrenzen") if (self.data.config.bat_control_activated is False - and self.data.set.current_state == CurrentState.STARTUP.value): + and self.data.set.current_state == CurrentState.STARTUP): self.data.set.set_limit = False - elif (self.data.set.current_state == CurrentState.IDLE.value and + elif (self.data.set.current_state == CurrentState.IDLE and charge_mode == BatChargeMode.BAT_SELF_REGULATION): self.data.set.set_limit = False else: self.data.set.set_limit = True if charge_mode == BatChargeMode.BAT_SELF_REGULATION: - self.data.set.current_state = CurrentState.IDLE.value + self.data.set.current_state = CurrentState.IDLE else: - self.data.set.current_state = CurrentState.ACTIVE.value + self.data.set.current_state = CurrentState.ACTIVE def time_charging_min_bat_soc_allowed(self) -> bool: if self.data.config.configured and self.data.config.bat_control_activated: # manueller Modus und keine Eigenregelung oder aktive Speichersteuerung bei Fahrzeugladung - if ((self.data.config.power_limit_condition == BatPowerLimitCondition.MANUAL.value and - self.data.config.manual_mode != ManualMode.MANUAL_DISABLE.value) or - self.data.config.power_limit_condition == BatPowerLimitCondition.VEHICLE_CHARGING.value): + if ((self.data.config.power_limit_condition == BatPowerLimitCondition.MANUAL and + self.data.config.manual_mode != ManualMode.MANUAL_DISABLE) or + self.data.config.power_limit_condition == BatPowerLimitCondition.VEHICLE_CHARGING): return False - elif self.data.config.power_limit_condition == BatPowerLimitCondition.PRICE_LIMIT.value: + elif self.data.config.power_limit_condition == BatPowerLimitCondition.PRICE_LIMIT: if ((self.data.config.price_limit_activated and data.data.optional_data.ep_is_charging_allowed_price_threshold( self.data.config.price_limit)) diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index 38863ccb92..78090986fc 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 @@ -10,7 +10,7 @@ from control import data from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_all import AllChargepointData, AllChargepoints, AllGet -from control.general import General, PvCharging +from control.general import ChargemodeConfigBat, General from control.pv import Config, Get, Pv, PvData from modules.devices.generic.mqtt.bat import MqttBat from modules.devices.generic.mqtt.config import MqttBatSetup @@ -44,9 +44,9 @@ def test_get_charging_power_left_diff_hybrid(bat_power: int, data.data.bat_data["bat1"] = Bat(1) data.data.bat_data["bat1"].data.get.power = bat_power data.data.bat_data["bat1"].data.get.soc = 71 - data.data.general_data.data.chargemode_config.pv_charging.bat_mode = BatConsiderationMode.MIN_SOC_BAT.value - data.data.general_data.data.chargemode_config.pv_charging.bat_power_discharge = 5000 - data.data.general_data.data.chargemode_config.pv_charging.bat_power_discharge_active = True + data.data.general_data.data.chargemode_config.bat.mode = BatConsiderationMode.MIN_SOC_BAT + data.data.general_data.data.chargemode_config.bat.power_discharge = 5000 + data.data.general_data.data.chargemode_config.bat.power_discharge_active = True monkeypatch.setattr(data.data.counter_all_data, "get_hybrid_bat_ids", Mock(return_value=[1])) monkeypatch.setattr(data.data.counter_all_data, "get_non_hybrid_bat_ids", Mock(return_value=[])) monkeypatch.setattr(data.data.counter_all_data, "get_hybrid_inverter_ids", Mock(return_value=[2])) @@ -99,74 +99,81 @@ def test__absolute_bat_discharge_power(hybrid_bat_ids: List[int], @dataclass class Params: name: str - config: PvCharging + config: ChargemodeConfigBat power: float soc: float expected_charging_power_left: float expected_regulate_up: bool power_limit: Optional[float] = None - hysteresis_discharge: Optional[bool] = False + hysteresis_discharge: bool = False cases = [ - Params("Speicher, Speicher lädt", PvCharging(bat_mode="bat_mode"), 500, 90, -100, True), - Params("Speicher, Speicher entlädt", PvCharging(bat_mode="bat_mode"), -500, 90, -600, True), - Params("Speicher, Speicher ist voll", PvCharging(bat_mode="bat_mode"), 0, 100, 0, False), - Params("EV, Speicher lädt", PvCharging(bat_mode="ev_mode"), 500, 90, 500, False), - Params("EV, Speicher entlädt", PvCharging(bat_mode="ev_mode"), -500, 90, -500, False), - Params("EV, Speicher ist voll", PvCharging(bat_mode="ev_mode"), 0, 100, 0, False), + Params("Speicher, Speicher lädt", ChargemodeConfigBat(mode=BatConsiderationMode.BAT_MODE), 500, 90, -100, True), + Params("Speicher, Speicher entlädt", + ChargemodeConfigBat(mode=BatConsiderationMode.BAT_MODE), -500, 90, -600, True), + Params("Speicher, Speicher ist voll", ChargemodeConfigBat(mode=BatConsiderationMode.BAT_MODE), 0, 100, 0, False), + Params("EV, Speicher lädt", ChargemodeConfigBat(mode=BatConsiderationMode.EV_MODE), 500, 90, 500, False), + Params("EV, Speicher entlädt", ChargemodeConfigBat(mode=BatConsiderationMode.EV_MODE), -500, 90, -500, False), + Params("EV, Speicher ist voll", ChargemodeConfigBat(mode=BatConsiderationMode.EV_MODE), 0, 100, 0, False), Params("Mindest-SoC, SoC nicht erreicht, Speicher entlädt", - PvCharging(bat_mode="min_soc_bat_mode"), -500, 40, -600, True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT), -500, 40, -600, True), Params("Mindest-SoC, SoC nicht erreicht, Speicher lädt", - PvCharging(bat_mode="min_soc_bat_mode"), 500, 40, -100, True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT), 500, 40, -100, True), Params("Mindest-SoC, SoC nicht erreicht, Speicher-Reserve, Speicher entlädt", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_reserve=2000, bat_power_reserve_active=True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, power_reserve=2000, power_reserve_active=True), -500, 40, -600, True), Params("Mindest-SoC, SoC nicht erreicht, Speicher-Reserve nicht ausgenutzt, Speicher lädt", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_reserve=2000, bat_power_reserve_active=True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, power_reserve=2000, power_reserve_active=True), 1600, 40, -500, True), Params("Mindest-SoC, SoC nicht erreicht, Speicher-Reserve ausgenutzt, Speicher lädt", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_reserve=2000, bat_power_reserve_active=True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, power_reserve=2000, power_reserve_active=True), 2200, 40, 200, False), - Params("Mindest-SoC, SoC erreicht, Speicher entlädt", PvCharging(bat_mode="min_soc_bat_mode"), -500, 90, -500, - False), - Params("Mindest-SoC, SoC erreicht, Speicher lädt", PvCharging(bat_mode="min_soc_bat_mode"), 500, 90, 500, False), - Params("Mindest-SoC, SoC erreicht, Speicher ist voll", PvCharging(bat_mode="min_soc_bat_mode"), 0, 100, 0, False), + Params("Mindest-SoC, SoC erreicht, Speicher entlädt", ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT), + -500, 90, -500, False), + Params("Mindest-SoC, SoC erreicht, Speicher lädt", + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT), 500, 90, 500, False), + Params("Mindest-SoC, SoC erreicht, Speicher ist voll", + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT), 0, 100, 0, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher entlädt, Entladeleistung nicht erreicht", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, power_discharge=500, power_discharge_active=True), -400, 90, 100, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher entlädt, mehr als Entladeleistung", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, power_discharge=500, power_discharge_active=True), -600, 90, -100, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher entlädt, Entladeleistung erreicht", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, power_discharge=500, power_discharge_active=True), -500, 90, 0, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher lädt mit mehr als Entladeleistung", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, power_discharge=500, power_discharge_active=True), 650, 90, 1150, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher lädt mit weniger als Entladeleistung", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, power_discharge=500, power_discharge_active=True), 400, 90, 900, False), Params("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher voll", - PvCharging(bat_mode="min_soc_bat_mode", bat_power_reserve=500, bat_power_reserve_active=True, - min_bat_soc=100), 0, 100, 0, False), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, power_reserve=500, power_reserve_active=True, + min_soc=100), 0, 100, 0, False), Params(("Mindest-SoC, SoC erreicht, Entladung in Auto, Speicher lädt mit weniger als Entladeleistung, " "Speicher-Sperre aktiv"), - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, + power_discharge=500, power_discharge_active=True), 400, 90, 0, False, 600), Params(("Mindest-SoC, Hysterese, EV-Vorrang, keine Speichernutzung"), - PvCharging(bat_mode="min_soc_bat_mode"), 400, 60, 400, False, hysteresis_discharge=False), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT), + 400, 60, 400, False, hysteresis_discharge=False), Params(("Mindest-SoC, Hysterese, Speicherentladung, Speichernutzung erlaubt"), - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, + power_discharge=500, power_discharge_active=True), 400, 60, 900, False, hysteresis_discharge=True), Params(("Mindest-SoC, Hysterese, Speicherentladung, Speichernutzung erlaubt, Speicher-Sperre aktiv"), - PvCharging(bat_mode="min_soc_bat_mode", bat_power_discharge=500, bat_power_discharge_active=True), + ChargemodeConfigBat(mode=BatConsiderationMode.MIN_SOC_BAT, + power_discharge=500, power_discharge_active=True), 400, 60, 0, False, 600, hysteresis_discharge=True), ] @pytest.mark.parametrize("params", cases, ids=[c.name for c in cases]) -def test_get_charging_power_left(params: Params, caplog, data_, monkeypatch): +def test_get_charging_power_left(params: Params, data_, monkeypatch: pytest.MonkeyPatch): # setup b_all = BatAll() b_all.data.get.power = params.power @@ -176,7 +183,7 @@ def test_get_charging_power_left(params: Params, caplog, data_, monkeypatch): b = Bat(0) b.data.get.power = params.power data.data.bat_data["bat0"] = b - data.data.general_data.data.chargemode_config.pv_charging = params.config + data.data.general_data.data.chargemode_config.bat = params.config mock_absolute_bat_discharge_power = MagicMock(return_value=10000) monkeypatch.setattr(BatAll, "_absolute_bat_discharge_power", mock_absolute_bat_discharge_power) @@ -196,12 +203,12 @@ def test_get_charging_power_left_uses_limited_bat_discharge_in_hysteresis( b_all.data.get.soc = 60 b_all.data.set.hysteresis_discharge = True b_all.data.set.power_limit = None - data.data.general_data.data.chargemode_config.pv_charging = PvCharging( - bat_mode="min_soc_bat_mode", - min_bat_soc=40, - max_bat_soc=80, - bat_power_discharge=8000, - bat_power_discharge_active=True, + data.data.general_data.data.chargemode_config.bat = ChargemodeConfigBat( + mode=BatConsiderationMode.MIN_SOC_BAT, + min_soc=40, + max_soc=80, + power_discharge=8000, + power_discharge_active=True, ) # Hybrid-Setup fuer reale Berechnung in _limit_bat_power_discharge @@ -232,21 +239,19 @@ def default_chargepoint_factory() -> List[Chargepoint]: class BatControlParams: name: str expected_power_limit_bat: Optional[float] - 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_mode: BatPowerLimitMode = BatPowerLimitMode.MODE_NO_DISCHARGE + power_limit_condition: BatPowerLimitCondition = BatPowerLimitCondition.VEHICLE_CHARGING + bat_manual_mode: ManualMode = ManualMode.MANUAL_DISABLE power_limit_controllable: bool = True bat_power: float = -10 bat_soc: float = 50.0 evu_power: float = 200 pv_power: float = -654 - bat_control_permitted: bool = True bat_control_activated: bool = True max_charge_power: float = 5000 max_discharge_power: float = -5000 - bat_control_min_soc: float = 10.0 - bat_control_max_soc: float = 90.0 + bat_control_min_soc: int = 10 + bat_control_max_soc: int = 90 price_limit_activated: bool = False price_charge_activated: bool = False price_limit: float = 0.30 @@ -255,48 +260,46 @@ class BatControlParams: cases = [ BatControlParams("Speicher nicht regelbar", None, power_limit_controllable=False, - power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE.value), + power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE), BatControlParams("Speichersteuerung deaktiviert", None, bat_control_activated=False, - power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE.value), + power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE), # Manuelle Steuerung BatControlParams("Manuelle Steuerung, Speichersteuerung deaktiviert", None, - power_limit_condition=BatPowerLimitCondition.MANUAL.value, - bat_manual_mode=ManualMode.MANUAL_DISABLE.value), + power_limit_condition=BatPowerLimitCondition.MANUAL, + bat_manual_mode=ManualMode.MANUAL_DISABLE), BatControlParams("Manuelle Steuerung, Entladung sperren", 0, - power_limit_condition=BatPowerLimitCondition.MANUAL.value, - bat_manual_mode=ManualMode.MANUAL_LIMIT.value), + power_limit_condition=BatPowerLimitCondition.MANUAL, + bat_manual_mode=ManualMode.MANUAL_LIMIT), BatControlParams("Manuelle Steuerung, Begrenzung Hausverbrauch", -456, - power_limit_condition=BatPowerLimitCondition.MANUAL.value, - bat_manual_mode=ManualMode.MANUAL_LIMIT.value, - power_limit_mode=BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION.value), + power_limit_condition=BatPowerLimitCondition.MANUAL, + bat_manual_mode=ManualMode.MANUAL_LIMIT, + power_limit_mode=BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION), BatControlParams("Manuelle Steuerung, Ladung PV Überschuss", 198, - power_limit_condition=BatPowerLimitCondition.MANUAL.value, - bat_manual_mode=ManualMode.MANUAL_LIMIT.value, - power_limit_mode=BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value), + power_limit_condition=BatPowerLimitCondition.MANUAL, + bat_manual_mode=ManualMode.MANUAL_LIMIT, + power_limit_mode=BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION), BatControlParams("Manuelle Steuerung, Aktive Ladung", 5000, - power_limit_condition=BatPowerLimitCondition.MANUAL.value, - bat_manual_mode=ManualMode.MANUAL_CHARGE.value), + power_limit_condition=BatPowerLimitCondition.MANUAL, + bat_manual_mode=ManualMode.MANUAL_CHARGE), # 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), + power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE), BatControlParams("Fahrzeuge laden, Begrenzung immer,Einspeisung", None, evu_power=-110, - power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE.value), + power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE), BatControlParams("Fahrzeuge laden, Begrenzung immer", 0, - power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE.value), + power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE), BatControlParams("Fahrzeuge laden, Begrenzung Hausverbrauch", -456, - power_limit_mode=BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION.value), + power_limit_mode=BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION), BatControlParams("Fahrzeuge laden, Ladung PV Überschuss", 198, - power_limit_mode=BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value), + power_limit_mode=BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION), BatControlParams("Fahrzeuge laden, Ladung PV Überschuss, Eigenverbrauch PV-Anlage", -456, - power_limit_mode=BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value, + power_limit_mode=BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION, pv_power=100), ] @pytest.mark.parametrize("params", cases, ids=[c.name for c in cases]) -def test_active_bat_control(params: BatControlParams, data_, monkeypatch): +def test_active_bat_control(params: BatControlParams, data_, monkeypatch: pytest.MonkeyPatch): b_all = BatAll() b_all.data.config.bat_control_activated = params.bat_control_activated b_all.data.config.power_limit_mode = params.power_limit_mode @@ -319,9 +322,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)], [])) @@ -340,43 +340,43 @@ def test_active_bat_control(params: BatControlParams, data_, monkeypatch): cases = [ # Nach Preisgrenze BatControlParams("Preisgrenze, Grenze deaktiviert, Eigenregelung", None, - power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT.value, + power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT, price_limit_activated=False, price_limit=0.40, - power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE.value), + power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE), BatControlParams("Preisgrenze, Entladung sperren, Grenze unterschritten", 0, - power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT.value, + power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT, price_limit_activated=True, price_limit=0.30, - power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE.value), + power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE), BatControlParams("Preisgrenze, Überschuss Laden, Grenze unterschritten", 198, - power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT.value, + power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT, price_limit_activated=True, price_limit=0.30, - power_limit_mode=BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value), + power_limit_mode=BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION), BatControlParams("Preisgrenze, Entladung sperren, Grenze greift nicht", None, - power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT.value, + power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT, price_limit_activated=True, price_limit=0.10, - power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE.value), + power_limit_mode=BatPowerLimitMode.MODE_NO_DISCHARGE), # Aktive Ladung BatControlParams("Preisgrenze, Grenze deaktiviert, Eigenregelung", None, - power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT.value, + power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT, price_charge_activated=False, charge_limit=0.40), BatControlParams("Preisgrenze, Grenze unterschritten, Ladung", 5000, - power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT.value, + power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT, price_charge_activated=True, charge_limit=0.30), BatControlParams("Preisgrenze, Grenze greift nicht, Eigenregelung", None, - power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT.value, + power_limit_condition=BatPowerLimitCondition.PRICE_LIMIT, price_charge_activated=True, charge_limit=0.10), ] @pytest.mark.parametrize("params", cases, ids=[c.name for c in cases]) -def test_control_price_limit(params: BatControlParams, data_, monkeypatch): +def test_control_price_limit(params: BatControlParams, data_, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr(data.data.optional_data, "ep_get_current_price", Mock(return_value=0.2)) b_all = BatAll() b_all.data.config.bat_control_activated = params.bat_control_activated @@ -401,9 +401,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)], [])) @@ -423,57 +420,57 @@ def test_control_price_limit(params: BatControlParams, data_, monkeypatch): "control_activated, condition, limit, manual_mode, expected_result", [ pytest.param(False, - BatPowerLimitCondition.MANUAL.value, - BatPowerLimitMode.MODE_NO_DISCHARGE.value, - ManualMode.MANUAL_DISABLE.value, True, + BatPowerLimitCondition.MANUAL, + BatPowerLimitMode.MODE_NO_DISCHARGE, + ManualMode.MANUAL_DISABLE, True, id="Speichersteuerung nicht aktiviert, aber aktiviert -> laden"), pytest.param(True, - BatPowerLimitCondition.MANUAL.value, - BatPowerLimitMode.MODE_NO_DISCHARGE.value, - ManualMode.MANUAL_DISABLE.value, True, + BatPowerLimitCondition.MANUAL, + BatPowerLimitMode.MODE_NO_DISCHARGE, + ManualMode.MANUAL_DISABLE, True, id="Manuell, Eigenregelung, volle Entladesperre -> nicht laden"), pytest.param(True, - BatPowerLimitCondition.MANUAL.value, - BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION.value, - ManualMode.MANUAL_LIMIT.value, False, + BatPowerLimitCondition.MANUAL, + BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION, + ManualMode.MANUAL_LIMIT, False, id="Manuell, Entladung in Fahrzeuge sperren -> nicht laden"), pytest.param(True, - BatPowerLimitCondition.MANUAL.value, - BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value, - ManualMode.MANUAL_CHARGE.value, False, + BatPowerLimitCondition.MANUAL, + BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION, + ManualMode.MANUAL_CHARGE, False, id="Manuell, PV-Ertrag speichern -> nicht laden"), pytest.param(True, - BatPowerLimitCondition.VEHICLE_CHARGING.value, - BatPowerLimitMode.MODE_NO_DISCHARGE.value, - ManualMode.MANUAL_DISABLE.value, False, + BatPowerLimitCondition.VEHICLE_CHARGING, + BatPowerLimitMode.MODE_NO_DISCHARGE, + ManualMode.MANUAL_DISABLE, False, id="Fahrzeuge laden, volle Entladesperre -> nicht laden"), pytest.param(True, - BatPowerLimitCondition.VEHICLE_CHARGING.value, - BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION.value, - ManualMode.MANUAL_DISABLE.value, False, + BatPowerLimitCondition.VEHICLE_CHARGING, + BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION, + ManualMode.MANUAL_DISABLE, False, id="Fahrzeuge laden, Entladung in Fahrzeuge sperren -> nicht laden"), pytest.param(True, - BatPowerLimitCondition.VEHICLE_CHARGING.value, - BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value, - ManualMode.MANUAL_DISABLE.value, False, + BatPowerLimitCondition.VEHICLE_CHARGING, + BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION, + ManualMode.MANUAL_DISABLE, False, id="Fahrzeuge laden, PV-Ertrag speichern -> nicht laden"), pytest.param(True, - BatPowerLimitCondition.PRICE_LIMIT.value, - BatPowerLimitMode.MODE_NO_DISCHARGE.value, - ManualMode.MANUAL_DISABLE.value, False, + BatPowerLimitCondition.PRICE_LIMIT, + BatPowerLimitMode.MODE_NO_DISCHARGE, + ManualMode.MANUAL_DISABLE, False, id="Preislimit, volle Entladesperre -> nicht laden"), pytest.param(True, - BatPowerLimitCondition.PRICE_LIMIT.value, - BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION.value, - ManualMode.MANUAL_DISABLE.value, False, + BatPowerLimitCondition.PRICE_LIMIT, + BatPowerLimitMode.MODE_DISCHARGE_HOME_CONSUMPTION, + ManualMode.MANUAL_DISABLE, False, id="Preislimit, Entladung in Fahrzeuge sperren -> nicht laden"), ] ) def test_time_charging_min_bat_soc_allowed(control_activated: bool, - condition: str, - limit: str, - manual_mode: str, + condition: BatPowerLimitCondition, + limit: BatPowerLimitMode, + manual_mode: ManualMode, expected_result: bool): # setup b = BatAll() @@ -516,8 +513,8 @@ def test_time_charging_min_bat_soc_allowed_pricing(ep_configured: bool, # setup b = BatAll() b.data.config.configured = True - b.data.config.power_limit_condition = BatPowerLimitCondition.PRICE_LIMIT.value - b.data.config.power_limit_mode = BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION.value + b.data.config.power_limit_condition = BatPowerLimitCondition.PRICE_LIMIT + b.data.config.power_limit_mode = BatPowerLimitMode.MODE_CHARGE_PV_PRODUCTION b.data.config.price_limit_activated = price_limit_activated b.data.config.price_charge_activated = price_charge_activated data.data.optional_data.data.electricity_pricing.configured = ep_configured diff --git a/packages/control/chargelog/chargelog.py b/packages/control/chargelog/chargelog.py index dcfbbfeb3d..621dbb06d4 100644 --- a/packages/control/chargelog/chargelog.py +++ b/packages/control/chargelog/chargelog.py @@ -1,3 +1,4 @@ +from control.chargemode import Chargemode from helpermodules.utils.json_file_handler import write_and_check from helpermodules import timecheck import copy @@ -99,8 +100,8 @@ def collect_data(chargepoint): if get_value_or_default(lambda: log_data.timestamp_start_charging is None): log_data.timestamp_start_charging = now submode = get_value_or_default(lambda: chargepoint.data.control_parameter.submode, "") - if submode == "time_charging": - log_data.chargemode_log_entry = "time_charging" + if submode == Chargemode.TIME_CHARGING: + log_data.chargemode_log_entry = Chargemode.TIME_CHARGING.value else: log_data.chargemode_log_entry = get_value_or_default( lambda: chargepoint.data.control_parameter.chargemode.value) 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 8fdf400905..db95b7666c 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -1,5 +1,3 @@ -from dataclasses import asdict -import dataclasses import logging from threading import Thread, Event import traceback @@ -20,7 +18,9 @@ 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 dataclass_utils import asdict from helpermodules.constants import DEFAULT_COLORS from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase from helpermodules.pub import Pub @@ -52,7 +52,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 @@ -218,7 +218,7 @@ def _process_charge_stop(self) -> None: def setup_values_at_start(self): self._set_values_at_start() - def set_control_parameter(self, submode: str): + def set_control_parameter(self, submode: Chargemode): """ setzt die Regel-Parameter, die der Algorithmus verwendet. Parameter @@ -227,13 +227,11 @@ def set_control_parameter(self, submode: str): neuer Lademodus, in dem geladen werden soll """ try: - self.data.control_parameter.submode = Chargemode(submode) - if submode == "time_charging": + self.data.control_parameter.submode = submode + if submode == Chargemode.TIME_CHARGING: self.data.control_parameter.chargemode = Chargemode.TIME_CHARGING 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 + self.data.control_parameter.chargemode = self.data.set.charge_template.data.chargemode.selected 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: @@ -593,18 +591,10 @@ def set_required_currents(self, required_current: float) -> None: self.data.set.required_power = sum( [c * v for c, v in zip(control_parameter.required_currents, self.data.get.voltages)]) - def set_timestamp_charge_start(self): - # Beim Ladestart Timer laufen lassen, manche Fahrzeuge brauchen sehr lange. - # Nach dem Algorithmus setzen, sonst steht set current noch nicht fest. - if self.data.control_parameter.timestamp_charge_start is None: - if self.data.set.current_prev == 0 and self.data.set.current != 0: - self.data.control_parameter.timestamp_charge_start = create_timestamp() - elif self.data.set.current == 0: - self.data.control_parameter.timestamp_charge_start = None - - def set_chargemode_changed(self, submode: str) -> None: - if ((submode == "time_charging" and self.data.control_parameter.chargemode != "time_charging") or - (submode != "time_charging" and + def set_chargemode_changed(self, submode: Chargemode) -> None: + 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 != self.data.set.charge_template.data.chargemode.selected)): self.chargemode_changed = True log.debug("Änderung des Lademodus") @@ -612,7 +602,7 @@ def set_chargemode_changed(self, submode: str) -> None: else: self.chargemode_changed = False - def set_submode_changed(self, submode: str) -> None: + def set_submode_changed(self, submode: Chargemode) -> None: self.submode_changed = (submode != self.data.control_parameter.submode) def update_ev(self, ev_list: Dict[str, Ev]) -> None: @@ -696,11 +686,10 @@ 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)) - self.data.control_parameter.submode = "stop" + self.data.control_parameter.submode = Chargemode.STOP else: self._process_charge_stop() if vehicle != -1: @@ -781,8 +770,7 @@ def _get_charging_ev(self, vehicle: int, ev_list: Dict[str, Ev]) -> Ev: def update_charge_template(self, charge_template: ChargeTemplate) -> None: # Prüfen, ob ein temporäres Ladeprofil aktiv ist und dieses übernehmen self.data.set.charge_template = charge_template - Pub().pub(f"openWB/set/chargepoint/{self.num}/set/charge_template", - dataclasses.asdict(charge_template.data)) + Pub().pub(f"openWB/set/chargepoint/{self.num}/set/charge_template", asdict(charge_template.data)) def _pub_connected_vehicle(self, vehicle: Ev): """ published die Daten, die zur Anzeige auf der Hauptseite benötigt werden. @@ -807,8 +795,8 @@ def _pub_connected_vehicle(self, vehicle: Ev): self.data.get.connected_vehicle.soc.range = vehicle.data.get.range self.data.get.connected_vehicle.info = ConnectedInfo(id=vehicle.num, name=vehicle.data.name) - if (self.data.set.charge_template.data.chargemode.selected == "time_charging" or - self.data.set.charge_template.data.chargemode.selected == "scheduled_charging"): + if (self.data.set.charge_template.data.chargemode.selected == Chargemode.TIME_CHARGING or + self.data.set.charge_template.data.chargemode.selected == Chargemode.SCHEDULED_CHARGING): current_plan = self.data.control_parameter.current_plan else: current_plan = None @@ -816,11 +804,10 @@ 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 == - "time_charging") else False) + Chargemode.TIME_CHARGING) else False) except Exception: log.exception("Fehler im Prepare-Modul") @@ -840,15 +827,18 @@ def hw_supports_phase_switch(self) -> bool: self.data.set.log.imported_since_plugged == 0)) def failed_phase_switches_reached(self) -> bool: - if (data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches and + if (data.data.general_data.data.chargemode_config.surplus.vehicle.retry_failed_phase_switches and self.data.control_parameter.failed_phase_switches > self.MAX_FAILED_PHASE_SWITCHES): # bei deaktiverter Wiederholung der Umschaltung nur den gewünschten Umschaltvorgang durchführen, # keinen Korrekturversuch self.set_state_and_log( "Keine Phasenumschaltung, da die maximale Anzahl an Fehlversuchen erreicht wurde. ") return True - elif (data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches is False and + elif (data.data.general_data.data.chargemode_config.surplus.vehicle.retry_failed_phase_switches is False and self.data.control_parameter.failed_phase_switches > 0): 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/chargepoint_all.py b/packages/control/chargepoint/chargepoint_all.py index a384681e17..b3206bc419 100644 --- a/packages/control/chargepoint/chargepoint_all.py +++ b/packages/control/chargepoint/chargepoint_all.py @@ -2,7 +2,6 @@ import logging from control import data -from control.chargepoint.chargepoint_state import ChargepointState from helpermodules.constants import NO_ERROR @@ -37,35 +36,6 @@ def all_chargepoint_data_factory() -> AllChargepointData: class AllChargepoints: data: AllChargepointData = field(default_factory=all_chargepoint_data_factory) - def no_charge(self): - """ Wenn keine EV angesteckt sind oder keine Verzögerungen aktiv sind, werden die Algorithmus-Werte - zurückgesetzt. - (dient der Robustheit) - """ - try: - for cp in data.data.cp_data: - try: - chargepoint = data.data.cp_data[cp] - # Kein EV angesteckt - control_parameter = chargepoint.data.control_parameter - if (not chargepoint.data.get.plug_state or - # Kein EV, das Laden soll - # Kein EV, das auf das Ablaufen der Einschalt- oder Phasenumschaltverzögerung wartet - (control_parameter.state != ChargepointState.PERFORMING_PHASE_SWITCH and - control_parameter.state != ChargepointState.PHASE_SWITCH_DELAY and - control_parameter.state != ChargepointState.SWITCH_OFF_DELAY and - control_parameter.state != ChargepointState.SWITCH_ON_DELAY and - control_parameter.state != ChargepointState.NO_CHARGING_ALLOWED)): - continue - else: - break - except Exception: - log.exception("Fehler in der allgemeinen Ladepunkt-Klasse für Ladepunkt "+cp) - else: - data.data.counter_all_data.get_evu_counter().reset_pv_data() - except Exception: - log.exception("Fehler in der allgemeinen Ladepunkt-Klasse") - def get_cp_sum(self): """ ermittelt die aktuelle Leistung und Zählerstand von allen Ladepunkten. """ diff --git a/packages/control/chargepoint/chargepoint_data.py b/packages/control/chargepoint/chargepoint_data.py index 0f41753cfb..429d20bdc3 100644 --- a/packages/control/chargepoint/chargepoint_data.py +++ b/packages/control/chargepoint/chargepoint_data.py @@ -1,6 +1,7 @@ from dataclasses import dataclass, field from threading import Event from typing import Dict, List, Optional, Protocol +from control.chargemode import Chargemode from control.chargepoint.chargepoint_template import CpTemplate from control.chargepoint.control_parameter import ControlParameter, control_parameter_factory @@ -38,7 +39,7 @@ class ConnectedInfo: class ConnectedConfig: average_consumption: float = 17 charge_template: int = 0 - chargemode: str = "stop" + chargemode: Chargemode = Chargemode.STOP current_plan: Optional[int] = 0 ev_template: int = 0 priority: bool = False diff --git a/packages/control/chargepoint/chargepoint_template.py b/packages/control/chargepoint/chargepoint_template.py index b8cf05ba40..fd10a30e85 100644 --- a/packages/control/chargepoint/chargepoint_template.py +++ b/packages/control/chargepoint/chargepoint_template.py @@ -1,4 +1,4 @@ -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field import logging import traceback from typing import List, Tuple @@ -6,6 +6,7 @@ from control import data from control.ev import ev as ev_module from control.chargepoint.charging_type import ChargingType +from dataclass_utils import asdict from dataclass_utils.factories import empty_list_factory from helpermodules.abstract_plans import AutolockPlan from helpermodules import timecheck diff --git a/packages/control/chargepoint/chargepoint_test.py b/packages/control/chargepoint/chargepoint_test.py index 3ea9c9e622..57a746649b 100644 --- a/packages/control/chargepoint/chargepoint_test.py +++ b/packages/control/chargepoint/chargepoint_test.py @@ -147,7 +147,7 @@ def test_is_phase_switch_required(params: Params): cp.data.get.charge_state = params.charge_state cp.data.control_parameter.failed_phase_switches = params.failed_phase_switches data.data_init(Mock()) - data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches = ( + data.data.general_data.data.chargemode_config.surplus.vehicle.retry_failed_phase_switches = ( params.retry_failed_phase_switches ) diff --git a/packages/control/chargepoint/control_parameter.py b/packages/control/chargepoint/control_parameter.py index dd0b1681e0..17f5f11665 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/chargepoint/get_phases_test.py b/packages/control/chargepoint/get_phases_test.py index b8bec247e0..e4bf1283f4 100644 --- a/packages/control/chargepoint/get_phases_test.py +++ b/packages/control/chargepoint/get_phases_test.py @@ -219,7 +219,7 @@ def test_failed_phase_switches_reached(cp: Chargepoint, failed_phase_switches: int, expected: bool): # setup - data.data.general_data.data.chargemode_config.pv_charging.retry_failed_phase_switches = ( + data.data.general_data.data.chargemode_config.surplus.vehicle.retry_failed_phase_switches = ( retry_failed_phase_switches ) cp.data.control_parameter.failed_phase_switches = failed_phase_switches diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py new file mode 100644 index 0000000000..035a5eec03 --- /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.consumer.usage import NOT_CONTROLLED +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 in NOT_CONTROLLED: + 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) + if self.chargemode_changed or self.submode_changed: + data.data.counter_all_data.get_evu_counter().reset_switch_on_off(self) + 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: + now = timecheck.create_timestamp() + if self.data.set.timestamp_wrote_last_on_time is None: + self.data.set.timestamp_wrote_last_on_time = now + self.data.set.on_time += now - self.data.set.timestamp_wrote_last_on_time + self.data.set.timestamp_wrote_last_on_time = now + + def reset_on_time(self): + self.data.set.on_time = 0 + self.data.set.timestamp_wrote_last_on_time = None + + 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_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..2d530fa4b9 --- /dev/null +++ b/packages/control/consumer/consumer_data.py @@ -0,0 +1,117 @@ +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 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 = field(default=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 = field(default=0) + current_prev: float = field(default=0) + state_str_prev: str = field(default="") + target_current: float = field(default=0) + charge_state_prev: bool = field(default=False) + on_time: float = field(default=0, metadata={"topic": "set/on_time"}) + power: Optional[float] = field(default=None) + switch_interval_elapsed: bool = field(default=False) + timestamp_last_current_set: float = field(default=0, metadata={"topic": "set/timestamp_last_current_set"}) + timestamp_wrote_last_on_time: Optional[float] = field( + default=None, metadata={"topic": "set/timestamp_wrote_last_on_time"}) + 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..3bb50e0166 --- /dev/null +++ b/packages/control/consumer/usage.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class ConsumerUsage(Enum): + METER_ONLY = "meter_only" + SELF_CONTROLLED = "self_controlled" + SUSPENDABLE_TUNABLE = "suspendable_tunable" + SUSPENDABLE_ONOFF = "suspendable_onoff" + CONTINUOUS = "continuous" + + +NOT_CONTROLLED = (ConsumerUsage.METER_ONLY, ConsumerUsage.SELF_CONTROLLED) diff --git a/packages/control/counter.py b/packages/control/counter.py index e7351b3cef..d1bdd8f488 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,10 +270,11 @@ 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: - 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] - surplus = data.data.counter_all_data.get_evu_counter().data.get.power + feed_in_yield + def get_control_range_state(self) -> ControlRangeState: + control_range_low = data.data.general_data.data.chargemode_config.surplus.control_range[0] + control_range_high = data.data.general_data.data.chargemode_config.surplus.control_range[1] + surplus = (data.data.counter_all_data.get_evu_counter().data.get.power + + data.data.general_data.get_feed_in_yield()) if control_range_low > surplus: return ControlRangeState.BELOW elif surplus > control_range_high: @@ -242,51 +282,80 @@ 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 - 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_low = data.data.general_data.data.chargemode_config.surplus.control_range[0] + control_range_high = data.data.general_data.data.chargemode_config.surplus.control_range[1] control_range_center = control_range_high - (control_range_high - control_range_low) / 2 range_offset = control_range_center log.debug(f"Anpassen des Regelbereichs {range_offset}W") return range_offset - def get_usable_surplus(self, feed_in_yield: float) -> float: + def get_usable_surplus(self) -> float: # verbleibender EVU-Überschuss unter Berücksichtigung der Einspeisegrenze und Speicherleistung 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]: + self.data.set.reserved_surplus - data.data.general_data.get_feed_in_yield()) + + 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, switch_on_threshold: float) -> 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 + surplus_config = data.data.general_data.data.chargemode_config.surplus - if chargepoint.data.set.charge_template.data.chargemode.pv_charging.feed_in_limit: - threshold = pv_config.feed_in_yield + if surplus_config.feed_in_limit: + threshold = surplus_config.feed_in_yield else: - threshold = pv_config.switch_on_threshold*control_parameter.phases + threshold = switch_on_threshold + return surplus, threshold - def switch_on_threshold_reached(self, chargepoint: Chargepoint) -> None: + def get_pv_config_py_load(self, load: Load) -> Tuple[float, float, float]: + surplus_config = data.data.general_data.data.chargemode_config.surplus + if isinstance(load, Chargepoint): + control_parameter = load.data.control_parameter + switch_on_delay = surplus_config.vehicle.switch_on_delay + switch_on_threshold = surplus_config.vehicle.switch_on_threshold * control_parameter.phases + power_to_reserve = surplus_config.vehicle.switch_on_threshold*control_parameter.phases + else: + switch_on_delay = surplus_config.consumer.switch_on_delay + switch_on_threshold = (load.data.control_parameter.required_current * + 230 * + load.data.control_parameter.phases) + power_to_reserve = switch_on_threshold + return switch_on_delay, switch_on_threshold, power_to_reserve + + 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 + surplus_config = data.data.general_data.data.chargemode_config.surplus timestamp_switch_on_off = control_parameter.timestamp_switch_on_off + switch_on_delay, switch_on_threshold, power_to_reserve = 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(switch_on_threshold) 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,30 +363,31 @@ 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 - if (surplus >= threshold) and ((feed_in_limit and self.data.set.reserved_surplus == 0) or - not feed_in_limit): + if (surplus >= threshold) and ((surplus_config.feed_in_limit and self.data.set.reserved_surplus == 0) or + not surplus_config.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)) - if feed_in_limit: - message += "Die Einspeisegrenze wird berücksichtigt." + message = texts.waiting.format(timecheck.convert_timestamp_delta_to_time_string( + timestamp_switch_on_off, switch_on_delay)) + + if surplus_config.feed_in_limit: + 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 +398,151 @@ 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, _, power_to_reserve = 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 -= power_to_reserve + 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 + 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() > 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.surplus.vehicle.switch_off_delay + else: + return data.data.general_data.data.chargemode_config.surplus.consumer.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: - 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: + def calc_switch_off_threshold(self, load: Load) -> float: + surplus_config = data.data.general_data.data.chargemode_config.surplus + control_parameter = load.data.control_parameter + if surplus_config.feed_in_limit: # Der EVU-Überschuss muss ggf um die Einspeisegrenze bereinigt werden. # Wnn die Leistung nicht Einspeisegrenze + Einschaltschwelle erreicht, darf die Ladung nicht pulsieren. # Abschaltschwelle um Einschaltschwelle reduzieren. - threshold = (-data.data.general_data.data.chargemode_config.pv_charging.feed_in_yield - + pv_config.switch_on_threshold*control_parameter.phases) + threshold = (-surplus_config.feed_in_yield + + surplus_config.vehicle.switch_on_threshold*control_parameter.phases) else: - threshold = pv_config.switch_off_threshold + threshold = surplus_config.consumer.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,51 +550,58 @@ 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): + def reset_switch_on_off(self, load: Load): try: - if chargepoint.data.control_parameter.timestamp_switch_on_off is not None: - chargepoint.data.control_parameter.timestamp_switch_on_off = None + if load.data.control_parameter.timestamp_switch_on_off is not None: + load.data.control_parameter.timestamp_switch_on_off = None evu_counter = data.data.counter_all_data.get_evu_counter() # Wenn bereits geladen wird, lief die Abschaltverzögerung -> Leistung, die nach Abschalten frei # geworden wäre, nicht mehr zum zur Verfügung stehenden Überschuss zählen. # Wenn nicht geladen wird, reservierte Leistung freigeben. - pv_config = data.data.general_data.data.chargemode_config.pv_charging - if not chargepoint.data.get.charge_state: - evu_counter.data.set.reserved_surplus -= (pv_config.switch_on_threshold - * chargepoint.data.set.phases_to_use) + surplus_config = data.data.general_data.data.chargemode_config.surplus + if isinstance(load, Chargepoint): + if not load.data.get.charge_state: + threshold = surplus_config.vehicle.switch_on_threshold * load.data.set.phases_to_use + else: + threshold = surplus_config.vehicle.switch_on_threshold * load.data.control_parameter.phases + else: + threshold = (load.data.control_parameter.required_current * + 230 * + load.data.control_parameter.phases) + if not load.data.get.charge_state: + evu_counter.data.set.reserved_surplus -= threshold else: - evu_counter.data.set.released_surplus -= (pv_config.switch_on_threshold - * chargepoint.data.control_parameter.phases) - chargepoint.data.control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED + evu_counter.data.set.released_surplus -= threshold + load.data.control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED except Exception: log.exception("Fehler im allgemeinen PV-Modul") @@ -504,7 +615,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 +630,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 e299129839..0000000000 --- a/packages/control/counter_all.py +++ /dev/null @@ -1,531 +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 get_hybrid_inverter_ids(self) -> List[int]: - inverter_ids = [] - - def collect_hybrid_inverter_ids(elements: List[Dict]): - for element in elements: - if element["type"] == ComponentType.INVERTER.value: - if any(child.get("type") == ComponentType.BAT.value for child in element.get("children", [])): - inverter_ids.append(element["id"]) - collect_hybrid_inverter_ids(element.get("children", [])) - - collect_hybrid_inverter_ids(self.data.get.hierarchy) - return inverter_ids - - def _get_bat_ids_by_hybrid_type(self, hybrid: bool) -> List[int]: - bat_ids = [] - - def collect_bat_ids(elements: List[Dict], parent_type: Optional[str] = None): - for element in elements: - element_type = element.get("type") - if element_type == ComponentType.BAT.value: - is_hybrid_bat = parent_type == ComponentType.INVERTER.value - if is_hybrid_bat == hybrid: - bat_ids.append(element["id"]) - collect_bat_ids(element.get("children", []), element_type) - - collect_bat_ids(self.data.get.hierarchy) - return bat_ids - - def get_hybrid_bat_ids(self) -> List[int]: - return self._get_bat_ids_by_hybrid_type(hybrid=True) - - def get_non_hybrid_bat_ids(self) -> List[int]: - return self._get_bat_ids_by_hybrid_type(hybrid=False) - - 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..5b24eb7572 --- /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, type: ComponentType, id: int) -> None: ... + def _remove_loadmanagement_prio_item(self, type: ComponentType, 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..cd5195f17d --- /dev/null +++ b/packages/control/counter_all/hierarchy.py @@ -0,0 +1,355 @@ +import logging +import re +from typing import Callable, Dict, List, Optional, 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 get_hybrid_inverter_ids(self: HierarchyProtocol) -> List[int]: + inverter_ids = [] + + def collect_hybrid_inverter_ids(elements: List[Dict]): + for element in elements: + if element["type"] == ComponentType.INVERTER.value: + if any(child.get("type") == ComponentType.BAT.value for child in element.get("children", [])): + inverter_ids.append(element["id"]) + collect_hybrid_inverter_ids(element.get("children", [])) + + collect_hybrid_inverter_ids(self.data.get.hierarchy) + return inverter_ids + + def _get_bat_ids_by_hybrid_type(self: HierarchyProtocol, hybrid: bool) -> List[int]: + bat_ids = [] + + def collect_bat_ids(elements: List[Dict], parent_type: Optional[str] = None): + for element in elements: + element_type = element.get("type") + if element_type == ComponentType.BAT.value: + is_hybrid_bat = parent_type == ComponentType.INVERTER.value + if is_hybrid_bat == hybrid: + bat_ids.append(element["id"]) + collect_bat_ids(element.get("children", []), element_type) + + collect_bat_ids(self.data.get.hierarchy) + return bat_ids + + def get_hybrid_bat_ids(self: HierarchyProtocol) -> List[int]: + return self._get_bat_ids_by_hybrid_type(hybrid=True) + + def get_non_hybrid_bat_ids(self: HierarchyProtocol) -> List[int]: + return self._get_bat_ids_by_hybrid_type(hybrid=False) + + 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) + check_and_remove("consumer", ComponentType.CONSUMER, data.data.consumer_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) + check_and_add(ComponentType.CONSUMER, data.data.consumer_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 97% rename from packages/control/hierarchy_test.py rename to packages/control/counter_all/hierarchy_test.py index 7fa28a3f10..6cf5a1456d 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..80b94dfc1b --- /dev/null +++ b/packages/control/counter_all/loadmanagement_prio.py @@ -0,0 +1,70 @@ +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, type: ComponentType, id: int) -> None: + if self._remove_loadmanagement_prio_item(type, 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, + type: ComponentType, + id: int, + entry: List[Dict]) -> bool: + for item in entry: + if item["type"] == type.value and item["id"] == id: + entry.remove(item) + return True + elif item["type"] == "group": + removed_item = self._remove_loadmanagement_prio_item(type, 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..4bdd0a1ff2 --- /dev/null +++ b/packages/control/counter_all/loadmanagement_prio_test.py @@ -0,0 +1,272 @@ + +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, type, id, expected_loadmanagement_prios", + [ + pytest.param([{"type": "vehicle", "id": 3}, {"type": "vehicle", "id": 2}], + ComponentType.VEHICLE, 2, [{"type": "vehicle", "id": 3}], id="flat list, remove vehicle"), + pytest.param([{"type": "vehicle", "id": 3}, {"type": "consumer", "id": 2}], + ComponentType.CONSUMER, 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}, + ], ComponentType.VEHICLE, 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}, + ], ComponentType.VEHICLE, 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}, + ], ComponentType.VEHICLE, 0, [{"type": "vehicle", "id": 2}], id="nested list, empty group"), + ] +) +def test_remove_loadmanagement_prio_item(loadmanagement_prios: List[Dict], + type: ComponentType, + id: int, + expected_loadmanagement_prios: List[Dict]): + # setup + c = CounterAll() + c.data.get.loadmanagement_prios = loadmanagement_prios + + # execution + c.remove_loadmanagement_prio_item(type, 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..21b6b7fc01 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.surplus.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) @@ -170,7 +172,7 @@ def test_control_range(control_range, evu_power, expected_range_offset, general_ get_evu_counter_mock = Mock(return_value=Mock(spec=Counter, data=Mock( spec=CounterData, get=Mock(spec=Get, power=evu_power)))) monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", get_evu_counter_mock) - data.data.general_data.data.chargemode_config.pv_charging.control_range = control_range + data.data.general_data.data.chargemode_config.surplus.control_range = control_range c = Counter(0) # execution diff --git a/packages/control/data.py b/packages/control/data.py index 298ced12f5..0b03c9758e 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 = 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..ad521ec218 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -1,15 +1,17 @@ -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field import datetime import logging import traceback from typing import List, Optional, Tuple from control import data +from control.chargemode import Chargemode as ChargemodeEnum from control.chargepoint.chargepoint_state import CHARGING_STATES from control.chargepoint.charging_type import ChargingType from control.chargepoint.control_parameter import ControlParameter from control.ev.ev_template import EvTemplate from control.text import BidiState +from dataclass_utils import asdict from dataclass_utils.factories import empty_list_factory from helpermodules.abstract_plans import Limit, limit_factory, ScheduledChargingPlan, TimeChargingPlan from helpermodules import timecheck @@ -64,7 +66,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 @@ -91,7 +92,7 @@ def instant_charging_factory() -> InstantCharging: @dataclass class Chargemode: - selected: str = "instant_charging" + selected: ChargemodeEnum = ChargemodeEnum.INSTANT_CHARGING eco_charging: EcoCharging = field(default_factory=eco_charging_factory) pv_charging: PvCharging = field(default_factory=pv_charging_factory) scheduled_charging: ScheduledCharging = field(default_factory=scheduled_charging_factory) @@ -110,7 +111,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) @@ -151,11 +152,11 @@ class ChargeTemplate: def time_charging(self, soc: Optional[float], used_amount_time_charging: float, - charging_type: str) -> Tuple[int, str, Optional[str], Optional[str], int]: + charging_type: str) -> Tuple[int, ChargemodeEnum, Optional[str], Optional[str], int]: """ prüft, ob ein Zeitfenster aktiv ist und setzt entsprechend den Ladestrom """ message = None - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP current = 0 id = None phases = None @@ -179,12 +180,12 @@ def time_charging(self, log.debug( "Zeitladen: minimaler Speicher-SoC überschritten, Laden mit Zeitladen möglich.") current = plan.current if charging_type == ChargingType.AC.value else plan.dc_current - sub_mode = "time_charging" + sub_mode = ChargemodeEnum.TIME_CHARGING else: message = self.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL else: current = plan.current if charging_type == ChargingType.AC.value else plan.dc_current - sub_mode = "time_charging" + sub_mode = ChargemodeEnum.TIME_CHARGING else: message = self.TIME_CHARGING_NO_PLAN_ACTIVE else: @@ -192,7 +193,10 @@ def time_charging(self, return current, sub_mode, message, id, phases except Exception: log.exception("Fehler im ev-Modul "+str(self.data.id)) - return (0, "stop", "Keine Ladung, da da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), None, + return (0, + ChargemodeEnum.STOP, + "Keine Ladung, da da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), + None, 0) SOC_REACHED = "Keine Ladung, da das Ladeziel bereits erreicht wurde." @@ -201,11 +205,11 @@ def time_charging(self, def instant_charging(self, soc: Optional[float], used_amount: float, - charging_type: str) -> Tuple[int, str, Optional[str], int]: + charging_type: str) -> Tuple[int, ChargemodeEnum, Optional[str], int]: """ prüft, ob die Lademengenbegrenzung erreicht wurde und setzt entsprechend den Ladestrom. """ message = None - sub_mode = "instant_charging" + sub_mode = ChargemodeEnum.INSTANT_CHARGING try: instant_charging = self.data.chargemode.instant_charging phases = instant_charging.phases_to_use @@ -216,17 +220,20 @@ def instant_charging(self, if instant_charging.limit.selected == "soc" and soc is not None and soc >= instant_charging.limit.soc: current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.SOC_REACHED elif instant_charging.limit.selected == "amount": if used_amount >= self.data.chargemode.instant_charging.limit.amount: current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.AMOUNT_REACHED return current, sub_mode, message, phases except Exception: log.exception("Fehler im ev-Modul "+str(self.data.id)) - return 0, "stop", "Keine Ladung, da da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), 0 + return (0, + ChargemodeEnum.STOP, + "Keine Ladung, da da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), + 0) PV_CHARGING_SOC_CHARGING = ("Ladung evtl. auch ohne PV-Überschuss, da der Mindest-SoC des Fahrzeugs noch nicht " "erreicht wurde.") @@ -236,11 +243,11 @@ def pv_charging(self, soc: Optional[float], min_current: int, charging_type: str, - used_amount: float) -> Tuple[int, str, Optional[str], int]: + used_amount: float) -> Tuple[int, ChargemodeEnum, Optional[str], int]: """ prüft, ob Min-oder Max-Soc erreicht wurden und setzt entsprechend den Ladestrom. """ message = None - sub_mode = "pv_charging" + sub_mode = ChargemodeEnum.PV_CHARGING try: pv_charging = self.data.chargemode.pv_charging phases = pv_charging.phases_to_use @@ -248,11 +255,11 @@ def pv_charging(self, else pv_charging.dc_min_current) if pv_charging.limit.selected == "soc" and soc is not None and soc >= pv_charging.limit.soc: current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.SOC_REACHED elif pv_charging.limit.selected == "amount" and used_amount >= pv_charging.limit.amount: current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.AMOUNT_REACHED else: if pv_charging.min_soc != 0 and soc is not None and soc < pv_charging.min_soc: @@ -260,33 +267,36 @@ def pv_charging(self, current = pv_charging.min_soc_current else: current = pv_charging.dc_min_soc_current - sub_mode = "instant_charging" + sub_mode = ChargemodeEnum.INSTANT_CHARGING message = self.PV_CHARGING_SOC_CHARGING phases = pv_charging.phases_to_use_min_soc elif min_pv_current == 0: # nur PV; Ampere darf nicht 0 sein, wenn geladen werden soll current = min_current - sub_mode = "pv_charging" + sub_mode = ChargemodeEnum.PV_CHARGING else: # Min PV current = min_pv_current - sub_mode = "instant_charging" + sub_mode = ChargemodeEnum.INSTANT_CHARGING message = self.PV_CHARGING_MIN_CURRENT_CHARGING return current, sub_mode, message, phases except Exception: log.exception("Fehler im ev-Modul "+str(self.data.id)) - return 0, "stop", "Keine Ladung, da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), 1 + return (0, + ChargemodeEnum.STOP, + "Keine Ladung, da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), + 1) def eco_charging(self, soc: Optional[float], control_parameter: ControlParameter, charging_type: str, used_amount: float, - max_phases_hw: int) -> Tuple[int, str, Optional[str], int]: + max_phases_hw: int) -> Tuple[int, ChargemodeEnum, Optional[str], int]: """ prüft, ob Min-oder Max-Soc erreicht wurden und setzt entsprechend den Ladestrom. """ message = None - sub_mode = "pv_charging" + sub_mode = ChargemodeEnum.PV_CHARGING try: eco_charging = self.data.chargemode.eco_charging phases = eco_charging.phases_to_use @@ -294,16 +304,15 @@ def eco_charging(self, if eco_charging.limit.selected == "soc" and soc is not None and soc >= eco_charging.limit.soc: current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.SOC_REACHED - elif (eco_charging.limit.selected == "amount" and - used_amount >= self.data.chargemode.instant_charging.limit.amount): + elif eco_charging.limit.selected == "amount" and used_amount >= eco_charging.limit.amount: current = 0 - sub_mode = "stop" + sub_mode = ChargemodeEnum.STOP message = self.AMOUNT_REACHED elif data.data.optional_data.data.electricity_pricing.configured: if data.data.optional_data.ep_is_charging_allowed_price_threshold(eco_charging.max_price): - sub_mode = "instant_charging" + sub_mode = ChargemodeEnum.INSTANT_CHARGING message = self.CHARGING_PRICE_LOW phases = max_phases_hw else: @@ -316,7 +325,10 @@ def eco_charging(self, return current, sub_mode, message, phases except Exception: log.exception("Fehler im ev-Modul "+str(self.data.id)) - return 0, "stop", "Keine Ladung, da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), 0 + return (0, + ChargemodeEnum.STOP, + "Keine Ladung, da ein interner Fehler aufgetreten ist: "+traceback.format_exc(), + 0) 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 @@ -553,9 +565,9 @@ def scheduled_charging_calc_current(self, charging_type: str, ev_template: EvTemplate, bidi_state: BidiState, - charge_state: bool) -> Tuple[float, str, str, int]: + charge_state: bool) -> Tuple[float, ChargemodeEnum, str, int]: current = 0 - submode = "stop" + submode = ChargemodeEnum.STOP if selected_plan is None: if len(self.data.chargemode.scheduled_charging.plans) == 0: return current, submode, self.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, control_parameter_phases @@ -585,14 +597,14 @@ def scheduled_charging_calc_current(self, if plan.bidi_charging_enabled and bidi_state == BidiState.BIDI_CAPABLE: message = self.SCHEDULED_CHARGING_BIDI current = min_current - submode = "bidi_charging" + submode = ChargemodeEnum.BIDI_CHARGING phases = control_parameter_phases else: message = self.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC if plan.bidi_charging_enabled and bidi_state != BidiState.BIDI_CAPABLE: message += bidi_state.value current = min_current - submode = "pv_charging" + submode = ChargemodeEnum.PV_CHARGING # bei Überschuss-Laden mit der Phasenzahl aus den control_parameter laden, # um die Umschaltung zu berücksichtigen. phases = plan.phases_to_use_pv @@ -609,7 +621,7 @@ def scheduled_charging_calc_current(self, limit_string = self.SCHEDULED_CHARGING_LIMITED_BY_AMOUNT.format(limit.amount/1000) message = self.SCHEDULED_CHARGING_IN_TIME.format(plan_current, limit_string, plan.time) current = plan_current - submode = "instant_charging" + submode = ChargemodeEnum.INSTANT_CHARGING # weniger als die berechnete Zeit verfügbar elif selected_plan.remaining_time <= 0 - soc_request_interval_offset: if selected_plan.duration + selected_plan.remaining_time < 0: @@ -618,7 +630,7 @@ def scheduled_charging_calc_current(self, current = min(selected_plan.missing_amount/((selected_plan.duration + selected_plan.remaining_time) / 3600)/(phases*230), max_current) message = self.SCHEDULED_CHARGING_MAX_CURRENT.format(round(current, 2)) - submode = "instant_charging" + submode = ChargemodeEnum.INSTANT_CHARGING else: # Wenn dynamische Tarife aktiv sind, prüfen, ob jetzt ein günstiger Zeitpunkt zum Laden # ist. @@ -671,12 +683,12 @@ def convert_loading_hours_to_string(hour_list: List[int]) -> str: message = self.SCHEDULED_CHARGING_CHEAP_HOUR.format(get_hours_message()) current = plan_current phases = max_phases_hw if plan.phases_to_use == 0 else plan.phases_to_use - submode = "instant_charging" + submode = ChargemodeEnum.INSTANT_CHARGING elif ((limit.selected == "soc" and soc <= limit.soc_limit) or (limit.selected == "amount" and used_amount < limit.amount)): message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(get_hours_message()) current = min_current - submode = "pv_charging" + submode = ChargemodeEnum.PV_CHARGING phases = plan.phases_to_use_pv else: message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format(get_hours_message()) @@ -694,12 +706,12 @@ def convert_loading_hours_to_string(hour_list: List[int]) -> str: message = self.SCHEDULED_CHARGING_USE_PV.format( f"am {start_time.strftime('%d.%m')} um {start_time.strftime('%-H:%M')} Uhr") current = min_current - submode = "pv_charging" + submode = ChargemodeEnum.PV_CHARGING phases = plan.phases_to_use_pv return current, submode, message, phases def stop(self) -> Tuple[int, str, str]: - return 0, "stop", "Keine Ladung, da der Lademodus Stop aktiv ist." + return 0, ChargemodeEnum.STOP, "Keine Ladung, da der Lademodus Stop aktiv ist." def bidi_charging_allowed(self, selected_plan: int, soc: float): # Wenn über den Limit-SoC geladen wurde, darf nur noch bidirektional entladen werden. diff --git a/packages/control/ev/charge_template_test.py b/packages/control/ev/charge_template_test.py index 5fc859e02b..652da3e6ea 100644 --- a/packages/control/ev/charge_template_test.py +++ b/packages/control/ev/charge_template_test.py @@ -7,6 +7,7 @@ from control import data from control import optional from control.bat_all import BatAll +from control.chargemode import Chargemode from control.chargepoint.control_parameter import ControlParameter from control.ev.charge_template import SelectedPlan from control.chargepoint.charging_type import ChargingType @@ -28,38 +29,39 @@ def data_module() -> None: @pytest.mark.parametrize( "plans, soc, used_amount_time_charging, plan_found, expected", [ - pytest.param({}, 0, 0, None, (0, "stop", ChargeTemplate.TIME_CHARGING_NO_PLAN_CONFIGURED, None, None), + pytest.param({}, 0, 0, None, (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_NO_PLAN_CONFIGURED, None, None), id="no plan defined"), pytest.param({"0": TimeChargingPlan(id=0)}, 0, 0, None, - (0, "stop", ChargeTemplate.TIME_CHARGING_NO_PLAN_ACTIVE, None, None), id="no plan active"), + (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_NO_PLAN_ACTIVE, None, None), + id="no plan active"), pytest.param({"0": TimeChargingPlan(id=0)}, 0, 0, TimeChargingPlan(id=0), - (16, "time_charging", None, 0, 1), id="plan active"), + (16, Chargemode.TIME_CHARGING, None, 0, 1), id="plan active"), pytest.param({"0": TimeChargingPlan(id=0, limit=Limit(selected="soc"))}, 100, 0, TimeChargingPlan(id=0, limit=Limit(selected="soc")), - (0, "stop", ChargeTemplate.TIME_CHARGING_SOC_REACHED, 0, 1), + (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_SOC_REACHED, 0, 1), id="plan active, soc is reached"), pytest.param({"0": TimeChargingPlan(id=0, limit=Limit(selected="soc"))}, 40, 0, TimeChargingPlan(id=0, limit=Limit(selected="soc")), - (16, "time_charging", None, 0, 1), id="plan active, soc is not reached"), + (16, Chargemode.TIME_CHARGING, None, 0, 1), id="plan active, soc is not reached"), pytest.param({"0": TimeChargingPlan(id=0, limit=Limit(selected="soc"))}, None, 0, TimeChargingPlan(id=0, limit=Limit(selected="soc")), - (16, "time_charging", None, 0, 1), id="plan active, soc is not defined"), + (16, Chargemode.TIME_CHARGING, None, 0, 1), id="plan active, soc is not defined"), pytest.param({"0": TimeChargingPlan(id=0, limit=Limit(selected="amount", amount=1000))}, 0, 1500, TimeChargingPlan(id=0, limit=Limit(selected="amount", amount=1000)), - (0, "stop", ChargeTemplate.TIME_CHARGING_AMOUNT_REACHED, 0, 1), + (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_AMOUNT_REACHED, 0, 1), id="plan active, used_amount_time_charging is reached"), pytest.param({"0": TimeChargingPlan(id=0, limit=Limit(selected="amount", amount=1000))}, 0, 500, TimeChargingPlan(id=0, limit=Limit(selected="amount", amount=1000)), - (16, "time_charging", None, 0, 1), + (16, Chargemode.TIME_CHARGING, None, 0, 1), id="plan active, used_amount_time_charging is not reached"), pytest.param({"0": TimeChargingPlan(id=0)}, 0, 0, None, - (0, "stop", ChargeTemplate.TIME_CHARGING_NO_PLAN_ACTIVE, None, None), + (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_NO_PLAN_ACTIVE, None, None), id="plan defined but not found"), ] ) def test_time_charging(plans: Dict[int, TimeChargingPlan], soc: float, used_amount_time_charging: float, plan_found: TimeChargingPlan, - expected: Tuple[int, str, Optional[str], Optional[str]], + expected: Tuple[int, Chargemode, Optional[str], Optional[str], Optional[int]], monkeypatch): # setup ct = ChargeTemplate() @@ -77,19 +79,20 @@ def test_time_charging(plans: Dict[int, TimeChargingPlan], soc: float, used_amou @pytest.mark.parametrize( "min_bat_soc, charging_allowed_mock, soc, expected", [ - pytest.param(80, False, 81, (0, "stop", ChargeTemplate.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL, 0, 1), + pytest.param(80, False, 81, + (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_CONFLICT_ACTIVE_BAT_CONTROL, 0, 1), id="Konflikt mit aktiver Speichersteuerung -> nicht laden"), - pytest.param(80, True, 79, (0, "stop", ChargeTemplate.TIME_CHARGING_MIN_BAT_SOC_REACHED, 0, 1), + pytest.param(80, True, 79, (0, Chargemode.STOP, ChargeTemplate.TIME_CHARGING_MIN_BAT_SOC_REACHED, 0, 1), id="Mindest-SoC des Speichers unterschritten -> nicht laden"), - pytest.param(80, True, 80, (16, "time_charging", None, 0, 1), id="laden erlaubt"), - pytest.param(None, True, 80, (16, "time_charging", None, 0, 1), + pytest.param(80, True, 80, (16, Chargemode.TIME_CHARGING, None, 0, 1), id="laden erlaubt"), + pytest.param(None, True, 80, (16, Chargemode.TIME_CHARGING, None, 0, 1), 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[int, str, Optional[str], Optional[str], int], + expected: Tuple[int, Chargemode, Optional[str], Optional[str], int], monkeypatch: pytest.MonkeyPatch): # setup ct = ChargeTemplate() @@ -115,17 +118,18 @@ def test_time_charging_min_bat_soc(min_bat_soc: Optional[int], @pytest.mark.parametrize( "selected, current_soc, used_amount, expected", [ - pytest.param("none", 0, 0, (16, "instant_charging", None, 3), id="without limit"), - pytest.param("soc", None, 0, (16, "instant_charging", None, 3), id="limit soc: soc not defined"), - pytest.param("soc", 49, 0, (16, "instant_charging", None, 3), id="limit soc: soc not reached"), - pytest.param("soc", 50, 0, (0, "stop", ChargeTemplate.SOC_REACHED, 3), + pytest.param("none", 0, 0, (16, Chargemode.INSTANT_CHARGING, None, 3), id="without limit"), + pytest.param("soc", None, 0, (16, Chargemode.INSTANT_CHARGING, None, 3), id="limit soc: soc not defined"), + pytest.param("soc", 49, 0, (16, Chargemode.INSTANT_CHARGING, None, 3), id="limit soc: soc not reached"), + pytest.param("soc", 50, 0, (0, Chargemode.STOP, ChargeTemplate.SOC_REACHED, 3), id="limit soc: soc reached"), - pytest.param("amount", 0, 999, (16, "instant_charging", None, 3), id="limit amount: amount not reached"), - pytest.param("amount", 0, 1000, (0, "stop", ChargeTemplate.AMOUNT_REACHED, 3), + pytest.param("amount", 0, 999, (16, Chargemode.INSTANT_CHARGING, + None, 3), id="limit amount: amount not reached"), + pytest.param("amount", 0, 1000, (0, Chargemode.STOP, ChargeTemplate.AMOUNT_REACHED, 3), id="limit amount: amount reached"), ]) def test_instant_charging(selected: str, current_soc: float, used_amount: float, - expected: Tuple[int, str, Optional[str]]): + expected: Tuple[int, Chargemode, Optional[str], int]): # setup ct = ChargeTemplate() ct.data.chargemode.instant_charging.limit.selected = selected @@ -141,21 +145,24 @@ def test_instant_charging(selected: str, current_soc: float, used_amount: float, @pytest.mark.parametrize( "min_soc, min_current, limit_selected, current_soc, used_amount, expected", [ - pytest.param(0, 0, "amount", 14, 1500, (0, "stop", ChargeTemplate.AMOUNT_REACHED, 0), id="max amount reached"), - pytest.param(0, 0, "soc", 100, 900, (0, "stop", ChargeTemplate.SOC_REACHED, 0), id="max soc reached"), - pytest.param(15, 0, None, 14, 900, (10, "instant_charging", ChargeTemplate.PV_CHARGING_SOC_CHARGING, 3), + pytest.param(0, 0, "amount", 14, 1500, (0, Chargemode.STOP, + ChargeTemplate.AMOUNT_REACHED, 0), id="max amount reached"), + pytest.param(0, 0, "soc", 100, 900, (0, Chargemode.STOP, ChargeTemplate.SOC_REACHED, 0), id="max soc reached"), + pytest.param(15, 0, None, 14, 900, + (10, Chargemode.INSTANT_CHARGING, ChargeTemplate.PV_CHARGING_SOC_CHARGING, 3), id="min soc not reached"), - pytest.param(15, 0, None, None, 900, (6, "pv_charging", None, 0), id="soc not defined"), - pytest.param(15, 8, None, 15, 900, (8, "instant_charging", ChargeTemplate.PV_CHARGING_MIN_CURRENT_CHARGING, 0), + pytest.param(15, 0, None, None, 900, (6, Chargemode.PV_CHARGING, None, 0), id="soc not defined"), + pytest.param(15, 8, None, 15, 900, + (8, Chargemode.INSTANT_CHARGING, ChargeTemplate.PV_CHARGING_MIN_CURRENT_CHARGING, 0), id="min current configured"), - pytest.param(15, 0, None, 15, 900, (6, "pv_charging", None, 0), id="bare pv charging"), + pytest.param(15, 0, None, 15, 900, (6, Chargemode.PV_CHARGING, None, 0), id="bare pv charging"), ]) def test_pv_charging(min_soc: int, min_current: int, limit_selected: str, current_soc: float, used_amount: float, - expected: Tuple[int, str, Optional[str], int]): + expected: Tuple[int, Chargemode, Optional[str], int]): # setup ct = ChargeTemplate() ct.data.chargemode.pv_charging.min_soc = min_soc @@ -267,34 +274,35 @@ def test_scheduled_charging_recent_plan(end_time_mock, @pytest.mark.parametrize( "plan_data, soc, used_amount, selected, bidi_charging_enabled, expected", [ - pytest.param(None, 0, 0, "none", False, (0, "stop", + pytest.param(None, 0, 0, "none", False, (0, Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_NO_DATE_PENDING, 3), id="no date pending"), - pytest.param(SelectedPlan(duration=3600), 90, 0, "soc", False, (0, "stop", + pytest.param(SelectedPlan(duration=3600), 90, 0, "soc", False, (0, Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC, 1), id="reached limit soc"), - pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", False, (6, "pv_charging", + pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", False, (6, Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC, 0), id="reached scheduled soc"), - pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", True, (6, "bidi_charging", + pytest.param(SelectedPlan(duration=3600), 80, 0, "soc", True, (6, Chargemode.BIDI_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_BIDI, 3), id="reached scheduled soc, bidi"), - pytest.param(SelectedPlan(phases=3, duration=3600), 0, 1000, "amount", False, (0, "stop", + pytest.param(SelectedPlan(phases=3, duration=3600), 0, 1000, "amount", False, (0, Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_REACHED_AMOUNT, 3), id="reached amount"), pytest.param(SelectedPlan(remaining_time=299, duration=3600), 0, 999, "amount", - False, (14, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( + False, (14, Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( 14, ChargeTemplate.SCHEDULED_CHARGING_LIMITED_BY_AMOUNT.format(1.0), "07:00"), 1), id="in time, limited by amount"), pytest.param(SelectedPlan(remaining_time=299, duration=3600), 79, 0, "soc", - False, (14, "instant_charging", ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( + False, (14, Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format( 14, ChargeTemplate.SCHEDULED_CHARGING_LIMITED_BY_SOC.format(80), "07:00"), 1), id="in time, limited by soc"), pytest.param(SelectedPlan(remaining_time=-500, duration=3600, missing_amount=9000, phases=3), 79, 0, "soc", - False, (15.147265077138847, "instant_charging", + False, (15.147265077138847, Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_MAX_CURRENT.format(15.15), 3), id="too late, but didn't miss for today"), pytest.param(SelectedPlan(remaining_time=-800, duration=780, missing_amount=4600, phases=3), 79, 0, "soc", - False, (16, "instant_charging", + False, (16, Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_MAX_CURRENT.format(16), 3), id="few minutes too late, but didn't miss for today"), pytest.param(SelectedPlan(remaining_time=601, duration=3600), 79, 0, "soc", - False, (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), + False, + (6, Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), id="too early, use pv"), ]) def test_scheduled_charging_calc_current(plan_data: SelectedPlan, @@ -302,7 +310,7 @@ def test_scheduled_charging_calc_current(plan_data: SelectedPlan, used_amount: float, selected: str, bidi_charging_enabled: bool, - expected: Tuple[float, str, str, int]): + expected: Tuple[float, Chargemode, str, int]): # setup ct = ChargeTemplate() plan = ScheduledChargingPlan(active=True, id=0) @@ -330,28 +338,28 @@ def test_scheduled_charging_calc_current_no_plans(): None, 63, 5, 3, 3, 6, 0, ChargingType.AC.value, EvTemplate(), BidiState.BIDI_CAPABLE, True) # evaluation - assert ret == (0, "stop", ChargeTemplate.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, 3) + assert ret == (0, Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_NO_PLANS_CONFIGURED, 3) @pytest.mark.parametrize( "selected_limit, charge_state, expected", [ pytest.param("soc", False, - (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), + (6, Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), id="soc request interval not considered, use pv"), pytest.param("soc", True, - (14, "instant_charging", + (14, Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_IN_TIME.format(14, 'einen SoC von 80%', "07:00"), 3), id="soc request interval considered"), pytest.param("amount", True, - (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), + (6, Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), id="amount, charging, soc request interval not considered, no soc limit configured"), pytest.param("amount", False, - (6, "pv_charging", ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), + (6, Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_USE_PV.format("um 8:50 Uhr"), 0), id="amount, not charging, soc request interval not considered, no soc limit configured"), ]) def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( - selected_limit: str, charge_state: bool, expected: Tuple[float, str, str, int]): + selected_limit: str, charge_state: bool, expected: Tuple[float, Chargemode, str, int]): # setup ct = ChargeTemplate() plan = ScheduledChargingPlan() @@ -379,7 +387,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(True, 79, 80, 90, LOADING_HOURS_TODAY + LOADING_HOURS_TOMORROW, ( 14, - "instant_charging", + Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_CHEAP_HOUR.format( "Geladen wird jetzt sowie morgen 8:00."), 3), @@ -387,7 +395,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(True, 79, 80, 70, LOADING_HOURS_TODAY, ( 14, - "instant_charging", + Chargemode.INSTANT_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_CHEAP_HOUR.format( "Geladen wird jetzt."), 3), @@ -395,7 +403,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(False, 79, 80, 90, LOADING_HOURS_TODAY, ( 6, - "pv_charging", + Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format( "Geladen wird heute 8:00."), 0), @@ -403,7 +411,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(False, 79, 80, 70, LOADING_HOURS_TODAY, ( 0, - "stop", + Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format( "Geladen wird heute 8:00."), 3), @@ -411,7 +419,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(False, 79, 80, 70, LOADING_HOURS_TODAY + LOADING_HOURS_TOMORROW, ( 0, - "stop", + Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_EXPENSIVE_HOUR_REACHED_MAX_SOC.format( "Geladen wird heute 8:00 sowie morgen 8:00."), 3), @@ -419,7 +427,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(False, 79, 60, 80, LOADING_HOURS_TODAY, ( 6, - "pv_charging", + Chargemode.PV_CHARGING, ChargeTemplate.SCHEDULED_CHARGING_REACHED_SCHEDULED_SOC.format( ""), 0), @@ -427,7 +435,7 @@ def test_scheduled_charging_calc_current_consider_soc_request_interval_offset( pytest.param(False, 79, 60, 50, LOADING_HOURS_TODAY, ( 0, - "stop", + Chargemode.STOP, ChargeTemplate.SCHEDULED_CHARGING_REACHED_MAX_AND_LIMIT_SOC.format( ""), 3), diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index fab9167328..0a1e77abcf 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -6,6 +6,7 @@ stärke wird auch geprüft, ob sich an diesen Parametern etwas geändert hat. Falls ja, muss das EV in der Regelung neu priorisiert werden und eine neue Zuteilung des Stroms erhalten. """ +from control.chargemode import Chargemode from modules.common.configurable_vehicle import ConfigurableVehicle from modules.common.abstract_vehicle import VehicleUpdateData from helpermodules.constants import DEFAULT_COLORS, NO_ERROR @@ -125,7 +126,7 @@ def get_required_current(self, charging_type: str, imported_since_plugged: float, bidi: BidiState, - charge_state: bool) -> Tuple[bool, Optional[str], str, float, int]: + charge_state: bool) -> Tuple[bool, Optional[str], Chargemode, float, int]: """ ermittelt, ob und mit welchem Strom das EV geladen werden soll (unabhängig vom Lastmanagement) Parameter @@ -150,7 +151,7 @@ def get_required_current(self, tmp_message = None state = True try: - if charge_template.data.chargemode.selected == "stop": + if charge_template.data.chargemode.selected == Chargemode.STOP: required_current, submode, message = charge_template.stop() phases = control_parameter.phases or max_phases_hw else: @@ -160,7 +161,7 @@ def get_required_current(self, soc_request_interval_offset = self.soc_module.general_config.request_interval_charging else: soc_request_interval_offset = 0 - if charge_template.data.chargemode.selected == "scheduled_charging": + if charge_template.data.chargemode.selected == Chargemode.SCHEDULED_CHARGING: required_current, submode, tmp_message, phases = charge_template.scheduled_charging( self.data.get.soc, self.ev_template, @@ -175,7 +176,7 @@ def get_required_current(self, message = f"{tmp_message or ''}".strip() # Wenn Zielladen auf Überschuss wartet, prüfen, ob Zeitladen aktiv ist. - if (submode != "instant_charging" and + if (submode != Chargemode.INSTANT_CHARGING and charge_template.data.time_charging.active): tmp_current, tmp_submode, tmp_message, plan_id, tmp_phases = charge_template.time_charging( self.data.get.soc, @@ -190,21 +191,21 @@ def get_required_current(self, submode = tmp_submode phases = tmp_phases if (required_current == 0) or (required_current is None): - if charge_template.data.chargemode.selected == "instant_charging": + if charge_template.data.chargemode.selected == Chargemode.INSTANT_CHARGING: required_current, submode, tmp_message, phases = charge_template.instant_charging( self.data.get.soc, imported_since_plugged, charging_type) - elif charge_template.data.chargemode.selected == "pv_charging": + elif charge_template.data.chargemode.selected == Chargemode.PV_CHARGING: required_current, submode, tmp_message, phases = charge_template.pv_charging( self.data.get.soc, control_parameter.min_current, charging_type, imported_since_plugged) - elif charge_template.data.chargemode.selected == "eco_charging": + elif charge_template.data.chargemode.selected == Chargemode.ECO_CHARGING: required_current, submode, tmp_message, phases = charge_template.eco_charging( self.data.get.soc, control_parameter, charging_type, imported_since_plugged, max_phases_hw) else: tmp_message = None message = f"{message or ''} {tmp_message or ''}".strip() - if submode == "stop" or (charge_template.data.chargemode.selected == "stop"): + if submode == Chargemode.STOP or (charge_template.data.chargemode.selected == Chargemode.STOP): state = False if phases is None: log.debug("Keine Phasenvorgabe durch Lademodus. Behalte Phasenzahl bei.") @@ -212,7 +213,7 @@ def get_required_current(self, return state, message, submode, required_current, phases except Exception as e: log.exception("Fehler im ev-Modul "+str(self.num)) - return (False, f"Kein Ladevorgang, da ein Fehler aufgetreten ist: {' '.join(e.args)}", "stop", 0, + return (False, f"Kein Ladevorgang, da ein Fehler aufgetreten ist: {' '.join(e.args)}", Chargemode.STOP, 0, control_parameter.phases) def check_min_max_current(self, @@ -302,7 +303,6 @@ def _check_phase_switch_conditions(self, PHASE_SWITCH_DELAY_TEXT = '{} Phasen in {}.' def auto_phase_switch(self, - charge_template: ChargeTemplate, control_parameter: ControlParameter, cp_num: int, evse_current: float, @@ -315,12 +315,8 @@ def auto_phase_switch(self, current = control_parameter.required_current 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 - else: - feed_in_yield = 0 - delay = pv_config.phase_switch_delay * 60 + surplus_config = data.data.general_data.data.chargemode_config.surplus + delay = surplus_config.vehicle.phase_switch_delay * 60 if phases_in_use == 1: direction_str = f"Umschaltung von 1 auf {max_phases}" required_reserved_power = (control_parameter.min_current * max_phases * 230 - @@ -328,15 +324,15 @@ def auto_phase_switch(self, new_phase = max_phases new_current = control_parameter.min_current - waiting_time = pv_config.switch_on_delay + waiting_time = surplus_config.vehicle.switch_on_delay else: direction_str = f"Umschaltung von {max_phases} auf 1" # Es kann einphasig mit entsprechend niedriger Leistung gestartet werden. required_reserved_power = 0 new_phase = 1 new_current = self.ev_template.data.max_current_single_phase - waiting_time = pv_config.switch_off_delay - all_surplus = data.data.counter_all_data.get_evu_counter().get_usable_surplus(feed_in_yield) + waiting_time = surplus_config.vehicle.switch_off_delay + all_surplus = data.data.counter_all_data.get_evu_counter().get_usable_surplus() if control_parameter.state == ChargepointState.PHASE_SWITCH_DELAY: # eigene reservierte Leistung addieren, wenn die Verzögerung für die Umschaltung läuft, # da diese Leistung bereits für die Umschaltung reserviert ist. diff --git a/packages/control/general.py b/packages/control/general.py index 80941ead81..df9d7f3037 100644 --- a/packages/control/general.py +++ b/packages/control/general.py @@ -12,56 +12,77 @@ log = logging.getLogger(__name__) -def control_range_factory() -> List: +def control_range_factory() -> List[int]: return [0, 230] @dataclass -class PvCharging: - bat_power_reserve: int = field(default=2000, metadata={ - "topic": "chargemode_config/pv_charging/bat_power_reserve"}) - bat_power_reserve_active: bool = field(default=False, metadata={ - "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"}) +class ChargemodeConfigBat: + power_reserve: int = field(default=2000, metadata={ + "topic": "chargemode_config/bat/power_reserve"}) + power_reserve_active: bool = field(default=False, metadata={ + "topic": "chargemode_config/bat/power_reserve_active"}) + power_discharge: int = field(default=1500, metadata={ + "topic": "chargemode_config/bat/power_discharge"}) + power_discharge_active: bool = field(default=False, metadata={ + "topic": "chargemode_config/bat/power_discharge_active"}) + min_soc: int = field(default=50, metadata={ + "topic": "chargemode_config/bat/min_soc"}) + max_soc: int = field(default=70, metadata={ + "topic": "chargemode_config/bat/max_soc"}) + mode: BatConsiderationMode = field(default=BatConsiderationMode.EV_MODE, metadata={ + "topic": "chargemode_config/bat/mode"}) + + +@dataclass +class ChargemodeConfigSurplusVehicle: phase_switch_delay: int = field(default=7, metadata={ - "topic": "chargemode_config/pv_charging/phase_switch_delay"}) - bat_power_discharge: int = field(default=1500, metadata={ - "topic": "chargemode_config/pv_charging/bat_power_discharge"}) - bat_power_discharge_active: bool = field(default=False, metadata={ - "topic": "chargemode_config/pv_charging/bat_power_discharge_active"}) - min_bat_soc: int = field(default=50, metadata={ - "topic": "chargemode_config/pv_charging/min_bat_soc"}) - max_bat_soc: int = field(default=70, metadata={ - "topic": "chargemode_config/pv_charging/max_bat_soc"}) - bat_mode: BatConsiderationMode = field(default=BatConsiderationMode.EV_MODE.value, metadata={ - "topic": "chargemode_config/pv_charging/bat_mode"}) + "topic": "chargemode_config/surplus/vehicle/phase_switch_delay"}) retry_failed_phase_switches: bool = field( default=False, - metadata={"topic": "chargemode_config/pv_charging/retry_failed_phase_switches"}) + metadata={"topic": "chargemode_config/surplus/vehicle/retry_failed_phase_switches"}) switch_off_delay: int = field(default=60, metadata={ - "topic": "chargemode_config/pv_charging/switch_off_delay"}) + "topic": "chargemode_config/surplus/vehicle/switch_off_delay"}) switch_off_threshold: int = field(default=0, metadata={ - "topic": "chargemode_config/pv_charging/switch_off_threshold"}) + "topic": "chargemode_config/surplus/vehicle/switch_off_threshold"}) switch_on_delay: int = field(default=30, metadata={ - "topic": "chargemode_config/pv_charging/switch_on_delay"}) + "topic": "chargemode_config/surplus/vehicle/switch_on_delay"}) switch_on_threshold: int = field(default=1500, metadata={ - "topic": "chargemode_config/pv_charging/switch_on_threshold"}) + "topic": "chargemode_config/surplus/vehicle/switch_on_threshold"}) -def pv_charging_factory() -> PvCharging: - return PvCharging() +@dataclass +class ChargemodeConfigSurplusConsumer: + switch_off_delay: int = field(default=60, metadata={ + "topic": "chargemode_config/surplus/consumer/switch_off_delay"}) + switch_off_threshold: int = field(default=0, metadata={ + "topic": "chargemode_config/surplus/consumer/switch_off_threshold"}) + switch_on_delay: int = field(default=30, metadata={ + "topic": "chargemode_config/surplus/consumer/switch_on_delay"}) + + +@dataclass +class ChargemodeConfigSurplus: + control_range: List = field(default_factory=control_range_factory, metadata={ + "topic": "chargemode_config/surplus/control_range"}) + feed_in_limit: bool = field(default=False, metadata={ + "topic": "chargemode_config/surplus/feed_in_limit"}) + feed_in_yield: int = field(default=15000, metadata={ + "topic": "chargemode_config/surplus/feed_in_yield"}) + consumer: ChargemodeConfigSurplusConsumer = field( + default_factory=lambda: ChargemodeConfigSurplusConsumer()) + vehicle: ChargemodeConfigSurplusVehicle = field( + default_factory=lambda: ChargemodeConfigSurplusVehicle()) @dataclass class ChargemodeConfig: - pv_charging: PvCharging = field(default_factory=pv_charging_factory) unbalanced_load_limit: int = field( default=18, metadata={"topic": "chargemode_config/unbalanced_load_limit"}) unbalanced_load: bool = field(default=False, metadata={ "topic": "chargemode_config/unbalanced_load"}) + surplus: ChargemodeConfigSurplus = field(default_factory=lambda: ChargemodeConfigSurplus()) + bat: ChargemodeConfigBat = field(default_factory=lambda: ChargemodeConfigBat()) def chargemode_config_factory() -> ChargemodeConfig: @@ -146,3 +167,10 @@ def grid_protection(self): self.data.grid_protection_random_stop = 0 except Exception: log.exception("Fehler im General-Modul") + + def get_feed_in_yield(self) -> int: + """ Liefert den aktuellen Feed-In-Ertrag in Watt zurück. """ + if self.data.chargemode_config.surplus.feed_in_limit: + return self.data.chargemode_config.surplus.feed_in_yield + else: + return 0 diff --git a/packages/control/load.py b/packages/control/load.py new file mode 100644 index 0000000000..abd9bb666a --- /dev/null +++ b/packages/control/load.py @@ -0,0 +1,44 @@ + +import logging + +from control import data +from control.chargepoint.chargepoint import Chargepoint +from control.chargepoint.chargepoint_state import ChargepointState +from control.load_protocol import Load + + +log = logging.getLogger(__name__) + + +def no_charge() -> None: + """ Wenn keine EV angesteckt sind oder keine Verzögerungen aktiv sind, werden die Algorithmus-Werte + zurückgesetzt. + (dient der Robustheit) + """ + try: + for load in list(data.data.cp_data.values()) + list(data.data.consumer_data.values()): + try: + control_parameter = load.data.control_parameter + # Kein EV, das Laden soll + # Kein EV, das auf das Ablaufen der Einschalt- oder Phasenumschaltverzögerung wartet + if (control_parameter.state != ChargepointState.PERFORMING_PHASE_SWITCH and + control_parameter.state != ChargepointState.PHASE_SWITCH_DELAY and + control_parameter.state != ChargepointState.SWITCH_OFF_DELAY and + control_parameter.state != ChargepointState.SWITCH_ON_DELAY and + control_parameter.state != ChargepointState.NO_CHARGING_ALLOWED): + continue + else: + break + except Exception: + log.exception("Fehler beim Bereinigen der Werte für Ladepunkt/Verbraucher "+load.num) + else: + data.data.counter_all_data.get_evu_counter().reset_pv_data() + except Exception: + log.exception("Fehler beim Bereinigen der Werte") + + +def get_load_str(load: Load) -> str: + if isinstance(load, Chargepoint): + return f"Ladepunkt {load.num}" + else: + return f"Verbraucher {load.num}" diff --git a/packages/control/load_protocol.py b/packages/control/load_protocol.py new file mode 100644 index 0000000000..5a6b33de45 --- /dev/null +++ b/packages/control/load_protocol.py @@ -0,0 +1,64 @@ +from dataclasses import dataclass +from typing import List, Optional, Protocol + +from control.chargepoint.control_parameter import ControlParameter +from helpermodules.timecheck import create_timestamp + + +@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 set_timestamp_charge_start(self) -> None: + # Beim Ladestart Timer laufen lassen, manche Fahrzeuge brauchen sehr lange. + # Nach dem Algorithmus setzen, sonst steht set current noch nicht fest. + if self.data.control_parameter.timestamp_charge_start is None: + if self.data.set.current_prev == 0 and self.data.set.current != 0: + self.data.control_parameter.timestamp_charge_start = create_timestamp() + elif self.data.set.current == 0: + self.data.control_parameter.timestamp_charge_start = None + + 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 ce7ed4e8db..764e3332f0 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,19 +18,19 @@ 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_loadmanager(available_currents, cp) + available_currents, new_limit = self._limit_loadmanager(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 @@ -38,8 +39,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(): @@ -51,21 +54,23 @@ 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 + surplus_config = data.data.general_data.data.chargemode_config.surplus + feed_in_yield = surplus_config.feed_in_yield if surplus_config.feed_in_limit else None 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, feed_in_yield) 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(): @@ -100,7 +105,7 @@ def _limit_by_power(self, available_currents: List[float], cp_voltage: float, raw_power_left: Optional[float], - feed_in: Optional[float]) -> Tuple[List[float], LoadmanagementLimit]: + feed_in: Optional[float] = None) -> Tuple[List[float], LoadmanagementLimit]: # Mittelwert der Spannungen verwenden, um Phasenverdrehung zu kompensieren # (Probleme bei einphasig angeschlossenen Wallboxen) currents = available_currents.copy() @@ -144,13 +149,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 @@ -158,8 +164,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..9c75fae169 100644 --- a/packages/control/loadmanagement_test.py +++ b/packages/control/loadmanagement_test.py @@ -33,7 +33,7 @@ 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) + ret = Loadmanagement()._limit_by_power(Counter(0), available_currents, 230, raw_power_left) # assertion assert ret == expected_ret diff --git a/packages/control/prepare.py b/packages/control/prepare.py index e10429952e..e72620d8e6 100644 --- a/packages/control/prepare.py +++ b/packages/control/prepare.py @@ -3,7 +3,7 @@ import logging -from control import data +from control import data, load from modules.common.component_type import ComponentType @@ -24,8 +24,11 @@ def setup_algorithm(self) -> None: data.data.bat_all_data.calc_power_for_all_components() for cp in data.data.cp_data.values(): cp.setup_values_at_start() - levels = data.data.counter_all_data.get_list_of_elements_per_level() - for level in reversed(levels): + # Für die Initialisierung des LM muss bekannt sein, ob der Soll-Strom des Verbrauchers in diesem Intervall + # angepasst wird (switch_interval_elapsed). + for consumer in data.data.consumer_data.values(): + consumer.update() + for level in reversed(data.data.counter_all_data.get_list_of_elements_per_level()): for element in level: if element["type"] == ComponentType.COUNTER.value: data.data.counter_data[f"counter{element['id']}"].setup_counter() @@ -33,8 +36,9 @@ def setup_algorithm(self) -> None: 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() + load.no_charge() data.data.counter_all_data.set_home_consumption() except Exception: log.exception("Fehler im Prepare-Modul") diff --git a/packages/control/process.py b/packages/control/process.py index 9fa439b828..99e6d73055 100644 --- a/packages/control/process.py +++ b/packages/control/process.py @@ -8,10 +8,16 @@ 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_consumer import CurrentValues from modules.common.abstract_io import AbstractIoDevice +from modules.common.configurable_consumer import SetLimitData from modules.common.configurable_device import set_power_limit_wrapper from modules.common.fault_state_level import FaultStateLevel from modules.io_actions.controllable_consumers.dimming.api_io import DimmingIo @@ -42,7 +48,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 +77,23 @@ def process_algorithm_results(self) -> None: args=(bat_component, data.data.bat_data[f"bat{bat_component.component_config.id}"].data.set.power_limit), name=f"set power limit {bat_component.component_config.id}")) + for consumer in data.data.consumer_data.values(): + try: + self._update_state_consumer(consumer) + if consumer.data.get.state_str is None: + if consumer.data.usage.type == ConsumerUsage.METER_ONLY: + consumer.data.get.state_str = "Messwerte des Verbrauchers werden erfasst." + elif consumer.data.usage.type == ConsumerUsage.SELF_CONTROLLED: + consumer.data.get.state_str = "Messwerte werden an den Verbraucher übermittelt." + else: + 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 +132,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 +172,46 @@ 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: + control_parameter = consumer.data.control_parameter + + 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 control_parameter.state not in CHARGING_STATES: + control_parameter.state = ChargepointState.CHARGING_ALLOWED + if control_parameter.state != ChargepointState.NO_CHARGING_ALLOWED or consumer.data.set.current != 0: + consumer.set_timestamp_charge_start() + 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(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}") + elif consumer.data.usage.type == ConsumerUsage.SELF_CONTROLLED: + current_values = CurrentValues( + bat_power=data.data.bat_all_data.data.get.power, + bat_soc=data.data.bat_all_data.data.get.soc, + cp_power=data.data.cp_all_data.data.get.power, + evu_power=data.data.counter_all_data.get_evu_counter().data.get.power, + home_consumption=data.data.counter_all_data.data.set.home_consumption, + pv_power=data.data.pv_all_data.data.get.power + ) + return Thread(target=consumer.module.send_values, + args=(current_values,), + name=f"send values consumer{consumer.num}") + else: + set_limit_data = SetLimitData(max_power=consumer.data.config.max_power) + return Thread(target=consumer.module.set_power_limit, + args=(consumer.data.set.power, set_limit_data), + name=f"set current consumer{consumer.num}") diff --git a/packages/dataclass_utils/_dataclass_asdict.py b/packages/dataclass_utils/_dataclass_asdict.py index 5d43362065..af13a567ca 100644 --- a/packages/dataclass_utils/_dataclass_asdict.py +++ b/packages/dataclass_utils/_dataclass_asdict.py @@ -1,11 +1,15 @@ from enum import Enum import logging +from typing import Any, Dict, List, Union, cast log = logging.getLogger(__name__) -def asdict(value): +AsDictValue = Union[None, bool, int, float, str, List["AsDictValue"], Dict[Any, "AsDictValue"]] + + +def asdict(value: Any) -> AsDictValue: """Converts an object to a dict This function is a simple replacement for the `dataclasses.asdict` function introduced in Python 3.7. This function @@ -17,7 +21,9 @@ def asdict(value): if isinstance(value, Enum): return value.value if isinstance(value, (list, tuple)): - return [None if v is None else asdict(v) for v in value] + sequence = cast(List[Any], value) + return [None if item is None else asdict(item) for item in sequence] if not isinstance(value, dict): - value = vars(value) - return {key: None if value is None else asdict(value) for key, value in value.items()} + value = vars(cast(object, value)) + mapping = cast(Dict[Any, Any], value) + return {key: None if item is None else asdict(item) for key, item in mapping.items()} 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..0853b90011 100644 --- a/packages/helpermodules/changed_values_handler.py +++ b/packages/helpermodules/changed_values_handler.py @@ -6,7 +6,7 @@ from control import data from control.data import Data -from dataclass_utils._dataclass_asdict import asdict +from dataclass_utils import asdict from helpermodules.pub import Pub @@ -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/changed_values_handler_test.py b/packages/helpermodules/changed_values_handler_test.py index 189912e814..48c13a4ba3 100644 --- a/packages/helpermodules/changed_values_handler_test.py +++ b/packages/helpermodules/changed_values_handler_test.py @@ -1,10 +1,11 @@ -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field from enum import IntEnum from typing import Dict, List, Optional, Tuple from unittest.mock import Mock import pytest +from dataclass_utils import asdict from dataclass_utils.factories import currents_list_factory from helpermodules.changed_values_handler import ChangedValuesHandler diff --git a/packages/helpermodules/command.py b/packages/helpermodules/command.py index 2b429f135e..af0c5d75ad 100644 --- a/packages/helpermodules/command.py +++ b/packages/helpermodules/command.py @@ -1,5 +1,4 @@ import copy -from dataclasses import asdict import importlib import json import logging @@ -13,14 +12,21 @@ from pathlib import Path import paho.mqtt.client as mqtt +from control import bat, bridge, counter, pv from control.chargelog.process_chargelog import get_log_data 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 import ev 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 dataclass_utils import asdict 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,12 +45,9 @@ 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.ev import ev from modules.chargepoints.internal_openwb.chargepoint_module import ChargepointModule from modules.chargepoints.internal_openwb.config import InternalChargepointMode from modules.common.component_type import ComponentType, special_to_general_type_mapping, type_to_topic_mapping -import dataclass_utils from modules.common.configurable_vehicle import GeneralVehicleConfig @@ -59,7 +62,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 +91,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 +103,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: @@ -176,7 +183,7 @@ def addDevice(self, connection_id: str, payload: dict) -> None: dev = importlib.import_module(f'.devices.{payload["data"]["vendor"]}' f'.{payload["data"]["type"]}.device', "modules") - device_default = dataclass_utils.asdict(dev.device_descriptor.configuration_factory()) + device_default = asdict(dev.device_descriptor.configuration_factory()) device_default["id"] = new_id Pub().pub(f'openWB/set/system/device/{new_id}/config', device_default) self.max_id_device = self.max_id_device + 1 @@ -204,7 +211,7 @@ def addIoAction(self, connection_id: str, payload: dict) -> None: 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 = 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 @@ -238,7 +245,7 @@ def addIoDevice(self, connection_id: str, payload: dict) -> None: new_id = self.max_id_io_device + 1 dev = importlib.import_module(".io_devices."+payload["data"]["type"]+".api", "modules") descriptor = dev.device_descriptor.configuration_factory() - device_default = dataclass_utils.asdict(descriptor) + device_default = asdict(descriptor) device_default["id"] = new_id Pub().pub(f'openWB/set/system/io/{new_id}/config', device_default) self.max_id_io_device = new_id @@ -279,7 +286,7 @@ def setup_added_chargepoint(): Pub().pub(f'openWB/chargepoint/{new_id}/config', chargepoint_config) {Pub().pub(f"openWB/chargepoint/{new_id}/get/"+k, v) for (k, v) in asdict(chargepoint.Get()).items()} charge_template = SubData.ev_charge_template_data[f"ct{SubData.ev_data['ev0'].data.charge_template}"] - charge_template = dataclass_utils.asdict(charge_template.data) + charge_template = asdict(charge_template.data) Pub().pub(f'openWB/chargepoint/{new_id}/set/charge_template', charge_template) Pub().pub(f'openWB/chargepoint/{new_id}/set/manual_lock', False) self.max_id_hierarchy = self.max_id_hierarchy + 1 @@ -300,7 +307,7 @@ def setup_added_chargepoint(): chargepoint_config = chargepoint.get_chargepoint_config_default() # chargepoint_default["id"] = new_id module = importlib.import_module(".chargepoints." + payload["data"]["type"] + ".chargepoint_module", "modules") - chargepoint_config.update(dataclass_utils.asdict( + chargepoint_config.update(asdict( module.chargepoint_descriptor.configuration_factory()).items()) check_num_msg = self._check_max_num_of_internal_chargepoints(chargepoint_config) if check_num_msg is not None: @@ -491,7 +498,7 @@ def removeAutolockPlan(self, connection_id: str, payload: dict) -> None: break Pub().pub( f'openWB/set/chargepoint/template/{payload["data"]["template"]}', - dataclass_utils.asdict(SubData.cp_template_data[f'cpt{payload["data"]["template"]}'].data)) + asdict(SubData.cp_template_data[f'cpt{payload["data"]["template"]}'].data)) pub_user_message( payload, connection_id, f'Plan für Sperren nach Uhrzeit mit ID \'{payload["data"]["plan"]}\' vom Profil ' @@ -563,11 +570,11 @@ def _pub_charge_template_to_source(self, payload: dict, charge_template: ChargeT if payload["data"]["changed_in_theme"]: Pub().pub( f'openWB/set/chargepoint/{payload["data"]["chargepoint"]}/set/charge_template', - dataclass_utils.asdict(charge_template.data)) + asdict(charge_template.data)) else: Pub().pub( f'openWB/set/vehicle/template/charge_template/{payload["data"]["template"]}', - dataclass_utils.asdict(charge_template.data)) + asdict(charge_template.data)) def addChargeTemplateSchedulePlan(self, connection_id: str, payload: dict) -> None: """ sendet das Topic, zu dem ein neuer Zielladen-Plan erstellt werden soll. @@ -667,7 +674,7 @@ def set_default(topic: str, defaults: dict): component = importlib.import_module(f'.devices.{payload["data"]["deviceVendor"]}' f'.{payload["data"]["deviceType"]}.{payload["data"]["type"]}', "modules") - component_default = dataclass_utils.asdict(component.component_descriptor.configuration_factory()) + component_default = asdict(component.component_descriptor.configuration_factory()) component_default["id"] = new_id general_type = special_to_general_type_mapping(payload["data"]["type"]) try: @@ -729,7 +736,7 @@ def addEvTemplate(self, connection_id: str, payload: dict) -> None: new_ev_template = asdict(SubData.ev_template_data[f"et{payload['data']['copy']}"].data).copy() new_ev_template["name"] = f'Kopie von {new_ev_template["name"]}' else: - new_ev_template = dataclass_utils.asdict(EvTemplateData()) + new_ev_template = asdict(EvTemplateData()) new_ev_template["name"] = f'{new_ev_template["name"]} {new_id}' new_ev_template["id"] = new_id self.max_id_ev_template = new_id @@ -763,8 +770,7 @@ def addVehicle(self, connection_id: str, payload: dict) -> None: for default in vehicle_default: Pub().pub(f"openWB/set/vehicle/{new_id}/{default}", vehicle_default[default]) Pub().pub(f"openWB/set/vehicle/{new_id}/soc_module/config", {"type": None, "configuration": {}}) - Pub().pub(f"openWB/set/vehicle/{new_id}/soc_module/general_config", - dataclass_utils.asdict(GeneralVehicleConfig())) + Pub().pub(f"openWB/set/vehicle/{new_id}/soc_module/general_config", asdict(GeneralVehicleConfig())) self.max_id_vehicle = self.max_id_vehicle + 1 Pub().pub("openWB/set/command/max_id/vehicle", self.max_id_vehicle) # Default-Mäßig werden die Profile 0 zugewiesen, wenn diese noch nicht existieren -> anlegen @@ -775,6 +781,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 +799,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 +1160,155 @@ 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 = asdict(dev.device_descriptor.configuration_factory()) + consumer_default["id"] = 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) + return + 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().pub(f'openWB/set/consumer/{new_id}/module', consumer_default) + Pub().pub(f"openWB/set/consumer/{new_id}/config", asdict(ConsumerConfig())) + for (k, v) in asdict(ConsumerGet()).items(): + Pub().pub(f"openWB/set/consumer/{new_id}/get/"+k, v) + for (k, v) in 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", asdict(Usage())) + self.max_id_hierarchy = new_id + Pub().pub("openWB/set/command/max_id/hierarchy", new_id) + 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', 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', 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', 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', 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 63ab81457f..1e7e72183d 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 @@ -113,21 +113,21 @@ def config_and_state(): chargemode_config = data.data.general_data.data.chargemode_config parsed_data += ( "\n## General Charge Config/ PV ##\n" - f"Phase_Switch_Delay: {chargemode_config.pv_charging.phase_switch_delay} min\n" - f"Retry_Failed_Phase_Switches: {chargemode_config.pv_charging.retry_failed_phase_switches}\n" - f"Control_Range: {chargemode_config.pv_charging.control_range}W\n" - f"Switch_On_Threshold: {chargemode_config.pv_charging.switch_on_threshold}W\n" - 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"Bat_Mode: {chargemode_config.pv_charging.bat_mode}\n" - f"Min_Bat_SoC: {chargemode_config.pv_charging.min_bat_soc}%\n" - f"Max_Bat_SoC: {chargemode_config.pv_charging.max_bat_soc}%\n" - f"Bat_Power_Reserve_Active: {chargemode_config.pv_charging.bat_power_reserve_active}\n" - f"Bat_Power_Reserve: {chargemode_config.pv_charging.bat_power_reserve}W\n" - f"Bat_Power_Discharge_Active: {chargemode_config.pv_charging.bat_power_discharge_active}\n" - f"Bat_Power_Discharge: {chargemode_config.pv_charging.bat_power_discharge}W\n") + f"Phase_Switch_Delay: {chargemode_config.surplus.vehicle.phase_switch_delay} min\n" + f"Retry_Failed_Phase_Switches: {chargemode_config.surplus.vehicle.retry_failed_phase_switches}\n" + f"Control_Range: {chargemode_config.surplus.control_range}W\n" + f"Switch_On_Threshold: {chargemode_config.surplus.vehicle.switch_on_threshold}W\n" + f"Switch_On_Delay: {chargemode_config.surplus.vehicle.switch_on_delay}s\n" + f"Switch_Off_Threshold: {chargemode_config.surplus.vehicle.switch_off_threshold}W\n" + f"Switch_Off_Delay: {chargemode_config.surplus.vehicle.switch_off_delay}s\n" + f"Feed_In_Yield: {chargemode_config.surplus.feed_in_yield}W\n" + f"Bat_Mode: {chargemode_config.bat.mode}\n" + f"Min_Bat_SoC: {chargemode_config.bat.min_soc}%\n" + f"Max_Bat_SoC: {chargemode_config.bat.max_soc}%\n" + f"Bat_Power_Reserve_Active: {chargemode_config.bat.power_reserve_active}\n" + f"Bat_Power_Reserve: {chargemode_config.bat.power_reserve}W\n" + f"Bat_Power_Discharge_Active: {chargemode_config.bat.power_discharge_active}\n" + f"Bat_Power_Discharge: {chargemode_config.bat.power_discharge}W\n") if secondary is False: with ErrorHandlingContext(): parsed_data += f"\n## Hierarchy ##\n{get_hierarchy(data.data.counter_all_data.data.get.hierarchy)}\n" @@ -465,9 +465,9 @@ def write_to_file(file_handler, func, default: Optional[Any] = None): json_rsp = req.get_http_session().put("https://debughandler.wb-solution.de", data=data, params={ - 'debugemail': debug_email, - 'ticketnumber': ticketnumber, - 'subject': subject + 'debugemail': debug_email, + 'ticketnumber': ticketnumber, + 'subject': subject }, timeout=10).json() diff --git a/packages/helpermodules/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..9ff7c45c1a 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: @@ -324,6 +324,7 @@ def add_monthly_log(month: str, check_next_month: bool = False) -> None: log.debug(f"Keine Logdatei für Monat {next_month} gefunden, " f"füge letzten Datensatz von {month} ein: {entries[-1]['date']}") names.update(content["names"]) + colors.update(content["colors"]) except FILE_ERRORS: log.debug(f"Kein Log für Monat {month} gefunden.") @@ -338,6 +339,7 @@ def add_daily_log(day: str) -> None: entries = [] names = {} + colors = {} dates = [] # we have to find a valid data range @@ -378,7 +380,7 @@ def add_daily_log(day: str) -> None: log.exception(f"Fehler beim Zusammenstellen der Jahresdaten für Monat {next_date}") # return our data - return {"entries": entries, "names": names} + return {"entries": entries, "names": names, "colors": colors} def _analyse_energy_source(data, calc_cp: Optional[str] = None) -> Dict: @@ -509,6 +511,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 +547,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 +566,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 +590,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 +615,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..c9a2cb3313 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, @@ -249,7 +260,14 @@ def create_entry(log_type: LogType, sh_log_data: LegacySmartHomeLogData, previou for counter in data.data.counter_data.values(): try: home_consumption_source_id = data.data.counter_all_data.data.config.home_consumption_source_id - if (home_consumption_source_id is None or counter.num != home_consumption_source_id): + if home_consumption_source_id is not None and counter.num == home_consumption_source_id: + continue + skip_counter = False + for consumer in data.data.consumer_data: + if counter.num == data.data.consumer_data[consumer].data.extra_meter: + skip_counter = True + break + if skip_counter is False: counter_dict.update( {f"counter{counter.num}": { "imported": counter.data.get.imported, @@ -292,6 +310,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 +342,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 +387,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 +403,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 +425,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 7c03072896..08b35f6c30 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -2,7 +2,6 @@ """ import copy -import dataclasses from pathlib import Path from threading import Event from typing import List, Optional, Tuple @@ -105,6 +104,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): @@ -170,7 +171,7 @@ def _validate_value(self, msg: mqtt.MQTTMessage, data_type, ranges=[], collectio elif re.search("^openWB/set/chargepoint/[1-9][0-9]*/config.*$", msg.topic) is not None: event = self.event_cp_config if "cp"+str(index) in subdata.SubData.cp_data: - template = dataclasses.asdict( + template = dataclass_utils.asdict( subdata.SubData.cp_data["cp"+str(index)].chargepoint.data.config) else: template = {} @@ -512,34 +513,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 +524,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,28 +771,32 @@ 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/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): + "openWB/set/general/chargemode_config/surplus/feed_in_limit" in msg.topic or + "openWB/set/general/chargemode_config/surplus/vehicle/retry_failed_phase_switches" in msg.topic or + "openWB/set/general/chargemode_config/bat/power_discharge_active" in msg.topic or + "openWB/set/general/chargemode_config/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 - "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): + elif ("openWB/set/general/chargemode_config/surplus/feed_in_yield" in msg.topic or + "openWB/set/general/chargemode_config/surplus/vehicle/switch_on_threshold" in msg.topic or + "openWB/set/general/chargemode_config/surplus/vehicle/switch_on_delay" in msg.topic or + "openWB/set/general/chargemode_config/surplus/vehicle/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/surplus/vehicle/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: + elif "openWB/set/general/chargemode_config/surplus/vehicle/phase_switch_delay" in msg.topic: self._validate_value(msg, int, [(5, 180)]) - elif "openWB/set/general/chargemode_config/pv_charging/control_range" in msg.topic: + elif "openWB/set/general/chargemode_config/surplus/control_range" in msg.topic: self._validate_value(msg, int, collection=list) - elif ("openWB/set/general/chargemode_config/pv_charging/min_bat_soc" in msg.topic or - "openWB/set/general/chargemode_config/pv_charging/max_bat_soc" in msg.topic): + elif ("openWB/set/general/chargemode_config/bat/min_soc" in msg.topic or + "openWB/set/general/chargemode_config/bat/max_soc" in msg.topic): self._validate_value(msg, int, [(0, 100)]) - elif ("openWB/set/general/chargemode_config/pv_charging/bat_power_discharge" in msg.topic or - "openWB/set/general/chargemode_config/pv_charging/bat_power_reserve" in msg.topic): + elif ("openWB/set/general/chargemode_config/bat/power_discharge" in msg.topic or + "openWB/set/general/chargemode_config/bat/power_reserve" in msg.topic): self._validate_value(msg, float, [(0, float("inf"))]) - elif "openWB/set/general/chargemode_config/pv_charging/bat_mode" in msg.topic: + elif "openWB/set/general/chargemode_config/bat/mode" in msg.topic: self._validate_value(msg, str) elif "openWB/set/general/chargemode_config/" in msg.topic and "/phases_to_use" in msg.topic: self._validate_value(msg, int, [(1, 1), (3, 3)]) @@ -845,6 +857,15 @@ 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|bat_power|bat_soc|cp_power|evu_power|home_consumption|pv_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: @@ -933,7 +954,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) @@ -1216,6 +1238,58 @@ 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]+/set/timestamp_wrote_last_on_time$", 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): + self._validate_value(msg, float, [(0, float("inf"))]) + elif (re.search("consumer/[0-9]+/get/fault_state$", msg.topic) is not None or + re.search("consumer/get/fault_state$", msg.topic) is not None): + self._validate_value(msg, int, [(0, 2)]) + elif (re.search("consumer/get/fault_str$", msg.topic) is not None or + 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 884ad3e0f4..64bcdf07b8 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: @@ -629,8 +641,14 @@ def process_general_topic(self, var: general.General, msg: mqtt.MQTTMessage): if re.search("/general/prices/", msg.topic) is not None: self.set_json_payload_class(var.data.prices, msg) elif re.search("/general/chargemode_config/", msg.topic) is not None: - if re.search("/general/chargemode_config/pv_charging/", msg.topic) is not None: - self.set_json_payload_class(var.data.chargemode_config.pv_charging, msg) + if re.search("/general/chargemode_config/surplus/consumer", msg.topic) is not None: + self.set_json_payload_class(var.data.chargemode_config.surplus.consumer, msg) + elif re.search("/general/chargemode_config/surplus/vehicle", msg.topic) is not None: + self.set_json_payload_class(var.data.chargemode_config.surplus.vehicle, msg) + elif re.search("/general/chargemode_config/surplus/", msg.topic) is not None: + self.set_json_payload_class(var.data.chargemode_config.surplus, msg) + elif re.search("/general/chargemode_config/bat/", msg.topic) is not None: + self.set_json_payload_class(var.data.chargemode_config.bat, msg) else: self.set_json_payload_class(var.data.chargemode_config, msg) elif "openWB/general/extern" == msg.topic: @@ -1216,3 +1234,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 c35ba3b974..2c6908b9a2 100644 --- a/packages/helpermodules/update_config.py +++ b/packages/helpermodules/update_config.py @@ -1,6 +1,5 @@ -from concurrent.futures import ProcessPoolExecutor +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor import copy -from dataclasses import asdict import datetime import glob import importlib @@ -12,8 +11,8 @@ 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 from control.chargepoint.chargepoint_template import get_chargepoint_template_default from helpermodules import timecheck @@ -34,15 +33,16 @@ 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 from control.ev.charge_template import EcoCharging, get_charge_template_default from control.ev import ev from control.ev.ev_template import EvTemplateData -from control.general import Prices, PvCharging +from control.general import ChargemodeConfigSurplusVehicle, Prices from control.optional_data import OcppConfig +from dataclass_utils import asdict from modules.common.abstract_vehicle import GeneralVehicleConfig from modules.common.component_type import ComponentType from modules.devices.sungrow.sungrow_sh.version import Version @@ -58,7 +58,7 @@ class UpdateConfig: - DATASTORE_VERSION = 139 + DATASTORE_VERSION = 142 valid_topic = [ "^openWB/bat/config/bat_control_activated$", @@ -174,6 +174,8 @@ class UpdateConfig: "^openWB/command/max_id/charge_template$", "^openWB/command/max_id/charge_template_scheduled_plan$", "^openWB/command/max_id/charge_template_time_charging_plan$", + "^openWB/command/max_id/consumer_scheduled_plan$", + "^openWB/command/max_id/consumer_time_plan$", "^openWB/command/max_id/chargepoint_template$", "^openWB/command/max_id/device$", "^openWB/command/max_id/ev_template$", @@ -185,9 +187,35 @@ 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/timestamp_wrote_last_on_time$", + "^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,21 +257,25 @@ class UpdateConfig: "^openWB/general/temporary_charge_templates_active$", "^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/pv_charging/switch_on_threshold$", - "^openWB/general/chargemode_config/pv_charging/switch_on_delay$", - "^openWB/general/chargemode_config/pv_charging/switch_off_threshold$", - "^openWB/general/chargemode_config/pv_charging/switch_off_delay$", - "^openWB/general/chargemode_config/pv_charging/phase_switch_delay$", - "^openWB/general/chargemode_config/pv_charging/control_range$", - "^openWB/general/chargemode_config/pv_charging/min_bat_soc$", - "^openWB/general/chargemode_config/pv_charging/max_bat_soc$", - "^openWB/general/chargemode_config/pv_charging/bat_power_discharge$", - "^openWB/general/chargemode_config/pv_charging/bat_power_discharge_active$", - "^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/chargemode_config/surplus/feed_in_limit$", + "^openWB/general/chargemode_config/bat/mode$", + "^openWB/general/chargemode_config/surplus/feed_in_yield$", + "^openWB/general/chargemode_config/surplus/vehicle/switch_on_threshold$", + "^openWB/general/chargemode_config/surplus/vehicle/switch_on_delay$", + "^openWB/general/chargemode_config/surplus/vehicle/switch_off_threshold$", + "^openWB/general/chargemode_config/surplus/vehicle/switch_off_delay$", + "^openWB/general/chargemode_config/surplus/vehicle/phase_switch_delay$", + "^openWB/general/chargemode_config/surplus/control_range$", + "^openWB/general/chargemode_config/bat/min_soc$", + "^openWB/general/chargemode_config/bat/max_soc$", + "^openWB/general/chargemode_config/bat/power_discharge$", + "^openWB/general/chargemode_config/bat/power_discharge_active$", + "^openWB/general/chargemode_config/bat/power_reserve$", + "^openWB/general/chargemode_config/bat/power_reserve_active$", + "^openWB/general/chargemode_config/surplus/vehicle/retry_failed_phase_switches$", + "^openWB/general/chargemode_config/surplus/consumer/switch_on_delay$", + "^openWB/general/chargemode_config/surplus/consumer/switch_off_delay$", + "^openWB/general/chargemode_config/surplus/consumer/switch_off_threshold$", # obsolet, Daten hieraus müssen nach prices/ überführt werden "^openWB/general/price_kwh$", "^openWB/general/prices/bat$", @@ -316,6 +348,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$", @@ -501,6 +541,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$", @@ -587,14 +628,15 @@ 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}), ("openWB/vehicle/0/charge_template", ev.Ev(0).charge_template.data.id), ("openWB/vehicle/0/soc_module/config", NO_MODULE), - ("openWB/vehicle/0/soc_module/general_config", dataclass_utils.asdict(GeneralVehicleConfig())), + ("openWB/vehicle/0/soc_module/general_config", asdict(GeneralVehicleConfig())), ("openWB/vehicle/0/ev_template", ev.Ev(0).ev_template.data.id), ("openWB/vehicle/0/tag_id", ev.Ev(0).data.tag_id), ("openWB/vehicle/0/get/soc", ev.Ev(0).data.get.soc), @@ -603,22 +645,26 @@ class UpdateConfig: ("openWB/vehicle/template/charge_template/0", get_charge_template_default()), ("openWB/general/allow_unencrypted_access", True), ("openWB/general/charge_log_data_config", get_default_charge_log_columns()), - ("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/min_bat_soc", 50), - ("openWB/general/chargemode_config/pv_charging/max_bat_soc", 70), - ("openWB/general/chargemode_config/pv_charging/bat_power_reserve", 200), - ("openWB/general/chargemode_config/pv_charging/bat_power_reserve_active", True), - ("openWB/general/chargemode_config/pv_charging/control_range", [0, 230]), - ("openWB/general/chargemode_config/pv_charging/switch_off_threshold", 0), - ("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/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/bat/mode", BatConsiderationMode.EV_MODE.value), + ("openWB/general/chargemode_config/bat/power_discharge", 1000), + ("openWB/general/chargemode_config/bat/power_discharge_active", True), + ("openWB/general/chargemode_config/bat/min_soc", 50), + ("openWB/general/chargemode_config/bat/max_soc", 70), + ("openWB/general/chargemode_config/bat/power_reserve", 200), + ("openWB/general/chargemode_config/bat/power_reserve_active", True), + ("openWB/general/chargemode_config/surplus/control_range", [0, 230]), + ("openWB/general/chargemode_config/surplus/feed_in_limit", False), + ("openWB/general/chargemode_config/surplus/vehicle/switch_off_threshold", 0), + ("openWB/general/chargemode_config/surplus/vehicle/switch_off_delay", 60), + ("openWB/general/chargemode_config/surplus/vehicle/switch_on_delay", 30), + ("openWB/general/chargemode_config/surplus/vehicle/switch_on_threshold", 1500), + ("openWB/general/chargemode_config/surplus/feed_in_yield", 0), + ("openWB/general/chargemode_config/surplus/vehicle/phase_switch_delay", 7), + ("openWB/general/chargemode_config/surplus/vehicle/retry_failed_phase_switches", + ChargemodeConfigSurplusVehicle().retry_failed_phase_switches), + ("openWB/general/chargemode_config/surplus/consumer/switch_on_delay", 60), + ("openWB/general/chargemode_config/surplus/consumer/switch_off_delay", 60), + ("openWB/general/chargemode_config/surplus/consumer/switch_off_threshold", 0), ("openWB/general/chargemode_config/unbalanced_load", False), ("openWB/general/chargemode_config/unbalanced_load_limit", 18), ("openWB/general/control_interval", 10), @@ -632,7 +678,7 @@ class UpdateConfig: ("openWB/general/prices/pv", Prices().pv), ("openWB/general/range_unit", "km"), ("openWB/general/temporary_charge_templates_active", False), - ("openWB/general/web_theme", dataclass_utils.asdict(KoalaWebTheme())), + ("openWB/general/web_theme", asdict(KoalaWebTheme())), ("openWB/graph/config/duration", 120), ("openWB/internal_chargepoint/0/data/parent_cp", None), ("openWB/internal_chargepoint/1/data/parent_cp", None), @@ -645,10 +691,10 @@ class UpdateConfig: ("openWB/optional/int_display/pin_code", "0000"), ("openWB/optional/int_display/standby", 60), ("openWB/optional/int_display/rotation", 0), - ("openWB/optional/int_display/theme", dataclass_utils.asdict(CardsDisplayTheme())), + ("openWB/optional/int_display/theme", asdict(CardsDisplayTheme())), ("openWB/optional/int_display/only_local_charge_points", False), ("openWB/optional/monitoring/config", NO_MODULE), - ("openWB/optional/ocpp/config", dataclass_utils.asdict(OcppConfig())), + ("openWB/optional/ocpp/config", asdict(OcppConfig())), ("openWB/optional/rfid/active", False), ("openWB/pv/config/configured", False), ("openWB/system/backup_password", None), @@ -1095,8 +1141,7 @@ def upgrade(topic: str, payload) -> Optional[dict]: if re.search("openWB/vehicle/[0-9]+/soc_module/config", topic) is not None: payload = decode_payload(payload) index = get_index(topic) - return {f"openWB/set/vehicle/{index}/soc_module/interval_config": - dataclass_utils.asdict(GeneralVehicleConfig())} + return {f"openWB/set/vehicle/{index}/soc_module/interval_config": asdict(GeneralVehicleConfig())} self._loop_all_received_topics(upgrade) self._append_datastore_version(12) @@ -2179,15 +2224,15 @@ def upgrade(topic: str, payload) -> Optional[dict]: {"value": 0, "input_matrix": {"RSE1": True, "RSE2": False}}, {"value": 0, "input_matrix": {"RSE1": True, "RSE2": True}}] - return {'openWB/system/io/0/config': dataclass_utils.asdict(io_device), - 'openWB/io/action/0/config': dataclass_utils.asdict(action)} + return {'openWB/system/io/0/config': asdict(io_device), + 'openWB/io/action/0/config': asdict(action)} self._loop_all_received_topics(upgrade) self._append_datastore_version(75) def upgrade_datastore_76(self) -> None: def upgrade(topic: str, payload) -> Optional[dict]: if re.search("openWB/chargepoint/[0-9]+/control_parameter/limit", topic) is not None: - return {topic: dataclass_utils.asdict(LoadmanagementLimit(None, None))} + return {topic: asdict(LoadmanagementLimit(None, None))} self._loop_all_received_topics(upgrade) self._append_datastore_version(76) @@ -2299,7 +2344,7 @@ def get_new_phases_to_use(topic) -> int: payload = decode_payload(payload) charge_template = copy.deepcopy(payload) - charge_template["chargemode"]["eco_charging"] = dataclass_utils.asdict(EcoCharging()) + charge_template["chargemode"]["eco_charging"] = asdict(EcoCharging()) if payload["chargemode"]["selected"] == "standby": charge_template["chargemode"]["selected"] = "stop" if payload["et"]["active"] is True: @@ -2315,7 +2360,7 @@ def get_new_phases_to_use(topic) -> int: charge_template["chargemode"]["pv_charging"]["phases_to_use"] = get_new_phases_to_use( "openWB/general/chargemode_config/pv_charging/phases_to_use") charge_template["chargemode"]["pv_charging"]["phases_to_use_min_soc"] = min(max_phases_ev, 3) - charge_template["chargemode"]["pv_charging"]["limit"] = dataclass_utils.asdict(Limit()) + charge_template["chargemode"]["pv_charging"]["limit"] = asdict(Limit()) if payload["chargemode"]["pv_charging"]["max_soc"] == 101: charge_template["chargemode"]["pv_charging"]["limit"]["selected"] = "none" else: @@ -3526,3 +3571,111 @@ def upgrade(topic: str, payload) -> Optional[dict]: return {topic: payload_device} self._loop_all_received_topics(upgrade) self._append_datastore_version(139) + + def upgrade_datastore_140(self) -> None: + feed_in_limit = False + for topic, payload in self.all_received_topics.items(): + if re.search("^openWB/vehicle/template/charge_template/[0-9]+$", topic) is not None: + config = decode_payload(payload) + pv_config = config.get("chargemode", {}).get("pv_charging", {}) + if "feed_in_limit" in pv_config: + feed_in_limit = feed_in_limit or pv_config.pop("feed_in_limit") is True + self.__update_topic(topic, config) + self.__update_topic("openWB/general/chargemode_config/surplus/feed_in_limit", feed_in_limit) + + def move_topic(new_topic: str, old_topic: str) -> None: + if old_topic in self.all_received_topics: + payload = decode_payload(self.all_received_topics[old_topic]) + self.__update_topic(new_topic, payload) + self.__update_topic(old_topic, "") + log.debug(f"Moved topic '{old_topic}' to '{new_topic}' with value: {payload}") + + chargemode_config_prefix = "openWB/general/chargemode_config" + old_prefix = "openWB/general/chargemode_config/pv_charging" + move_topic(f"{chargemode_config_prefix}/bat/mode", f"{old_prefix}/bat_mode") + move_topic(f"{chargemode_config_prefix}/surplus/feed_in_yield", f"{old_prefix}/feed_in_yield") + move_topic(f"{chargemode_config_prefix}/surplus/vehicle/switch_on_threshold", + f"{old_prefix}/switch_on_threshold") + move_topic(f"{chargemode_config_prefix}/surplus/vehicle/switch_on_delay", f"{old_prefix}/switch_on_delay") + move_topic(f"{chargemode_config_prefix}/surplus/vehicle/switch_off_threshold", + f"{old_prefix}/switch_off_threshold") + move_topic(f"{chargemode_config_prefix}/surplus/vehicle/switch_off_delay", f"{old_prefix}/switch_off_delay") + move_topic(f"{chargemode_config_prefix}/surplus/vehicle/phase_switch_delay", f"{old_prefix}/phase_switch_delay") + move_topic(f"{chargemode_config_prefix}/surplus/control_range", f"{old_prefix}/control_range") + move_topic(f"{chargemode_config_prefix}/bat/min_soc", f"{old_prefix}/min_bat_soc") + move_topic(f"{chargemode_config_prefix}/bat/max_soc", f"{old_prefix}/max_bat_soc") + move_topic(f"{chargemode_config_prefix}/bat/power_discharge", f"{old_prefix}/bat_power_discharge") + move_topic(f"{chargemode_config_prefix}/bat/power_discharge_active", f"{old_prefix}/bat_power_discharge_active") + move_topic(f"{chargemode_config_prefix}/bat/power_reserve", f"{old_prefix}/bat_power_reserve") + move_topic(f"{chargemode_config_prefix}/bat/power_reserve_active", f"{old_prefix}/bat_power_reserve_active") + move_topic(f"{chargemode_config_prefix}/surplus/vehicle/retry_failed_phase_switches", + f"{old_prefix}/retry_failed_phase_switches") + + self._append_datastore_version(140) + + def upgrade_datastore_141(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.ECO_CHARGING.value, False), + (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: + grouped_vehicles.append({"type": "vehicle", "id": int(get_index(topic))}) + + loadmanagement_prios = [] + for chargemode, prio in CHARGEMODES: + grouped_vehicles = [] + self._loop_all_received_topics(upgrade) + if len(grouped_vehicles) == 1: + loadmanagement_prios.append(grouped_vehicles[0]) + elif len(grouped_vehicles) > 1: + loadmanagement_prios.append({"type": "group", "children": grouped_vehicles}) + self.__update_topic("openWB/counter/get/loadmanagement_prios", loadmanagement_prios) + self._append_datastore_version(141) + + def upgrade_datastore_142(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(142) diff --git a/packages/helpermodules/update_config_test.py b/packages/helpermodules/update_config_test.py index 18e036b162..30359a1cfd 100644 --- a/packages/helpermodules/update_config_test.py +++ b/packages/helpermodules/update_config_test.py @@ -1,4 +1,8 @@ +from typing import List from unittest.mock import Mock, patch, mock_open +from control.chargemode import Chargemode +from control.ev.charge_template import ChargeTemplate +import dataclass_utils from helpermodules import update_config import json from pathlib import Path @@ -233,3 +237,64 @@ def test_upgrade_datastore_125_is_idempotent_for_already_converted_values(mock_p assert uc.all_received_topics["openWB/optional/ep/grid_fee/provider"] == expected_grid_fee assert uc.all_received_topics["openWB/system/datastore_version"] == [123, 124, 125] assert mock_pub.pub.call_count == 1 # einmal publishen für Upgrade der Datastore-Version + + +@pytest.mark.parametrize("ev0_prio, ev0_chargemode, ev1_prio, ev1_chargemode, ev2_prio, ev2_chargemode, expected", [ + pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, + False, Chargemode.INSTANT_CHARGING.value, + [{"type": "group", "children": [{"type": "vehicle", "id": 0}, + {"type": "vehicle", "id": 1}, + {"type": "vehicle", "id": 2}]}], + id="alle gleich"), + pytest.param(False, Chargemode.INSTANT_CHARGING.value, False, Chargemode.INSTANT_CHARGING.value, + False, Chargemode.SCHEDULED_CHARGING.value, + [{"type": "vehicle", "id": 2}, + {"type": "group", "children": [{"type": "vehicle", "id": 0}, {"type": "vehicle", "id": 1}]}], + id="Lademodi unterschiedlich"), + pytest.param(False, Chargemode.INSTANT_CHARGING.value, True, Chargemode.INSTANT_CHARGING.value, + False, Chargemode.INSTANT_CHARGING.value, + [{"type": "vehicle", "id": 1}, + {"type": "group", "children": [{"type": "vehicle", "id": 0}, {"type": "vehicle", "id": 2}]}], + id="Prioritäten unterschiedlich") +] +) +def test_upgrade_datastore_141_ev_chargemode_conversion(ev0_prio: bool, + ev0_chargemode: str, + ev1_prio: bool, + ev1_chargemode: str, + ev2_prio: bool, + ev2_chargemode: str, + expected: List[dict], + mock_pub: Mock): + # setup + ev0_charge_template = ChargeTemplate() + ev0_charge_template.data.id = 0 + ev0_charge_template.data.chargemode.selected = ev0_chargemode + ev0_charge_template.data.prio = ev0_prio + ev1_charge_template = ChargeTemplate() + ev1_charge_template.data.id = 1 + ev1_charge_template.data.chargemode.selected = ev1_chargemode + ev1_charge_template.data.prio = ev1_prio + ev2_charge_template = ChargeTemplate() + ev2_charge_template.data.id = 2 + ev2_charge_template.data.chargemode.selected = ev2_chargemode + ev2_charge_template.data.prio = ev2_prio + + uc = UpdateConfig() + uc.all_received_topics = { + "openWB/vehicle/0/charge_template": 0, + "openWB/vehicle/1/charge_template": 1, + "openWB/vehicle/2/charge_template": 2, + "openWB/vehicle/template/charge_template/0": dataclass_utils.asdict(ev0_charge_template.data), + "openWB/vehicle/template/charge_template/1": dataclass_utils.asdict(ev1_charge_template.data), + "openWB/vehicle/template/charge_template/2": dataclass_utils.asdict(ev2_charge_template.data), + "openWB/system/datastore_version": [131, 132], + } + + # execution + uc.upgrade_datastore_141() + + # evaluation + assert uc.all_received_topics["openWB/counter/get/loadmanagement_prios"] == expected + assert uc.all_received_topics["openWB/system/datastore_version"] == [131, 132, 141] + assert mock_pub.pub.call_count == 2 # einmal publishen für Upgrade der Datastore-Version 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/backup_clouds/nextcloud/backup_cloud.py b/packages/modules/backup_clouds/nextcloud/backup_cloud.py index 9a8af0693b..f42be2a166 100644 --- a/packages/modules/backup_clouds/nextcloud/backup_cloud.py +++ b/packages/modules/backup_clouds/nextcloud/backup_cloud.py @@ -3,7 +3,7 @@ import re from typing import Callable, List, Tuple -from dataclass_utils._dataclass_asdict import asdict +from dataclass_utils import asdict from helpermodules.pub import Pub from modules.backup_clouds.nextcloud.config import NextcloudBackupCloud, NextcloudBackupCloudConfiguration from modules.common import req diff --git a/packages/modules/common/abstract_consumer.py b/packages/modules/common/abstract_consumer.py new file mode 100644 index 0000000000..dd6f423c8e --- /dev/null +++ b/packages/modules/common/abstract_consumer.py @@ -0,0 +1,11 @@ +from dataclasses import dataclass + + +@dataclass +class CurrentValues: + bat_power: float = 0 + bat_soc: float = 0 + cp_power: float = 0 + evu_power: float = 0 + home_consumption: float = 0 + pv_power: float = 0 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..ee4ca3b169 --- /dev/null +++ b/packages/modules/common/configurable_consumer.py @@ -0,0 +1,109 @@ +from dataclasses import dataclass +import logging +from typing import Callable, Optional, TypeVar, Generic + +from helpermodules import timecheck +from helpermodules.pub import Pub +from modules.common.abstract_consumer import CurrentValues +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") + + +@dataclass +class SetLimitData: + max_power: float = 0.0 + + +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, + send_values: Optional[Callable[[CurrentValues], None]] = lambda _values: 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_send_values = send_values + 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: float, data: SetLimitData) -> None: + with SingleComponentUpdateContext(self.fault_state): + self.module_set_power_limit(power_limit, data) + + 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 send_values(self, values: CurrentValues) -> None: + with SingleComponentUpdateContext(self.fault_state): + self.module_send_values(values) + + +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..2559a81dc8 --- /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..535c05bf30 --- /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, SetLimitData +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_power_limit(power_limit: float, data: SetLimitData) -> 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_power_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..e25b462dba --- /dev/null +++ b/packages/modules/consumers/generic/dac/consumer.py @@ -0,0 +1,48 @@ +#!/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, SetLimitData +from modules.common.modbus import ModbusDataType, 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: ModbusTcpClient_ = 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_power_limit(power_limit: float, data: SetLimitData) -> None: + power_limit = max(power_limit, 0) + modbus_id = config.configuration.modbus_id + if config.configuration.model == Model.N4Dac02: + client.write_register(1, power_limit * 1000 / data.max_power, ModbusDataType.INT_16, unit=modbus_id) + elif config.configuration.model == Model.DA02: + client.write_register(0x01f4, power_limit * 4000 / data.max_power, ModbusDataType.INT_16, unit=modbus_id) + elif config.configuration.model == Model.M120T: + # ausgabe nicht kleiner 0,9V sonst Leistungsregelung der WP aus + power_limit = max(power_limit * 4095 / data.max_power, 370) + client.write_register(0x01f4, power_limit, ModbusDataType.INT_16, unit=modbus_id) + elif config.configuration.model == Model.AA02B: + power_limit = max((power_limit * (4095-820) / data.max_power)+820, 820) + # ausgabe nicht kleiner 4ma sonst Leistungsregelung der WP aus + client.write_register(0x01f4, power_limit, ModbusDataType.INT_16, unit=modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + set_power_limit=set_power_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..020c7bf3bb --- /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, SetLimitData +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: float, data: SetLimitData) -> None: + # 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..8d92c20b4c --- /dev/null +++ b/packages/modules/consumers/generic/json/config.py @@ -0,0 +1,47 @@ +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_currents = (jq_current_l1, jq_current_l2, 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..34caecd075 --- /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, SetLimitData +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({"state": True}) + + def switch_off(): + # Authorization? + jq_switch_off({"state": False}) + + def set_power_limit(power_limit: float, data: SetLimitData) -> None: + # Authorization? + jq_set_power_limit({"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..39e2daa961 --- /dev/null +++ b/packages/modules/consumers/generic/mqtt/config.py @@ -0,0 +1,29 @@ +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.SELF_CONTROLLED, + 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..733c65f1a5 --- /dev/null +++ b/packages/modules/consumers/generic/mqtt/consumer.py @@ -0,0 +1,71 @@ +#!/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_consumer import CurrentValues +from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_state import ConsumerState +from modules.common.configurable_consumer import ConfigurableConsumer, SetLimitData +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, data: SetLimitData) -> 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) + + def send_values(values: CurrentValues) -> None: + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/bat_power", values.bat_power) + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/bat_soc", values.bat_soc) + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/cp_power", values.cp_power) + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/evu_power", values.evu_power) + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/home_consumption", values.home_consumption) + Pub().pub(f"openWB/set/mqtt/consumer/{config.id}/set/pv_power", values.pv_power) + + return ConfigurableConsumer(consumer_config=config, + update=update, + send_values=send_values, + 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..e417129637 --- /dev/null +++ b/packages/modules/consumers/idm/idm/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 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.SELF_CONTROLLED, + 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..f32116ce64 --- /dev/null +++ b/packages/modules/consumers/idm/idm/consumer.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +from pymodbus.constants import Endian +import logging + +from modules.common.abstract_consumer import CurrentValues +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, SetLimitData +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 send_values(values: CurrentValues) -> None: + # Sendet die aktuellen Systemwerte an die IDM Navigator 2.0-Regelung über die + # Gebäudeleittechnik/Smartfox-Schnittstelle (Modbus TCP). IDM unterstützt keine + # direkte Leistungsvorgabe für die WP - die Regelung berechnet ihre Überschuss- und + # Speicherstrategie selbst anhand dieser Werte. Wird vom Core automatisch aufgerufen, + # solange dieser Verbraucher als ConsumerUsage.SELF_CONTROLLED konfiguriert ist. + # Register-Layout siehe IDM "Gebäudeleittechnik-Smartfox.pdf", Kap. 2.2.5.1 + modbus_id = config.configuration.modbus_id + + # values.pv_power kommt roh aus process.py (negativ während Produktion, gleiche + # Konvention wie beim EVU-Zähler) -> Vorzeichen drehen und auf 0 begrenzen, um die + # reine Erzeugung zu erhalten + pv_power = max(-values.pv_power, 0) + # Ladeleistung mit einrechnen, da diese sonst fälschlich als freier Überschuss an die + # WP gemeldet würde, obwohl sie bereits von einer Fahrzeugladung verbraucht wird + surplus = max(pv_power - values.home_consumption - values.cp_power, 0) + battery_soc = int(values.bat_soc) if values.bat_soc is not None else -1 + + # Reg 74: Aktueller PV-Überschuss [kW] + client.write_register(74, surplus / 1000, wordorder=Endian.Little, unit=modbus_id) + # Reg 78: Aktuelle PV-Produktion [kW] + client.write_register(78, pv_power / 1000, wordorder=Endian.Little, unit=modbus_id) + # Reg 82: Hausverbrauch [kW], Default 0 + client.write_register(82, values.home_consumption / 1000, wordorder=Endian.Little, unit=modbus_id) + # Reg 84: Batterieentladung [kW], Default 0 (negativ = Ladung) + client.write_register(84, values.bat_power / 1000, wordorder=Endian.Little, unit=modbus_id) + # Reg 86: Batteriefüllstand [%], Default -1 (= kein Speicher) + client.write_register(86, battery_soc, unit=modbus_id) + + 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_power_limit(power_limit: float, data: SetLimitData) -> None: + # Schreibt den von openWB berechneten Überschuss in Register 74 (PV-Überschuss [kW]). + # Keine echte Leistungsvorgabe - die IDM-Regelung berechnet ihre Strategie selbst anhand dieses Wertes. + # Hinweis: Dies überschreibt den intern berechneten PV-Überschuss der IDM-Regelung und kann + # die PV-Eigenverbrauchs-Statistik im IDM-Portal verfälschen. + client.write_register(74, max(power_limit, 0) / 1000, wordorder=Endian.Little, + unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + send_values=send_values, + set_power_limit=set_power_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..c02249395f --- /dev/null +++ b/packages/modules/consumers/lambda_/lambda_/config.py @@ -0,0 +1,32 @@ +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): + self.ip_address = ip_address + self.port = port + self.modbus_id = modbus_id + + +@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.SELF_CONTROLLED, + 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..b65248d1f4 --- /dev/null +++ b/packages/modules/consumers/lambda_/lambda_/consumer.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +from pymodbus.constants import Endian +import logging + +from modules.common.abstract_consumer import CurrentValues +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, SetLimitData +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 send_values(values: CurrentValues) -> None: + # Sendet die aktuelle EVU-Leistung an den Lambda E-Manager (Modbus-Protokoll 1.0, + # Kap. 3.2 und 4.3, Reg 102). Der E-Manager muss zusätzlich einmalig auf der + # Wärmepumpe selbst auf Datenquelle "Modbus Client" umgestellt werden (Kap. 4.3), + # das kann openWB nicht per Modbus setzen. Wird vom Core automatisch aufgerufen, + # solange dieser Verbraucher als ConsumerUsage.SELF_CONTROLLED konfiguriert ist. + client.write_register(102, values.evu_power, wordorder=Endian.Little, unit=config.configuration.modbus_id) + + 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_power_limit(power_limit: float, data: SetLimitData) -> None: + # Schreibt den von openWB berechneten Überschuss in Register 102 (E-Manager Bezug/Einspeisung). + # Keine echte Leistungsvorgabe - die Wärmepumpe regelt weiterhin selbst anhand dieses Wertes. + client.write_register(102, + 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, + send_values=send_values, + set_power_limit=set_power_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..f35e63cab7 --- /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, SetLimitData +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_power_limit(power_limit: float, data: SetLimitData) -> 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_power_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..f5c7cfd598 --- /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, SetLimitData +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 power, 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_power_limit(power_limit: float, data: SetLimitData) -> 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 = max(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_power_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/ovum/__init__.py b/packages/modules/consumers/ovum/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ovum/ovum/__init__.py b/packages/modules/consumers/ovum/ovum/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/ovum/ovum/config.py b/packages/modules/consumers/ovum/ovum/config.py new file mode 100644 index 0000000000..d37f948158 --- /dev/null +++ b/packages/modules/consumers/ovum/ovum/config.py @@ -0,0 +1,36 @@ +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 OvumConfiguration: + 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 Ovum(ConsumerSetup[OvumConfiguration]): + def __init__(self, + name: str = "OVUM Wärmepumpe (CubeSpeicher/MPlus)", + type: str = "ovum", + id: int = 0, + configuration: OvumConfiguration = None, + # OVUM unterstützt eine echte Leistungsvorgabe (SUSPENDABLE_TUNABLE), + # Eigenregelung anhand der Systemwerte (SELF_CONTROLLED) sowie + # SG-Ready-Ein-/Ausschalten (SUSPENDABLE_ONOFF) + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.SELF_CONTROLLED, + ConsumerUsage.SUSPENDABLE_ONOFF, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or OvumConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/ovum/ovum/consumer.py b/packages/modules/consumers/ovum/ovum/consumer.py new file mode 100644 index 0000000000..3961f4ac53 --- /dev/null +++ b/packages/modules/consumers/ovum/ovum/consumer.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +from typing import Optional +import logging + +from modules.common.abstract_consumer import CurrentValues +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, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.ovum.ovum.config import Ovum + +log = logging.getLogger(__name__) + + +def create_consumer(config: Ovum): + 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 send_values(values: CurrentValues) -> None: + # ConsumerUsage.SELF_CONTROLLED: aktiviert Ovum-eigene PV-Watch-Regelung (Reg 709 = 0) + # und schreibt informativ den aktuellen Netzsaldo in Reg 710, damit OVUM-eigene + # Anzeigen (PV-Watch Messwert Reg 456, Autarkiegrad Reg 450) einen sinnvollen Wert + # zeigen. UNVERIFIZIERT, ob Reg 710 dabei tatsächlich nur angezeigt und nicht doch in + # interne Berechnungen einfließt - vor Dauerbetrieb an echter Hardware prüfen, ob + # Reg 708 dadurch vom erwarteten Eigenregelungs-Verhalten abweicht. Vor jedem Schreiben + # wird der Ist-Zustand gelesen, damit unnötige Moduswechsel-Befehle vermieden werden. + unit = config.configuration.modbus_id + + mode = client.read_holding_registers(709, ModbusDataType.INT_16, unit=unit) + if mode != 0: + client.write_register(709, 0, ModbusDataType.INT_16, unit=unit) + log.debug("Wärmepumpe in Eigenregelung, Ovum-eigene PV-Watch-Regelung aktivieren.") + client.write_register(710, int(values.evu_power / 10), ModbusDataType.INT_16, unit=unit) + + def update() -> ConsumerState: + # Reg 708: elektrische Leistungsaufnahme Wärmepumpe [kW]*100 -> Watt = Rohwert * 10 + power = client.read_holding_registers(708, ModbusDataType.INT_16, + unit=config.configuration.modbus_id) * 10 + imported, exported = sim_counter.sim_count(power) + + return ConsumerState( + power=power, + imported=imported, + exported=exported + ) + + def set_power_limit(power_limit: Optional[float], data: SetLimitData) -> None: + # ConsumerUsage.SUSPENDABLE_TUNABLE: externe Leistungsvorgabe. Ist-Modus wird vor dem + # Schreiben gelesen, um unnötige Moduswechsel-Befehle zu vermeiden. + unit = config.configuration.modbus_id + mode = client.read_holding_registers(709, ModbusDataType.INT_16, unit=unit) + + if power_limit is None: + # Keine Leistungsvorgabe gefordert, Ovum-eigene PV-Watch-Regelung übernimmt wieder + if mode != 0: + client.write_register(709, 0, ModbusDataType.INT_16, unit=unit) + log.debug("Keine Leistungsvorgabe, Ovum-eigene PV-Watch-Regelung aktivieren.") + else: + # Leistungsvorgabe gefordert: externe Steuerung aktivieren (falls noch nicht aktiv) + # und den absoluten Ziel-Leistungsbezug in Reg 711 schreiben. power_limit ist bereits + # von openWB berechnet und berücksichtigt die aktuelle WP-Leistungsaufnahme - bewusst + # NICHT Reg 710 (PV-Watch TCP, roher Netzsaldo) als Regelgröße verwendet, da das laut + # evcc-Issue #28372 einen Regelkreis-Konflikt erzeugt (WP-eigene Leistung ist im + # Saldo bereits enthalten, WP-Regler und openWB-Regler können gegeneinander + # aufschaukeln). + if mode != 2: + client.write_register(709, 2, ModbusDataType.INT_16, unit=unit) + log.debug("Leistungsvorgabe gefordert, externe Steuerung (Reg 711) aktivieren.") + raw_value = int(max(power_limit, 0) / 10) # W -> [kW]*100 + client.write_register(711, raw_value, ModbusDataType.INT_16, unit=unit) + + def switch_on() -> None: + # ConsumerUsage.SUSPENDABLE_ONOFF: SG-Ready "Empfehlung" (Kontakt 1 = 0, Kontakt 2 = 1) - + # die WP darf eigene Prioritäten (Legionellenschutz, Mindestlaufzeiten) weiter + # berücksichtigen, wird aber zum Einschalten angeregt. Ist-Zustand wird vor jedem + # Schreiben gelesen, um unnötige Befehle zu vermeiden. + unit = config.configuration.modbus_id + + sg_ready_mode = client.read_holding_registers(1250, ModbusDataType.INT_16, unit=unit) + if sg_ready_mode != 1: + client.write_register(1250, 1, ModbusDataType.INT_16, unit=unit) # SG-Ready per TCP + + contact_1 = client.read_holding_registers(1251, ModbusDataType.INT_16, unit=unit) + if contact_1 != 0: + client.write_register(1251, 0, ModbusDataType.INT_16, unit=unit) + + contact_2 = client.read_holding_registers(1252, ModbusDataType.INT_16, unit=unit) + if contact_2 != 1: + client.write_register(1252, 1, ModbusDataType.INT_16, unit=unit) + + def switch_off() -> None: + # ConsumerUsage.SUSPENDABLE_ONOFF: SG-Ready Normalbetrieb (Kontakt 1 = 0, Kontakt 2 = 0) + unit = config.configuration.modbus_id + + sg_ready_mode = client.read_holding_registers(1250, ModbusDataType.INT_16, unit=unit) + if sg_ready_mode != 1: + client.write_register(1250, 1, ModbusDataType.INT_16, unit=unit) # SG-Ready per TCP + + contact_1 = client.read_holding_registers(1251, ModbusDataType.INT_16, unit=unit) + if contact_1 != 0: + client.write_register(1251, 0, ModbusDataType.INT_16, unit=unit) + + contact_2 = client.read_holding_registers(1252, ModbusDataType.INT_16, unit=unit) + if contact_2 != 0: + client.write_register(1252, 0, ModbusDataType.INT_16, unit=unit) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + send_values=send_values, + set_power_limit=set_power_limit, + switch_on=switch_on, + switch_off=switch_off) + + +device_descriptor = DeviceDescriptor(configuration_factory=Ovum) diff --git a/packages/modules/consumers/ovum/vendor.py b/packages/modules/consumers/ovum/vendor.py new file mode 100644 index 0000000000..0523a05cf4 --- /dev/null +++ b/packages/modules/consumers/ovum/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 = "Ovum" + 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..0ab039c769 --- /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, SetLimitData +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_power_limit(power_limit: float, data: SetLimitData) -> None: + # Absturz bei negativen Zahlen + raw_value = int(round(max(power_limit, 0))) + client.write_register(100, raw_value, ModbusDataType.INT_16, unit=config.configuration.modbus_id) + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + set_power_limit=set_power_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/solarfocus/__init__.py b/packages/modules/consumers/solarfocus/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/solarfocus/vampair/__init__.py b/packages/modules/consumers/solarfocus/vampair/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/modules/consumers/solarfocus/vampair/config.py b/packages/modules/consumers/solarfocus/vampair/config.py new file mode 100644 index 0000000000..0de42b9fba --- /dev/null +++ b/packages/modules/consumers/solarfocus/vampair/config.py @@ -0,0 +1,29 @@ +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 VampairConfiguration: + 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 Vampair(ConsumerSetup[VampairConfiguration]): + def __init__(self, + name: str = "Vampair Wärmepumpe", + type: str = "vampair", + id: int = 0, + configuration: VampairConfiguration = None, + usage: Tuple[ConsumerUsage, ...] = (ConsumerUsage.SELF_CONTROLLED, + ConsumerUsage.SUSPENDABLE_TUNABLE, + ConsumerUsage.METER_ONLY), + **kwargs) -> None: + super().__init__(name, type, id, vendor=vendor_descriptor.configuration_factory( + ).type, configuration=configuration or VampairConfiguration(), usage=usage, **kwargs) diff --git a/packages/modules/consumers/solarfocus/vampair/consumer.py b/packages/modules/consumers/solarfocus/vampair/consumer.py new file mode 100644 index 0000000000..812571a634 --- /dev/null +++ b/packages/modules/consumers/solarfocus/vampair/consumer.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +from modules.common.abstract_consumer import CurrentValues +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, SetLimitData +from modules.common.modbus import ModbusDataType, ModbusTcpClient_ +from modules.common.simcount._simcounter import SimCounterConsumer +from modules.consumers.solarfocus.vampair.config import Vampair + + +def create_consumer(config: Vampair): + 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(2322, 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_power_limit(power_limit: float, data: SetLimitData) -> None: + # Elektrische Sollleistung HEMS (PV) + client.write_register(33415, power_limit, unit=config.configuration.modbus_id) + client.write_register(33409, power_limit * -1, unit=config.configuration.modbus_id) + + def send_values(values: CurrentValues) -> None: + client.write_register(33408, values.pv_power * -1, unit=config.configuration.modbus_id) + client.write_register(33409, values.evu_power * -1, unit=config.configuration.modbus_id) + + return ConfigurableConsumer(consumer_config=config, + initializer=initializer, + error_handler=error_handler, + update=update, + set_power_limit=set_power_limit, + send_values=send_values) + + +device_descriptor = DeviceDescriptor(configuration_factory=Vampair) diff --git a/packages/modules/consumers/solarfocus/vendor.py b/packages/modules/consumers/solarfocus/vendor.py new file mode 100644 index 0000000000..fa7842045c --- /dev/null +++ b/packages/modules/consumers/solarfocus/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 = "Solarfocus" + 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/avm/avm/device.py b/packages/modules/devices/avm/avm/device.py index db1fb7c51a..4c370a9ac0 100644 --- a/packages/modules/devices/avm/avm/device.py +++ b/packages/modules/devices/avm/avm/device.py @@ -5,7 +5,7 @@ from typing import Iterable import xml.etree.ElementTree as ET -from dataclass_utils._dataclass_asdict import asdict +from dataclass_utils import asdict from helpermodules.pub import Pub from modules.common import req from modules.common.abstract_device import DeviceDescriptor 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..08d3c8fb90 100644 --- a/packages/modules/display_themes/cards/source/src/App.vue +++ b/packages/modules/display_themes/cards/source/src/App.vue @@ -60,10 +60,11 @@ 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", - "openWB/general/chargemode_config/pv_charging/bat_mode", + "openWB/general/chargemode_config/bat/mode", "openWB/optional/ep/configured", "openWB/optional/ep/get/prices", "openWB/optional/int_display/only_local_charge_points", 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/Battery/BatteryModeModal.vue b/packages/modules/display_themes/cards/source/src/components/Battery/BatteryModeModal.vue index ac813fe499..5c99374478 100644 --- a/packages/modules/display_themes/cards/source/src/components/Battery/BatteryModeModal.vue +++ b/packages/modules/display_themes/cards/source/src/components/Battery/BatteryModeModal.vue @@ -99,8 +99,8 @@ export default { methods: { setBatteryMode(mode) { if(mode !== this.mqttStore.getBatteryMode) { - this.mqttStore.updateState("openWB/general/chargemode_config/pv_charging/bat_mode", mode); - this.$root.sendTopicToBroker("openWB/general/chargemode_config/pv_charging/bat_mode", mode); + this.mqttStore.updateState("openWB/general/chargemode_config/bat/mode", mode); + this.$root.sendTopicToBroker("openWB/general/chargemode_config/bat/mode", mode); } }, }, 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..da7acf7e2f 100644 --- a/packages/modules/display_themes/cards/source/src/stores/mqtt.js +++ b/packages/modules/display_themes/cards/source/src/stores/mqtt.js @@ -397,7 +397,7 @@ export const useMqttStore = defineStore("mqtt", { return state.getChartData("openWB/bat/get/soc"); }, getBatteryMode(state) { - return state.topics["openWB/general/chargemode_config/pv_charging/bat_mode"]; + return state.topics["openWB/general/chargemode_config/bat/mode"]; }, getPvConfigured(state) { return state.getValueBool("openWB/pv/config/configured"); @@ -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) { @@ -776,16 +791,6 @@ export const useMqttStore = defineStore("mqtt", { return undefined; }; }, - getChargePointConnectedVehiclePvChargingFeedInLimit(state) { - return (chargePointId) => { - if (state.getChargePointConnectedVehicleChargeTemplate(chargePointId)) { - return state.getChargePointConnectedVehicleChargeTemplate( - chargePointId, - ).chargemode.pv_charging.feed_in_limit; - } - return undefined; - }; - }, getChargePointConnectedVehiclePvChargingMinCurrent(state) { return (chargePointId) => { if (state.getChargePointConnectedVehicleChargeTemplate(chargePointId)) { diff --git a/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue b/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue index dffaf783bd..2ba286e972 100644 --- a/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue +++ b/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue @@ -217,14 +217,6 @@ export default { this.updateChargePointChargeTemplate(id, event, "chargemode.instant_charging.limit.amount"); } }, - setChargePointConnectedVehiclePvChargingFeedInLimit(id, event) { - if ( - event != - this.mqttStore.getChargePointConnectedVehiclePvChargingFeedInLimit(id) - ) { - this.updateChargePointChargeTemplate(id, event, "chargemode.pv_charging.feed_in_limit"); - } - }, setChargePointConnectedVehiclePvChargingMinCurrent(id, event) { let previous_value = this.mqttStore.getChargePointConnectedVehiclePvChargingMinCurrent(id); @@ -1026,45 +1018,6 @@ export default { - - Einspeisegrenze beachten - - - Nein - - - Ja - - - - - - diff --git a/packages/modules/display_themes/colors/source/src/components/chargePointList/model.ts b/packages/modules/display_themes/colors/source/src/components/chargePointList/model.ts index 710d180a37..8eea21a9c2 100755 --- a/packages/modules/display_themes/colors/source/src/components/chargePointList/model.ts +++ b/packages/modules/display_themes/colors/source/src/components/chargePointList/model.ts @@ -156,15 +156,6 @@ export class ChargePoint { updateChargeTemplate(this.id) } } - get pvFeedInLimit() { - return this.chargeTemplate?.chargemode.pv_charging.feed_in_limit ?? false - } - set pvFeedInLimit(setting: boolean) { - if (this.chargeTemplate) { - this.chargeTemplate.chargemode.pv_charging.feed_in_limit = setting - updateChargeTemplate(this.id) - } - } get pvMinCurrent() { return this.chargeTemplate?.chargemode.pv_charging.min_current ?? 0 } @@ -448,7 +439,6 @@ export interface ChargeTemplate { pv_charging: { dc_min_current: number dc_min_soc_current: number - feed_in_limit: boolean limit: { selected: string amount: number diff --git a/packages/modules/io_devices/eebus/api.py b/packages/modules/io_devices/eebus/api.py index 12b8a68b8d..f212d6bc30 100644 --- a/packages/modules/io_devices/eebus/api.py +++ b/packages/modules/io_devices/eebus/api.py @@ -7,7 +7,7 @@ from typing import Optional from control import data -from dataclass_utils._dataclass_asdict import asdict +from dataclass_utils import asdict from helpermodules import timecheck from helpermodules.broker import BrokerClient from helpermodules.pub import Pub diff --git a/packages/modules/loadvars.py b/packages/modules/loadvars.py index d88f9197da..81e9e6b187 100644 --- a/packages/modules/loadvars.py +++ b/packages/modules/loadvars.py @@ -62,6 +62,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 _set_timeout_fault_for_timed_out_device_components(self, not_finished_threads: List[str]) -> None: @@ -104,6 +111,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/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue b/packages/modules/web_themes/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue index dfdbb43f8d..d820678972 100755 --- a/packages/modules/web_themes/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue +++ b/packages/modules/web_themes/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue @@ -130,15 +130,6 @@ unit="kWh" /> - - - diff --git a/packages/modules/web_themes/colors/source/src/components/chargePointList/model.ts b/packages/modules/web_themes/colors/source/src/components/chargePointList/model.ts index e3291f7b61..0ee9fc4a54 100755 --- a/packages/modules/web_themes/colors/source/src/components/chargePointList/model.ts +++ b/packages/modules/web_themes/colors/source/src/components/chargePointList/model.ts @@ -71,7 +71,6 @@ export class ChargePoint implements PowerItem { private _instantTargetSoc = 0 private _instantMaxEnergy = 0 private _instantTargetPhases = 0 - private _pvFeedInLimit = false private _pvMinCurrent = 0 private _pvMaxSoc = 0 private _pvMinSoc = 0 @@ -206,15 +205,6 @@ export class ChargePoint implements PowerItem { updateChargeTemplate(this.id) } } - get pvFeedInLimit() { - return this.chargeTemplate?.chargemode.pv_charging.feed_in_limit ?? false - } - set pvFeedInLimit(setting: boolean) { - if (this.chargeTemplate) { - this.chargeTemplate.chargemode.pv_charging.feed_in_limit = setting - updateChargeTemplate(this.id) - } - } get pvMinCurrent() { return this.chargeTemplate?.chargemode.pv_charging.min_current ?? 0 } @@ -504,7 +494,6 @@ export interface ChargeTemplate { pv_charging: { dc_min_current: number dc_min_soc_current: number - feed_in_limit: boolean limit: ChargeLimit min_current: number min_soc_current: number 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/ChargePointPvSettings.vue b/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue index fb1bcb4464..9087f1e042 100644 --- a/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue +++ b/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue @@ -91,21 +91,11 @@ - -
-
Einspeisegrenze beachten
-
- -
-
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..33f01554fd --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerCard.vue @@ -0,0 +1,165 @@ + + + + + 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..b8d9773054 --- /dev/null +++ b/packages/modules/web_themes/koala/source/src/components/ConsumerSettings.vue @@ -0,0 +1,173 @@ + + + + + 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 @@ -