diff --git a/packages/control/algorithm/bidi_charging.py b/packages/control/algorithm/bidi_charging.py index 4dec5a7b4c..e5f6e8ebb6 100644 --- a/packages/control/algorithm/bidi_charging.py +++ b/packages/control/algorithm/bidi_charging.py @@ -1,9 +1,18 @@ import logging 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 get_chargepoints_with_required_current_by_chargemode from helpermodules.phase_handling import voltages_mean +from control.limiting_value import LoadmanagementLimit +from control.loadmanagement import Loadmanagement + +from control.chargepoint.chargepoint import Chargepoint +import control.algorithm.common as common +from typing import List + + log = logging.getLogger(__name__) @@ -14,33 +23,115 @@ def __init__(self): 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: - 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") + counts = self.get_counts(cp) + + 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") + + 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 + + available_for_cp = common.available_current_for_cp( + 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) + + 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) + + common.set_current_counterdiff(cp.data.set.target_current, current, cp, surplus=True) + preferenced_cps.pop(0) + + def _set_loadmangement_message(self, + current: float, + limit: LoadmanagementLimit, + chargepoint: Chargepoint) -> 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}") + required_currents = chargepoint.data.control_parameter.required_currents + required_current = min(required_currents) if current < 0 else max(required_currents) + if (limit.message and + # Strom erreicht nicht die vorgegebene Stromstärke + round(current, 2) != round(required_current, 2)): + if current == 0: + chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen/entladen werden" + f"{limit.message}") + elif current < 0: + chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke entladen werden" + f"{limit.message}") + else: + chargepoint.set_state_and_log(f"Es kann nicht mit der vorgegebenen Stromstärke geladen werden" + f"{limit.message}") + + def get_counts(self, chargepoint: Chargepoint) -> List[int]: + + counts = [0]*3 + required_currents = chargepoint.data.control_parameter.required_currents + for i in range(3): + if required_currents[i] != 0: + counts[i] += 1 + return counts + + def get_missing_currents(self, preferenced_cps: List[Chargepoint], grid_counter) -> List[float]: + cp = preferenced_cps[0] + missing_currents = [0, 0, 0] + if cp.data.control_parameter.chargemode == Chargemode.INSTANT_CHARGING: + # Entladen im Instant-Charging-Modus + if cp.data.set.charging_ev_data.data.get.soc > 0: + # Auto-bat ist nicht leer + + dc_current = cp.data.set.charging_ev_data.charge_template.data.chargemode.instant_charging.dc_current + if dc_current < 0: + # Phasen in use berücksichtigen + missing_currents = [dc_current for i in range(0, cp.data.get.phases_in_use)] + missing_currents += [0] * (3 - len(missing_currents)) + + for index in range(0, 3): + missing_currents[index] = cp.check_min_max_current( + missing_currents[index], cp.data.get.phases_in_use) + + else: + # Default Nullpunktanpassung + 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: + for index in range(0, 3): + missing_currents[index] = cp.check_min_max_current(missing_currents[index], + cp.data.get.phases_in_use) + + return missing_currents diff --git a/packages/control/algorithm/chargemodes.py b/packages/control/algorithm/chargemodes.py index 0ebfa07cf3..9966b55c16 100644 --- a/packages/control/algorithm/chargemodes.py +++ b/packages/control/algorithm/chargemodes.py @@ -2,32 +2,34 @@ # 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, True), # 0 + (Chargemode.SCHEDULED_CHARGING, Chargemode.INSTANT_CHARGING, False), # 1 + (None, Chargemode.TIME_CHARGING, True), # 2 + (None, Chargemode.TIME_CHARGING, False), # 3 + (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING, True), # 4 + (Chargemode.INSTANT_CHARGING, Chargemode.INSTANT_CHARGING, False), # 5 + (Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING, True), # 6 + (Chargemode.ECO_CHARGING, Chargemode.INSTANT_CHARGING, False), # 7 + (Chargemode.PV_CHARGING, Chargemode.INSTANT_CHARGING, True), # 8 + (Chargemode.PV_CHARGING, Chargemode.INSTANT_CHARGING, False), # 9 + (Chargemode.SCHEDULED_CHARGING, Chargemode.PV_CHARGING, True), # 10 + (Chargemode.SCHEDULED_CHARGING, Chargemode.PV_CHARGING, False), # 11 + (Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING, True), # 12 + (Chargemode.ECO_CHARGING, Chargemode.PV_CHARGING, False), # 13 + (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, True), # 14 + (Chargemode.PV_CHARGING, Chargemode.PV_CHARGING, False), # 15 # 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.INSTANT_CHARGING, Chargemode.BIDI_CHARGING, False), # 16 + (Chargemode.INSTANT_CHARGING, Chargemode.BIDI_CHARGING, True), # 17 + (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING, False), # 18 + (Chargemode.SCHEDULED_CHARGING, Chargemode.BIDI_CHARGING, True), # 19 + (None, Chargemode.STOP, True), # 20 + (None, Chargemode.STOP, False)) # 21 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_MIN_CURRENT = CHARGEMODES[0:-6] +CONSIDERED_CHARGE_MODES_NO_CURRENT = CHARGEMODES[20:22] +CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE = CHARGEMODES[16:20] CONSIDERED_CHARGE_MODES_CHARGING = CHARGEMODES[0:16] diff --git a/packages/control/algorithm/common.py b/packages/control/algorithm/common.py index 60f81c7fe5..8380eee9a8 100644 --- a/packages/control/algorithm/common.py +++ b/packages/control/algorithm/common.py @@ -67,10 +67,9 @@ def set_current_counterdiff(diff_current: float, required_currents = chargepoint.data.control_parameter.required_currents considered_current = consider_less_charging_chargepoint_in_loadmanagement( chargepoint, current) - # gar nicht ladende Autos? - diff = max(considered_current - diff_current, 0) + diff = considered_current - diff_current diffs = [diff if required_currents[i] != 0 else 0 for i in range(3)] - if max(diffs) > 0: + if any(diff_value != 0 for diff_value in diffs): counters = data.data.counter_all_data.get_counters_to_check(chargepoint.num) for counter in counters: if surplus: @@ -95,10 +94,16 @@ def get_current_to_set(set_current: float, diff: float, prev_current: float) -> wird.""" new_current = prev_current + diff if set_current is not None: - if new_current > set_current: - log.debug("Neuer Soll-Strom darf nicht höher als bisher gesetzter sein: " - f"bisher {set_current}A, neuer {new_current}") - return set_current + if diff < 0: + if new_current < set_current: + log.debug("Neuer Soll-Strom darf beim Entladen nicht niedriger als bisher gesetzter sein: " + f"bisher {set_current}A, neuer {new_current}") + return set_current + else: + if new_current > set_current: + log.debug("Neuer Soll-Strom darf nicht höher als bisher gesetzter sein: " + f"bisher {set_current}A, neuer {new_current}") + return set_current return new_current # tested @@ -107,10 +112,29 @@ def get_current_to_set(set_current: float, diff: float, prev_current: float) -> def available_current_for_cp(chargepoint: Chargepoint, counts: List[int], available_currents: List[float], - missing_currents: List[float]) -> float: + missing_currents: List[float], + bidi_mode: bool = False) -> float: control_parameter = chargepoint.data.control_parameter - available_current = float("inf") missing_current_cp = control_parameter.required_current - chargepoint.data.set.target_current + + if bidi_mode: + is_discharge = missing_current_cp < 0 + available_current = float("-inf") if is_discharge else float("inf") + for i in range(0, 3): + if control_parameter.required_currents[i] == 0 or counts[i] == 0: + continue + phase_available_current = available_currents[i] / counts[i] + if is_discharge: + available_current = max( + max(missing_current_cp, phase_available_current), available_current) + else: + available_current = min( + min(missing_current_cp, phase_available_current), available_current) + if available_current in [float("inf"), float("-inf")]: + available_current = missing_current_cp + return available_current + + available_current = float("inf") for i in range(0, 3): if (control_parameter.required_currents[i] != 0 and missing_currents[i] != available_currents[i]): diff --git a/packages/control/algorithm/integration_test/bidi_charging_test.py b/packages/control/algorithm/integration_test/bidi_charging_test.py index 0a065ac734..1c1906b0af 100644 --- a/packages/control/algorithm/integration_test/bidi_charging_test.py +++ b/packages/control/algorithm/integration_test/bidi_charging_test.py @@ -2,6 +2,7 @@ from unittest.mock import Mock from control import data +from control import loadmanagement from control.algorithm.algorithm import Algorithm from control.chargemode import Chargemode @@ -9,6 +10,11 @@ @pytest.fixture() def bidi_cps(): def _setup(*cps): + for counter in ("counter0", "counter6"): + data.data.counter_data[counter].data.set.raw_currents_left = [32]*3 + data.data.counter_data[counter].data.set.raw_exported_currents_left = [32]*3 + data.data.counter_data[counter].data.set.raw_power_left = 22000 + data.data.counter_data[counter].data.set.raw_exported_power_left = 22000 for cp in cps: data.data.cp_data[cp].data.get.max_discharge_power = -11000 data.data.cp_data[cp].data.get.max_charge_power = 11000 @@ -30,7 +36,9 @@ def test_cp3_bidi(grid_power: float, expected_current: float, bidi_cps, all_cp_n # setup bidi_cps("cp3") data.data.counter_data["counter0"].data.get.power = grid_power - return_mock = Mock(reurn_value=True) + return_mock = Mock(return_value=True) + mock_get_component_name_by_id = Mock(return_value="Garage") + monkeypatch.setattr(loadmanagement, "get_component_name_by_id", mock_get_component_name_by_id) monkeypatch.setattr( data.data.cp_data["cp3"].data.set.charging_ev_data.charge_template, "bidi_charging_allowed", return_mock) @@ -48,6 +56,8 @@ def test_cp3_cp4_bidi_discharge(bidi_cps, all_cp_not_charging, monkeypatch): # setup bidi_cps("cp3", "cp4") data.data.counter_data["counter0"].data.get.power = 4000 + 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() @@ -57,3 +67,139 @@ def test_cp3_cp4_bidi_discharge(bidi_cps, all_cp_not_charging, monkeypatch): assert data.data.cp_data["cp4"].data.set.current == -2.898550724637681 assert data.data.cp_data["cp5"].data.set.current == 0 assert data.data.counter_data["counter0"].data.set.surplus_power_left == 0 + + +def test_cp3_bidi_instant_discharge_uses_dc_current(bidi_cps, all_cp_not_charging, monkeypatch): + # Testet, ob das Entladen entsprechend vom max_discharge_power limitiert wird + + # setup + bidi_cps("cp3") + control_parameter = data.data.cp_data["cp3"].data.control_parameter + control_parameter.chargemode = Chargemode.INSTANT_CHARGING + data.data.cp_data["cp3"].data.set.charging_ev_data.data.get.soc = 80 + data.data.cp_data["cp3"].data.set.charging_ev_data.charge_template.data.chargemode.instant_charging.dc_current = -20 + 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 == 0 + assert data.data.cp_data["cp5"].data.set.current == 0 + assert data.data.counter_data["counter0"].data.set.surplus_power_left == 10310.0 + + +def test_cp3_bidi_instant_discharge_limited_by_counter_export_current( + bidi_cps, all_cp_not_charging, monkeypatch): + # Testet, ob das Entladen entsprechend vom Counter limitiert wird + + # setup + bidi_cps("cp3") + control_parameter = data.data.cp_data["cp3"].data.control_parameter + control_parameter.chargemode = Chargemode.INSTANT_CHARGING + data.data.cp_data["cp3"].data.get.max_discharge_power = -22000 + data.data.cp_data["cp3"].data.set.charging_ev_data.data.get.soc = 80 + data.data.cp_data["cp3"].data.set.charging_ev_data.charge_template.data.chargemode.instant_charging.dc_current = -20 + for counter in ("counter0", "counter6"): + data.data.counter_data[counter].data.set.raw_exported_currents_left = [10]*3 + 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 == -10 + assert data.data.cp_data["cp4"].data.set.current == 0 + assert data.data.cp_data["cp5"].data.set.current == 0 + + +@pytest.mark.parametrize( + "max_discharge_power, expected_cp4_current, expected_cp5_current", + [ + # Begrenzt durch max_discharge_power und Counter + pytest.param(-11000, -15.942028985507246, -4.057971014492754, id="max_discharge_11kW"), + # Nur durch Counter begrenzt + pytest.param(-110000, -20, 0, id="max_discharge_110kW"), + ], +) +def test_cp3_cp4_bidi_instant_discharge_splits_limited_export_current( + max_discharge_power, expected_cp4_current, expected_cp5_current, + bidi_cps, all_cp_not_charging, monkeypatch): + # Beide CPs sind auf einer Ebene und wollen -20 A entladen + # Counter erlaubt insgesamt nur -20 A pro Phase + + # setup + bidi_cps("cp4", "cp5") + for cp in ("cp4", "cp5"): + control_parameter = data.data.cp_data[cp].data.control_parameter + control_parameter.chargemode = Chargemode.INSTANT_CHARGING + data.data.cp_data[cp].data.get.max_discharge_power = max_discharge_power + data.data.cp_data[cp].data.set.charging_ev_data.data.get.soc = 80 + (data.data.cp_data[cp].data.set.charging_ev_data.charge_template.data + .chargemode.instant_charging.dc_current) = -20 + + for counter in ("counter0", "counter6"): + data.data.counter_data[counter].data.set.raw_exported_currents_left = [20]*3 + 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 == 0 + assert data.data.cp_data["cp4"].data.set.current == expected_cp4_current + assert data.data.cp_data["cp5"].data.set.current == expected_cp5_current + + +def test_cp4_bidi_discharge_unlocks_cp5_instant_charging_in_next_cycle( + bidi_cps, all_cp_not_charging, monkeypatch): + # Zyklus 1: CP4 entlaedt, CP5 bleibt bei 20 A (Counter-Limit 20 A). + # Zyklus 2: Entladung ist am Counter reflektiert, dadurch kann CP5 auf 40 A steigen. + + # setup + bidi_cps("cp4") + cp4_control_parameter = data.data.cp_data["cp4"].data.control_parameter + cp4_control_parameter.chargemode = Chargemode.INSTANT_CHARGING + data.data.cp_data["cp4"].data.get.max_discharge_power = -22000 + data.data.cp_data["cp4"].data.set.charging_ev_data.data.get.soc = 80 + data.data.cp_data["cp4"].data.set.charging_ev_data.charge_template.data.chargemode.instant_charging.dc_current = -20 + + cp5 = data.data.cp_data["cp5"] + cp5_control_parameter = cp5.data.control_parameter + cp5_control_parameter.chargemode = Chargemode.INSTANT_CHARGING + cp5_control_parameter.submode = Chargemode.INSTANT_CHARGING + cp5_control_parameter.phases = 3 + cp5_control_parameter.required_currents = [50]*3 + cp5_control_parameter.required_current = 50 + cp5.data.get.max_charge_power = 110000 + cp5.data.get.charge_state = True + cp5.data.get.currents = [20]*3 + cp5.data.set.charging_ev_data.ev_template.data.max_current_multi_phases = 50 + cp5.template.data.max_current_multi_phases = 50 + + for counter in ("counter0", "counter6"): + data.data.counter_data[counter].data.set.raw_currents_left = [20]*3 + data.data.counter_data[counter].data.set.raw_exported_currents_left = [20]*3 + data.data.counter_data[counter].data.set.raw_power_left = 100000 + data.data.counter_data[counter].data.set.raw_exported_power_left = 100000 + + 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 + evaluation cycle 1 + Algorithm().calc_current() + assert data.data.cp_data["cp4"].data.set.current == -20 + assert data.data.cp_data["cp5"].data.set.current == 20 + + # Entladung aus Zyklus 1 ist im naechsten Zyklus als zusaetzlicher Spielraum verfuegbar. + for counter in ("counter0", "counter6"): + data.data.counter_data[counter].data.set.raw_currents_left = [40]*3 + + # execution + evaluation cycle 2 + Algorithm().calc_current() + assert data.data.cp_data["cp4"].data.set.current == -20 + assert data.data.cp_data["cp5"].data.set.current == 40 diff --git a/packages/control/counter.py b/packages/control/counter.py index e7351b3cef..8e30b0e4a9 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -80,6 +80,11 @@ class Set: released_surplus: float = field(default=0, metadata={"topic": "set/released_surplus"}) raw_power_left: Optional[float] = 0 raw_currents_left: List[float] = field(default_factory=currents_list_factory) + + # Bidi Entladung + raw_exported_power_left: Optional[float] = 0 + raw_exported_currents_left: List[float] = field(default_factory=currents_list_factory) + surplus_power_left: float = 0 @@ -145,6 +150,7 @@ def _get_loadmanagement_state(self) -> None: def _set_current_left(self, loadmanagement_available: bool) -> None: if loadmanagement_available: currents_raw = self.data.get.currents + currents_exported_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] @@ -154,18 +160,40 @@ def _set_current_left(self, loadmanagement_available: bool) -> None: chargepoint.data.get.currents) except KeyError: element_current = [get_medium_charging_current(chargepoint.data.get.currents)]*3 + + if min(element_current) < 0: + # nur Hausverbraucher ohne Einspeisung + currents_exported_raw = list(map(operator.sub, currents_exported_raw, element_current)) + continue + # nur Hausverbraucher und Einspeisung 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: - log.debug(f"Verbleibende Ströme: {currents_raw}, Überbelastung wird durch Hausverbrauch verursacht") - currents_raw = [max(currents_raw[i], 0) for i in range(0, 3)] - self.data.set.raw_currents_left = currents_raw + + raw_currents_left = list(map(operator.sub, self.data.config.max_currents, currents_raw)) + raw_exported_currents_left = list(map(operator.add, self.data.config.max_currents, currents_exported_raw)) + + if min(raw_currents_left) < 0: + log.debug( + f"Verbleibende Ströme Laden: " + f"{raw_currents_left}, Überbelastung wird durch Hausverbrauch verursacht") + raw_currents_left = [max(raw_currents_left[i], 0) for i in range(0, 3)] + + if min(raw_exported_currents_left) < 0: + log.debug(("Verbleibende Ströme Entladen: " + f"{raw_exported_currents_left}, Überbelastung wird durch Einspeisung verursacht")) + raw_exported_currents_left = [max(raw_exported_currents_left[i], 0) for i in range(0, 3)] + + self.data.set.raw_currents_left = raw_currents_left + self.data.set.raw_exported_currents_left = raw_exported_currents_left log.info(f'Verbleibende Ströme an Zähler {self.num}: {self.data.set.raw_currents_left}A') + log.info((f'Verbleibende exportierte Ströme an Zähler {self.num}: ' + f'{self.data.set.raw_exported_currents_left}A')) else: self.data.set.raw_currents_left = [self.data.config.max_power_errorcase/230/3]*3 + self.data.set.raw_exported_currents_left = [self.data.config.max_power_errorcase/230/3]*3 log.info(f'Verbleibende Ströme an Zähler {self.num} (Fehlerfall): {self.data.set.raw_currents_left}A') # tested + def get_unbalanced_load_exceeding(self, raw_currents_left: List[float]) -> List[float]: """gibt eine Liste zurück, die für jede Phase angibt, um wie viel Ampere die Schieflast überschritten wurde. So können gezielt Fahrzeuge reduziert werden, die auf dieser/n Phase(n) laden. Die Phase mit dem höchsten @@ -184,31 +212,54 @@ def _set_power_left(self, loadmanagement_available: bool) -> None: if f'counter{self.num}' == data.data.counter_all_data.get_evu_counter_str(): if loadmanagement_available: power_raw = self.data.get.power + power_exported_raw = self.data.get.power for cp in data.data.cp_data.values(): + if cp.data.get.power < 0: + power_exported_raw -= cp.data.get.power + continue power_raw -= cp.data.get.power self.data.set.raw_power_left = self.data.config.max_total_power - power_raw + self.data.set.raw_exported_power_left = self.data.config.max_total_power + power_exported_raw log.info(f'Verbleibende Leistung an Zähler {self.num}: {self.data.set.raw_power_left}W') + log.info((f'Verbleibende exportierte Leistung an Zähler {self.num}: ' + f'{self.data.set.raw_exported_power_left}W')) else: self.data.set.raw_power_left = self.data.config.max_power_errorcase + self.data.set.raw_exported_power_left = self.data.config.max_power_errorcase log.info(f'Verbleibende Leistung an Zähler {self.num} (Fehlerfall): {self.data.set.raw_power_left}W') + log.info((f'Verbleibende exportierte Leistung an Zähler {self.num} (Fehlerfall): ' + f'{self.data.set.raw_exported_power_left}W')) else: self.data.set.raw_power_left = None + self.data.set.raw_exported_power_left = None def update_values_left(self, diffs, cp_voltage: float) -> None: - # Mittelwert der Spannungen verwenden, um Phasenverdrehung zu kompensieren - # (Probleme bei einphasig angeschlossenen Wallboxen) - self.data.set.raw_currents_left = list(map(operator.sub, self.data.set.raw_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]) - log.debug(f'Zähler {self.num}: {self.data.set.raw_currents_left}A verbleibende Ströme, ' - f'{self.data.set.raw_power_left}W verbleibende Leistung') + self._update_raw_values(diffs, cp_voltage, surplus=False) def update_surplus_values_left(self, diffs, cp_voltage: float) -> None: + self._update_raw_values(diffs, cp_voltage, surplus=True) + + def _update_raw_values(self, diffs, cp_voltage: float, surplus: bool = False) -> None: + # Mittelwert der Spannungen verwenden, um Phasenverdrehung zu kompensieren + # (Probleme bei einphasig angeschlossenen Wallboxen) self.data.set.raw_currents_left = list(map(operator.sub, self.data.set.raw_currents_left, diffs)) - if self.data.set.surplus_power_left is not None: - self.data.set.surplus_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.surplus_power_left}W verbleibender Überschuss') + self.data.set.raw_exported_currents_left = list( + map(operator.add, self.data.set.raw_exported_currents_left, diffs)) + + if surplus: + if self.data.set.surplus_power_left is not None: + self.data.set.surplus_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.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, ' + f'{self.data.set.raw_exported_power_left}W verbleibende exportierte Leistung') def calc_surplus(self): # reservierte Leistung wird nicht berücksichtigt, weil diese noch verwendet werden kann, bis die EV diff --git a/packages/control/ev/charge_template.py b/packages/control/ev/charge_template.py index 20f7ba88bb..748f973b30 100644 --- a/packages/control/ev/charge_template.py +++ b/packages/control/ev/charge_template.py @@ -197,11 +197,13 @@ def time_charging(self, SOC_REACHED = "Keine Ladung, da das Ladeziel bereits erreicht wurde." AMOUNT_REACHED = "Keine Ladung, da die Energiemenge bereits geladen wurde." + INSTANT_CHARGING_BIDI = "Fahrzeug entlädt." def instant_charging(self, soc: Optional[float], used_amount: float, - charging_type: str) -> Tuple[int, str, Optional[str], int]: + charging_type: str, + bidi_state: BidiState) -> Tuple[int, str, Optional[str], int]: """ prüft, ob die Lademengenbegrenzung erreicht wurde und setzt entsprechend den Ladestrom. """ message = None @@ -223,6 +225,15 @@ def instant_charging(self, current = 0 sub_mode = "stop" message = self.AMOUNT_REACHED + elif current < 0: + if bidi_state == BidiState.BIDI_CAPABLE: + sub_mode = "bidi_charging" + message = self.INSTANT_CHARGING_BIDI + else: + message = bidi_state.value + current = 0 + sub_mode = "stop" + return current, sub_mode, message, phases except Exception: log.exception("Fehler im ev-Modul "+str(self.data.id)) diff --git a/packages/control/ev/charge_template_test.py b/packages/control/ev/charge_template_test.py index 5fc859e02b..b6989b5467 100644 --- a/packages/control/ev/charge_template_test.py +++ b/packages/control/ev/charge_template_test.py @@ -132,7 +132,7 @@ def test_instant_charging(selected: str, current_soc: float, used_amount: float, ct.data.chargemode.instant_charging.limit.amount = 1000 # execution - ret = ct.instant_charging(current_soc, used_amount, ChargingType.AC.value) + ret = ct.instant_charging(current_soc, used_amount, ChargingType.AC.value, BidiState.CP_NOT_BIDI_CAPABLE) # evaluation assert ret == expected diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index fab9167328..0ce96807b5 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -194,7 +194,8 @@ def get_required_current(self, required_current, submode, tmp_message, phases = charge_template.instant_charging( self.data.get.soc, imported_since_plugged, - charging_type) + charging_type, + bidi) elif charge_template.data.chargemode.selected == "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) diff --git a/packages/control/loadmanagement.py b/packages/control/loadmanagement.py index ae12e06f3c..a008b829e0 100644 --- a/packages/control/loadmanagement.py +++ b/packages/control/loadmanagement.py @@ -72,6 +72,26 @@ def get_available_currents_surplus(self, limit = new_limit if new_limit.limiting_value is not None else limit return available_currents, limit + def get_available_currents_bidi(self, + missing_currents: List[float], + cp_voltage: float, + counter: Counter, + feed_in: int = 0) -> Tuple[List[float], LoadmanagementLimit]: + raw_currents_left = counter.data.set.raw_currents_left + available_currents, limit = self._limit_by_current(counter, missing_currents, raw_currents_left) + + available_currents, new_limit = self._limit_by_power( + counter, available_currents, cp_voltage, counter.data.set.raw_power_left, feed_in) + 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(): + available_currents, new_limit = self._limit_by_unbalanced_load( + counter, available_currents, raw_currents_left, + len([value for value in missing_currents if value != 0])) + limit = new_limit if new_limit.limiting_value is not None else limit + + return available_currents, limit + def _limit_by_unbalanced_load(self, counter: Counter, available_currents: List[float], @@ -102,21 +122,25 @@ def _limit_by_power(self, # (Probleme bei einphasig angeschlossenen Wallboxen) currents = available_currents.copy() limit = LoadmanagementLimit(None, None) - if raw_power_left is None: + raw_exported_power_left = counter.data.set.raw_exported_power_left + total_power = sum([c * cp_voltage for c in available_currents]) + power_left = raw_power_left if total_power >= 0 else raw_exported_power_left + + if power_left is None: return currents, limit - elif raw_power_left > 0: + elif power_left > 0: if feed_in is not None: - raw_power_left = max(raw_power_left - feed_in, 0) - log.debug(f"Verbleibende Leistung unter Berücksichtigung der Einspeisegrenze: {raw_power_left}W") - if sum([c * cp_voltage for c in available_currents]) > raw_power_left: + power_left = max(power_left - feed_in, 0) + log.debug(f"Verbleibende Leistung unter Berücksichtigung der Einspeisegrenze: {power_left}W") + if abs(total_power) > power_left: for i in range(0, 3): try: # Am meisten belastete Phase trägt am meisten zur Leistungsreduktion bei. - currents[i] = available_currents[i] / sum(available_currents) * raw_power_left / cp_voltage + currents[i] = available_currents[i] / sum(available_currents) * power_left / cp_voltage except ZeroDivisionError: # bei einphasig angeschlossenen Wallboxen ist die Spannung der anderen Phasen 0V currents[i] = 0.0 - log.debug(f"Leistungsüberschreitung auf {raw_power_left}W korrigieren: {available_currents}") + log.debug(f"Leistungsüberschreitung auf {power_left}W korrigieren: {available_currents}") limit = LoadmanagementLimit(LimitingValue.POWER.value.format(get_component_name_by_id(counter.num)), LimitingValue.POWER) return currents, limit @@ -130,9 +154,13 @@ def _limit_by_current(self, missing_currents: List[float], raw_currents_left: List[float]) -> Tuple[List[float], LoadmanagementLimit]: available_currents = [0.0]*3 + raw_exported_currents_left = counter.data.set.raw_exported_currents_left limit = LoadmanagementLimit(None, None) for i in range(0, 3): - available_currents[i] = min(missing_currents[i], raw_currents_left[i]) + if missing_currents[i] >= 0: + available_currents[i] = min(missing_currents[i], raw_currents_left[i]) + else: + available_currents[i] = max(missing_currents[i], -raw_exported_currents_left[i]) if available_currents != missing_currents: log.debug(f"Stromüberschreitung {missing_currents}W korrigieren: {available_currents}") limit = LoadmanagementLimit(LimitingValue.CURRENT.value.format(get_component_name_by_id(counter.num)), diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index 7973936181..764bfa35df 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -485,7 +485,7 @@ def process_chargepoint_topic(self, msg: mqtt.MQTTMessage): else: self._validate_value(msg, float, [(float("-inf"), 0), (6, 32), (0, 0)]) elif "/set/required_power" in msg.topic: - self._validate_value(msg, float, [(0, float("inf"))]) + self._validate_value(msg, float) elif "/set/phases_to_use" in msg.topic: self._validate_value(msg, int, [(0, 3)]) elif ("/set/manual_lock" in msg.topic or