diff --git a/packages/control/algorithm/additional_current.py b/packages/control/algorithm/additional_current.py index e2965acb05..ae3723fd3a 100644 --- a/packages/control/algorithm/additional_current.py +++ b/packages/control/algorithm/additional_current.py @@ -1,11 +1,12 @@ import logging +from typing import List 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.algorithm.filter_chargepoints import (filtered_loads_to_str, get_grouped_loads_by_mode_and_counter, +from control.algorithm.filter_chargepoints import (filter_grouped_loads_by_mode_and_counter, filtered_loads_to_str, get_preferenced_load_charging) log = logging.getLogger(__name__) @@ -16,33 +17,33 @@ class AdditionalCurrent: def __init__(self) -> None: pass - def set_additional_current(self) -> None: - common.reset_current_by_chargemode(CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT) + def set_additional_current(self, grouped_loads: List[Load]) -> None: + common.reset_current_by_chargemode(grouped_loads, CONSIDERED_CHARGE_MODES_ADDITIONAL_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_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}") - 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( - load.data.set.current, available_for_cp, load.data.set.target_current) - self._set_loadmangement_message(current, limit, load) - common.set_current_counterdiff( - load.data.control_parameter.min_current, - current, - load) - preferenced_loads.pop(0) + preferenced_loads, preferenced_loads_without_set_current = get_preferenced_load_charging( + filter_grouped_loads_by_mode_and_counter(grouped_loads, + CONSIDERED_CHARGE_MODES_ADDITIONAL_CURRENT, + f"counter{counter.num}")) + 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}") + 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( + load.data.set.current, available_for_cp, load.data.set.target_current) + self._set_loadmangement_message(current, limit, load) + common.set_current_counterdiff( + load.data.control_parameter.min_current, + current, + load) + preferenced_loads.pop(0) for load in preferenced_loads_without_set_current: load.data.set.current = load.data.set.target_current diff --git a/packages/control/algorithm/algorithm.py b/packages/control/algorithm/algorithm.py index fa4b01ed8c..7cd8b78753 100644 --- a/packages/control/algorithm/algorithm.py +++ b/packages/control/algorithm/algorithm.py @@ -5,6 +5,7 @@ from control.algorithm import common from control.algorithm.additional_current import AdditionalCurrent from control.algorithm.bidi_charging import Bidi +from control.algorithm.filter_chargepoints import filtered_loads_to_str, group_loads_generator from control.algorithm.min_current import MinCurrent from control.algorithm.no_current import NoCurrent from control.algorithm.surplus_controlled import SurplusControlled @@ -31,21 +32,24 @@ def calc_current(self) -> None: common.reset_current() for cp in data.data.cp_data.values(): cp.reset_values_before_algorithm() - log.info("**Mindestrom setzen**") - self.min_current.set_min_current() - log.info("**Soll-Strom setzen**") - common.reset_current_to_target_current() - self.additional_current.set_additional_current() - self.surplus_controlled.set_required_current_to_max() - log.info("**PV-geführten Strom setzen**") - counter.limit_raw_power_left_to_surplus(self.evu_counter.calc_raw_surplus()) - if self.evu_counter.data.set.surplus_power_left > 0: - common.reset_current_to_target_current() - self.surplus_controlled.set_surplus_current() - else: - log.info("Keine Leistung für PV-geführtes Laden übrig.") - log.info("**Bidi-(Ent-)Lade-Strom setzen**") - counter.set_raw_surplus_power_left() + for grouped_loads in group_loads_generator(): + log.info(f"Gruppe {filtered_loads_to_str(grouped_loads)}") + log.info("**Mindestrom setzen**") + self.min_current.set_min_current(grouped_loads) + log.info("**Soll-Strom setzen**") + common.reset_current_to_target_current(grouped_loads) + self.additional_current.set_additional_current(grouped_loads) + self.surplus_controlled.set_required_current_to_max(grouped_loads) + log.info("**PV-geführten Strom setzen**") + counter.limit_raw_power_left_to_surplus(self.evu_counter.calc_raw_surplus()) + if self.evu_counter.data.set.surplus_power_left > 0: + common.reset_current_to_target_current(grouped_loads) + self.surplus_controlled.set_surplus_current(grouped_loads) + else: + log.info("Keine Leistung für PV-geführtes Laden übrig.") + log.info("**Bidi-(Ent-)Lade-Strom setzen**") + counter.set_raw_surplus_power_left() + # Nullpunktausregelung, nachdem für alle anderen Ladepunkte der Soll-Strom feststeht self.bidi.set_bidi() self.no_current.set_no_current() self.no_current.set_none_current() diff --git a/packages/control/algorithm/bidi_charging.py b/packages/control/algorithm/bidi_charging.py index 434c3e9fc4..0cc6858094 100644 --- a/packages/control/algorithm/bidi_charging.py +++ b/packages/control/algorithm/bidi_charging.py @@ -2,7 +2,8 @@ from control import data from control.chargemode import Chargemode from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE -from control.algorithm.filter_chargepoints import filtered_loads_to_str, get_loads_by_chargemodes +from control.algorithm.filter_chargepoints import ( + filter_loads_by_chargemodes, filtered_loads_to_str, group_loads_generator) from control.chargepoint.chargepoint import Chargepoint from helpermodules.phase_handling import voltages_mean @@ -33,43 +34,44 @@ def set_bidi(self): # CPs nach Modus filtern, dann verfügbare Ströme gegen alle relevanten Counter-Limits begrenzen. # -> Um bei mehreren Bidi-CPs das Entladen bei der Nullpunktanpassung gleichmäßig zu verteilen - 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] + for grouped_loads in group_loads_generator(): + preferenced_cps = filter_loads_by_chargemodes(grouped_loads, 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] - counts = self.get_counts(cp) + counts = self.get_counts(cp) - cp.data.set.target_current = 0 + cp.data.set.target_current = 0 - missing_currents = self.get_missing_currents(preferenced_cps, grid_counter) - log.debug(f"Bidi-LP{cp.num}: missing currents {missing_currents}A") + missing_currents = self.get_missing_currents(preferenced_cps, grid_counter) + log.debug(f"Bidi-LP{cp.num}: missing currents {missing_currents}A") - counters = data.data.counter_all_data.get_counters_to_check(cp.num) - for counter in counters: - available_currents, limit = Loadmanagement().get_available_currents_bidi( - missing_currents, voltages_mean(cp.data.get.voltages), data.data.counter_data[counter]) + counters = data.data.counter_all_data.get_counters_to_check(cp.num) + for counter in counters: + available_currents, limit = Loadmanagement().get_available_currents_bidi( + missing_currents, voltages_mean(cp.data.get.voltages), data.data.counter_data[counter]) - if limit.limiting_value is not None: - cp.data.control_parameter.limit = limit + if limit.limiting_value is not None: + cp.data.control_parameter.limit = limit - available_for_cp = common.available_current_for_load( - cp, counts, available_currents, missing_currents, bidi_mode=True) + available_for_cp = common.available_current_for_load( + cp, counts, available_currents, missing_currents, bidi_mode=True) - # Der neue Strom darf nicht höher als der bisher gesetzte Strom sein - current = common.get_current_to_set( - cp.data.set.current, available_for_cp, cp.data.set.target_current) + # Der neue Strom darf nicht höher als der bisher gesetzte Strom sein + current = common.get_current_to_set( + cp.data.set.current, available_for_cp, cp.data.set.target_current) - cp.data.set.current = current - log.info(f"LP{cp.num}: Stromstärke {current}A") + cp.data.set.current = current + log.info(f"LP{cp.num}: Stromstärke {current}A") - # Ausgabe LIMIT-MSG - self._set_loadmangement_message(current, limit, cp) + # Ausgabe LIMIT-MSG + self._set_loadmangement_message(current, limit, cp) - common.set_current_counterdiff(cp.data.set.target_current, current, cp, surplus=True) + common.set_current_counterdiff(cp.data.set.target_current, current, cp, surplus=True) - preferenced_cps.pop(0) + preferenced_cps.pop(0) def _set_loadmangement_message(self, current: float, diff --git a/packages/control/algorithm/common.py b/packages/control/algorithm/common.py index 5cd1fa1c8a..02eb1aa0a7 100644 --- a/packages/control/algorithm/common.py +++ b/packages/control/algorithm/common.py @@ -2,7 +2,7 @@ from typing import Iterable, List, Optional, Tuple from control import data -from control.algorithm.filter_chargepoints import get_loads_by_chargemodes +from control.algorithm.filter_chargepoints import filter_loads_by_chargemodes from control.algorithm.utils import get_medium_charging_current from control.chargepoint.chargepoint import Chargepoint from control.counter import Counter @@ -29,8 +29,8 @@ def reset_current(): log.exception(f"Fehler im Algorithmus-Modul für {get_load_str(load)}") -def reset_current_by_chargemode(chargemodes: Tuple[Tuple[Optional[str], str]]) -> None: - for load in get_loads_by_chargemodes(chargemodes): +def reset_current_by_chargemode(grouped_loads: List[Load], chargemodes: Tuple[Tuple[Optional[str], str]]) -> None: + for load in filter_loads_by_chargemodes(grouped_loads, chargemodes): load.data.set.current = None @@ -226,11 +226,11 @@ def get_missing_currents_left(preferenced_loads: List[Load]) -> Tuple[List[float return missing_currents, counts -def reset_current_to_target_current(): +def reset_current_to_target_current(grouped_loads: List[Load]): """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 load in (list(data.data.cp_data.values()) + list(data.data.consumer_data.values())): + for load in grouped_loads: try: load.data.set.target_current = load.data.set.current except Exception: diff --git a/packages/control/algorithm/filter_chargepoints.py b/packages/control/algorithm/filter_chargepoints.py index b1490954db..5dad142481 100644 --- a/packages/control/algorithm/filter_chargepoints.py +++ b/packages/control/algorithm/filter_chargepoints.py @@ -1,7 +1,7 @@ # tested import logging import re -from typing import List, Optional, Tuple +from typing import Iterator, List, Optional, Tuple from control import data from control.chargemode import Chargemode @@ -13,23 +13,24 @@ log = logging.getLogger(__name__) -def get_grouped_loads_by_mode_and_counter(chargemodes: Tuple[Tuple[Optional[str], str]], - counter: str) -> List[List[Load]]: +def filter_grouped_loads_by_mode_and_counter(grouped_loads: List[Load], + chargemodes: Tuple[Tuple[Optional[str], str]], + counter: str) -> List[Load]: + filtered_grouped_loads = list(grouped_loads) + + filtered_grouped_loads = filter_loads_by_chargemodes(filtered_grouped_loads, chargemodes) + filtered_grouped_loads = _filter_active_loads(filtered_grouped_loads) + 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) + valid_loads: List[Load] = [] + for load in filtered_grouped_loads: + if load.num in loads_to_counter_ids: + valid_loads.append(load) - return _group_loads_by_chargemode(chargemodes, _is_valid_for_chargemode)[1] + return valid_loads def _get_consumer_by_prio_item(item: dict) -> Optional[Consumer]: @@ -46,9 +47,54 @@ def _get_consumer_by_prio_item(item: dict) -> Optional[Consumer]: return consumer +def filter_loads_by_chargemodes(grouped_loads: List[Load], + chargemodes: Tuple[Tuple[Optional[str], str]]) -> List[Load]: + filtered_grouped_loads: List[Load] = [] + for load in grouped_loads: + for chargemode in chargemodes: + if ((load.data.control_parameter.chargemode == chargemode[0] or chargemode[0] is None) and + load.data.control_parameter.submode == chargemode[1]): + filtered_grouped_loads.append(load) + break + + return filtered_grouped_loads + + +def _filter_active_loads(grouped_loads: List[Load]) -> List[Load]: + active_loads: List[Load] = [] + for load in grouped_loads: + if load.data.control_parameter.required_current != 0: + active_loads.append(load) + + return active_loads + + +def group_loads_generator() -> Iterator[List[Load]]: + 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: + sub_valid_chargemode.append(cp) + elif group_item["type"] == "consumer": + consumer = _get_consumer_by_prio_item(group_item) + if consumer is not None: + sub_valid_chargemode.append(consumer) + yield sub_valid_chargemode + if item["type"] == "vehicle": + for cp in data.data.cp_data.values(): + if item["id"] == cp.data.config.ev: + yield [cp] + elif item["type"] == "consumer": + consumer = _get_consumer_by_prio_item(item) + if consumer is not None: + yield [consumer] + + def _group_loads_by_chargemode(chargemodes: Tuple[Tuple[Optional[str], str]], - filter_func) -> Tuple[List[Load], List[List[Load]]]: - grouped_loads: List[List[Load]] = [] + filter_func) -> List[Load]: flat_loads: List[Load] = [] for chargemode in chargemodes: for item in data.data.counter_all_data.data.get.loadmanagement_prios: @@ -66,19 +112,16 @@ def _group_loads_by_chargemode(chargemodes: Tuple[Tuple[Optional[str], str]], if consumer is not None and 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 = _get_consumer_by_prio_item(item) if consumer is not None and filter_func(consumer, chargemode, flat_loads): - grouped_loads.append([consumer]) flat_loads.append(consumer) - return flat_loads, grouped_loads + return flat_loads def get_loads_by_chargemodes(chargemodes: Tuple[Tuple[Optional[Chargemode], Chargemode]]) -> List[Load]: @@ -88,31 +131,29 @@ def _is_valid_for_chargemode(entity, chargemode, valid): entity.data.control_parameter.submode == chargemode[1] and entity not in valid) - return _group_loads_by_chargemode(chargemodes, _is_valid_for_chargemode)[0] + return _group_loads_by_chargemode(chargemodes, _is_valid_for_chargemode) def get_preferenced_load_charging( - grouped_loads: List[List[Load]]) -> Tuple[List[List[Load]], List[Load]]: + grouped_loads: List[Load]) -> Tuple[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 + valid_group: List[Load] = [] + for load in grouped_loads: + 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) + return valid_group, preferenced_loads_without_set_current def filtered_loads_to_str(loads: List[Load]) -> str: diff --git a/packages/control/algorithm/filter_chargepoints_test.py b/packages/control/algorithm/filter_chargepoints_test.py index bf42f2759a..5d768675fb 100644 --- a/packages/control/algorithm/filter_chargepoints_test.py +++ b/packages/control/algorithm/filter_chargepoints_test.py @@ -124,3 +124,92 @@ def test_get_loads_by_chargemodes( 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 + + +def test_filter_grouped_loads_by_mode_and_counter_filters_all_non_matching_loads( + mock_cp1: Chargepoint, + mock_cp2: Chargepoint, + mock_consumer3: Consumer): + # setup + for load in [mock_cp1, mock_cp2, mock_consumer3]: + load.data.control_parameter.required_current = 6 + load.data.control_parameter.chargemode = Chargemode.SCHEDULED_CHARGING + load.data.control_parameter.submode = Chargemode.INSTANT_CHARGING + + data.data.counter_all_data = Mock() + data.data.counter_all_data.get_loads_of_counter.return_value = ["consumer3"] + + # evaluation + valid_loads = filter_chargepoints.filter_grouped_loads_by_mode_and_counter( + [mock_cp1, mock_cp2, mock_consumer3], + ((None, Chargemode.INSTANT_CHARGING),), + "counter0" + ) + + # assertion + assert valid_loads == [mock_consumer3] + + +def test_filter_loads_by_chargemodes_filters_and_keeps_order( + mock_cp1: Chargepoint, + mock_cp2: Chargepoint, + mock_consumer3: Consumer): + # setup + mock_cp1.data.control_parameter.chargemode = Chargemode.SCHEDULED_CHARGING + mock_cp1.data.control_parameter.submode = Chargemode.INSTANT_CHARGING + + mock_cp2.data.control_parameter.chargemode = Chargemode.INSTANT_CHARGING + mock_cp2.data.control_parameter.submode = Chargemode.INSTANT_CHARGING + + mock_consumer3.data.control_parameter.chargemode = Chargemode.SCHEDULED_CHARGING + mock_consumer3.data.control_parameter.submode = Chargemode.STOP + + # evaluation + valid_loads = filter_chargepoints.filter_loads_by_chargemodes( + [mock_cp2, mock_consumer3, mock_cp1], + ((Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING), + (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING)), + ) + + # assertion + assert valid_loads == [mock_cp2, mock_cp1] + + +def test_filter_loads_by_chargemodes_supports_wildcard_for_chargemode( + mock_cp1: Chargepoint, + mock_cp2: Chargepoint, + mock_consumer3: Consumer): + # setup + mock_cp1.data.control_parameter.chargemode = Chargemode.SCHEDULED_CHARGING + mock_cp1.data.control_parameter.submode = Chargemode.INSTANT_CHARGING + + mock_cp2.data.control_parameter.chargemode = Chargemode.INSTANT_CHARGING + mock_cp2.data.control_parameter.submode = Chargemode.STOP + + mock_consumer3.data.control_parameter.chargemode = Chargemode.SCHEDULED_CHARGING + mock_consumer3.data.control_parameter.submode = Chargemode.INSTANT_CHARGING + + # evaluation + valid_loads = filter_chargepoints.filter_loads_by_chargemodes( + [mock_cp1, mock_cp2, mock_consumer3], + ((None, Chargemode.INSTANT_CHARGING),), + ) + + # assertion + assert valid_loads == [mock_cp1, mock_consumer3] + + +def test_filter_active_loads_removes_loads_with_zero_required_current( + mock_cp1: Chargepoint, + mock_cp2: Chargepoint, + mock_consumer3: Consumer): + # setup + mock_cp1.data.control_parameter.required_current = 0 + mock_cp2.data.control_parameter.required_current = 6 + mock_consumer3.data.control_parameter.required_current = 1 + + # evaluation + active_loads = filter_chargepoints._filter_active_loads([mock_cp1, mock_cp2, mock_consumer3]) + + # assertion + assert active_loads == [mock_cp2, mock_consumer3] diff --git a/packages/control/algorithm/integration_test/bidi_charging_test.py b/packages/control/algorithm/integration_test/bidi_charging_test.py index 1c1906b0af..7b20a7888d 100644 --- a/packages/control/algorithm/integration_test/bidi_charging_test.py +++ b/packages/control/algorithm/integration_test/bidi_charging_test.py @@ -203,3 +203,40 @@ def test_cp4_bidi_discharge_unlocks_cp5_instant_charging_in_next_cycle( Algorithm().calc_current() assert data.data.cp_data["cp4"].data.set.current == -20 assert data.data.cp_data["cp5"].data.set.current == 40 + + +def test_bidi_instant_discharge_prioritizes_single_ev_before_group_and_splits_remainder_evenly( + bidi_cps, all_cp_not_charging, monkeypatch): + # Prio 1: einzelnes Fahrzeug (EV3), Prio 2: Gruppe (EV4, EV5). + # Bei begrenzter Entladeleistung muss EV3 zuerst voll bedient werden, + # die verbleibende Leistung wird in der Gruppe gleichmaessig verteilt. + + # setup + bidi_cps("cp3", "cp4", "cp5") + # Rund 30 A Entladebedarf pro Phase. + data.data.counter_data["counter0"].data.get.power = 20700 + for counter in ("counter0", "counter6"): + data.data.counter_data[counter].data.set.raw_exported_currents_left = [30]*3 + + data.data.counter_all_data.data.get.loadmanagement_prios = [ + {"type": "vehicle", "id": 3}, + { + "type": "group", + "label": "Fahrzeuge", + "children": [ + {"type": "vehicle", "id": 4}, + {"type": "vehicle", "id": 5}, + ], + }, + ] + + 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() + + # evaluation + assert data.data.cp_data["cp3"].data.set.current == -15.942028985507246 + assert data.data.cp_data["cp4"].data.set.current == -7.028985507246377 + assert data.data.cp_data["cp5"].data.set.current == -7.028985507246377 diff --git a/packages/control/algorithm/integration_test/instant_charging_test.py b/packages/control/algorithm/integration_test/instant_charging_test.py index e2f23cf8a0..20d7657588 100644 --- a/packages/control/algorithm/integration_test/instant_charging_test.py +++ b/packages/control/algorithm/integration_test/instant_charging_test.py @@ -187,19 +187,19 @@ class ParamsControlParameter(ParamsExpectedSetCurrent, ParamsExpectedCounterSet) {"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.0]*3, + expected_current_cp3=16, + expected_current_cp4=0, + expected_current_cp5=16, + expected_raw_power_left=0, + expected_raw_currents_left_counter0=[0]*3, expected_raw_currents_left_counter6=[0]*3), 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=16, - expected_current_cp4=10, - expected_current_cp5=6, + expected_current_cp4=16, + expected_current_cp5=0, expected_raw_power_left=0, expected_raw_currents_left_counter0=[0]*3, expected_raw_currents_left_counter6=[0]*3), diff --git a/packages/control/algorithm/integration_test/pv_charging_test.py b/packages/control/algorithm/integration_test/pv_charging_test.py index 8e53d7c591..7ebfed6a27 100644 --- a/packages/control/algorithm/integration_test/pv_charging_test.py +++ b/packages/control/algorithm/integration_test/pv_charging_test.py @@ -203,7 +203,7 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc expected_current_cp4=8, expected_current_cp5=8, expected_current_consumer7=5.5, - expected_raw_power_left=34705, + expected_raw_power_left=27776.25, expected_surplus_power_left=1090, expected_reserved_surplus=0, expected_released_surplus=0), @@ -216,7 +216,7 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc expected_current_cp4=6.8827997554740445, expected_current_cp5=7.201405373552686, expected_current_consumer7=2.3519152915057484, - expected_raw_power_left=24355, + expected_raw_power_left=21885.000000000004, expected_surplus_power_left=1090, expected_reserved_surplus=0, expected_released_surplus=0), @@ -284,7 +284,7 @@ def test_surplus(params: ParamsSurplus, all_cp_pv_charging_3p, all_cp_charging_3 expected_current_cp4=6, expected_current_cp5=6, expected_current_consumer7=0.5, - expected_raw_power_left=37520.0, + expected_raw_power_left=32460, expected_surplus_power_left=3000, expected_reserved_surplus=0, expected_released_surplus=0) diff --git a/packages/control/algorithm/min_current.py b/packages/control/algorithm/min_current.py index 50f789dd0c..9146eac8dc 100644 --- a/packages/control/algorithm/min_current.py +++ b/packages/control/algorithm/min_current.py @@ -1,12 +1,13 @@ import logging +from typing import List 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.load_protocol import Load from control.loadmanagement import Loadmanagement -from control.algorithm.filter_chargepoints import filtered_loads_to_str, get_grouped_loads_by_mode_and_counter +from control.algorithm.filter_chargepoints import filter_grouped_loads_by_mode_and_counter, filtered_loads_to_str log = logging.getLogger(__name__) @@ -16,44 +17,43 @@ class MinCurrent: def __init__(self) -> None: pass - def set_min_current(self) -> None: + def set_min_current(self, grouped_loads: List[Load]) -> None: 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, load) + preferenced_loads = filter_grouped_loads_by_mode_and_counter(grouped_loads, + CONSIDERED_CHARGE_MODES_MIN_CURRENT, + f"counter{counter.num}") + 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, load) + if limit.limiting_value is not None: + 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( + load.data.set.current, available_for_load, load.data.set.target_current) + if 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.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( - 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( - load.data.set.target_current, - load.data.control_parameter.min_current, - load) + load.set_state_and_log( + f"Ladung kann nicht gestartet werden{limit.message}") else: - if (load.data.control_parameter.chargemode, - load.data.control_parameter.submode) in CONSIDERED_CHARGE_MODES_PV_ONLY: - try: - 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 {load.num}") - load.data.set.current = 0 - preferenced_loads.pop(0) + common.set_current_counterdiff( + load.data.set.target_current, + load.data.control_parameter.min_current, + load) + else: + if (load.data.control_parameter.chargemode, + load.data.control_parameter.submode) in CONSIDERED_CHARGE_MODES_PV_ONLY: + try: + 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 {load.num}") + load.data.set.current = 0 + preferenced_loads.pop(0) diff --git a/packages/control/algorithm/surplus_controlled.py b/packages/control/algorithm/surplus_controlled.py index 518890bcaf..7e19348f1b 100644 --- a/packages/control/algorithm/surplus_controlled.py +++ b/packages/control/algorithm/surplus_controlled.py @@ -5,9 +5,9 @@ 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 (filtered_loads_to_str, get_loads_by_chargemodes, - get_grouped_loads_by_mode_and_counter, - get_preferenced_load_charging) +from control.algorithm.filter_chargepoints import (filter_grouped_loads_by_mode_and_counter, + filter_loads_by_chargemodes, filtered_loads_to_str, + get_loads_by_chargemodes, 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 @@ -30,64 +30,65 @@ class SurplusControlled: def __init__(self) -> None: pass - def set_surplus_current(self) -> None: - common.reset_current_by_chargemode(CONSIDERED_CHARGE_MODES_SURPLUS) + def set_surplus_current(self, grouped_loads: List[Load]) -> None: + common.reset_current_by_chargemode(grouped_loads, CONSIDERED_CHARGE_MODES_SURPLUS) 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) + preferenced_loads, preferenced_loads_without_set_current = get_preferenced_load_charging( + filter_grouped_loads_by_mode_and_counter(grouped_loads, + CONSIDERED_CHARGE_MODES_SURPLUS, + f"counter{counter.num}")) + self._set(preferenced_loads, 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 load in get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_SURPLUS): + for load in filter_loads_by_chargemodes(grouped_loads, 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, - preferenced_loads_groups: List[List[Load]], + loads: List[Load], counter: Counter) -> None: - 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: + 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 - - 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) + else: + # Nicht mehr freigeben, wie das Lastmanagement vorgibt + current = min(load.data.set.current_prev - load.data.set.target_current, available_for_cp) + else: + 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, @@ -163,9 +164,9 @@ def phase_switch_necessary() -> bool: except Exception: log.exception(f"Fehler in der PV-gesteuerten Ladung bei {load.num}") - def set_required_current_to_max(self) -> None: - for load in get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_SURPLUS + - CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE): + def set_required_current_to_max(self, grouped_loads: List[Load]) -> None: + for load in filter_loads_by_chargemodes(grouped_loads, CONSIDERED_CHARGE_MODES_SURPLUS + + CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE): try: control_parameter = load.data.control_parameter if control_parameter.required_current != 0: diff --git a/packages/control/algorithm/surplus_controlled_test.py b/packages/control/algorithm/surplus_controlled_test.py index 3c537130b8..6ff1d6de4f 100644 --- a/packages/control/algorithm/surplus_controlled_test.py +++ b/packages/control/algorithm/surplus_controlled_test.py @@ -81,11 +81,11 @@ def test_set_required_current_to_max(phases: int, required_currents=required_currents)) mock_cp1.template = CpTemplate() mock_get_chargepoints_surplus_controlled = Mock(return_value=[mock_cp1]) - monkeypatch.setattr(surplus_controlled, "get_loads_by_chargemodes", + monkeypatch.setattr(surplus_controlled, "filter_loads_by_chargemodes", mock_get_chargepoints_surplus_controlled) # execution - SurplusControlled().set_required_current_to_max() + SurplusControlled().set_required_current_to_max([mock_cp1]) # evaluation assert mock_cp1.data.control_parameter.required_currents == expected_currents diff --git a/packages/control/counter.py b/packages/control/counter.py index 3e9634ea46..0ed7e75910 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -307,6 +307,10 @@ def _update_raw_values(self, diffs, cp_voltage: float, surplus: bool = False) -> self.data.set.raw_currents_left = list(map(operator.sub, self.data.set.raw_currents_left, diffs)) self.data.set.raw_exported_currents_left = list( map(operator.add, self.data.set.raw_exported_currents_left, diffs)) + if self.data.set.raw_power_left is not None: + self.data.set.raw_power_left -= sum([c * cp_voltage for c in diffs]) + if self.data.set.raw_exported_power_left is not None: + self.data.set.raw_exported_power_left += sum([c * cp_voltage for c in diffs]) if surplus: if self.data.set.surplus_power_left is not None: @@ -314,10 +318,6 @@ def _update_raw_values(self, diffs, cp_voltage: float, surplus: bool = False) -> log.debug(f'Zähler {self.num}: {self.data.set.raw_currents_left}A verbleibende Ströme, ' f'{self.data.set.surplus_power_left}W verbleibender Überschuss') else: - if self.data.set.raw_power_left is not None: - self.data.set.raw_power_left -= sum([c * cp_voltage for c in diffs]) - if self.data.set.raw_exported_power_left is not None: - self.data.set.raw_exported_power_left += sum([c * cp_voltage for c in diffs]) log.debug(f'Zähler {self.num}: {self.data.set.raw_currents_left}A verbleibende Ströme, ' f'{self.data.set.raw_exported_currents_left}A verbleibende exportierte Ströme, ' f'{self.data.set.raw_power_left}W verbleibende Leistung, '