From 2456f16d2158d7303edf6f847ad0e63a5a0b31fa Mon Sep 17 00:00:00 2001 From: s0170071 Date: Wed, 23 Sep 2026 09:21:17 +0200 Subject: [PATCH 1/2] Speicher-Puffer: laufende Ladung halten statt zurueckschalten oder abschalten Wenn der Speicher im Modus "Mindest-SoC des Speichers" oberhalb des Maximal-SoC seine freigegebene Entladeleistung als Ueberschuss anbietet, soll er eine laufende Ueberschuss-Ladung ueber Wolken hinweg stuetzen. Die Regelung arbeitet aber dagegen: - Die Entladeleistung deckt bei mehrphasigem Laden nur einen Teil des Bedarfs, der Ueberschuss ist also <= 0 und damit die Bedingung fuer die Rueckschaltung 3 -> 1 erfuellt. Einphasig ist die Ladeleistung ein Drittel und der Puffer damit wirkungslos. - Ein- und festphasige Ladepunkte haben keine Umschaltung, die greifen koennte; bei ihnen laeuft stattdessen die Abschaltverzoegerung an. BatAll.is_buffering() macht diesen Zustand abfragbar - exakt die Bedingung, unter der get_charging_power_left_diff() die Entladeleistung freigibt. Solange sie wahr ist: - sperrt Ev._check_phase_switch_conditions() die Rueckschaltung 3 -> 1 (die Hochschaltung 1 -> 3 bleibt unberuehrt), - haelt Counter.switch_off_check_threshold() eine tatsaechlich fliessende Ladung gegen die Abschaltschwelle und bricht eine bereits laufende Abschaltverzoegerung ab. Der Mindeststrom selbst ist bereits strukturell gesichert: limit_adjust_current() gibt nie weniger als min_current zurueck. Verbraucher sind nicht betroffen, fuer sie gilt weiterhin die Verbraucher-Abschaltschwelle. Eine Ladung, die noch gar nicht laeuft, startet der Puffer nicht. Co-Authored-By: Claude Opus 5 --- packages/control/bat_all.py | 35 +++++++++++++++++++ packages/control/bat_all_test.py | 44 ++++++++++++++++++++++++ packages/control/counter.py | 29 ++++++++++++++-- packages/control/counter_test.py | 59 ++++++++++++++++++++++++++++++++ packages/control/ev/ev.py | 10 ++++++ packages/control/ev/ev_test.py | 45 +++++++++++++++++++++++- 6 files changed, 218 insertions(+), 4 deletions(-) diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index c1939c2187..69e2983c6a 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -459,6 +459,41 @@ def get_charging_power_left_diff(self): except Exception: log.exception("Fehler im Bat-Modul") + def is_buffering(self) -> bool: + """ gibt zurück, ob der Speicher die Überschuss-Ladung gerade puffert. + + Wahr im Modus "Mindest-SoC des Speichers" auf dem absteigenden Ast der Hysterese: der + Maximal-SoC wurde erreicht (hysteresis_discharge), der Mindest-SoC noch nicht wieder + unterschritten, und es ist eine Entladeleistung freigegeben. Genau in diesem Zustand gibt + get_charging_power_left_diff() die erlaubte Entladeleistung als Überschuss frei, damit der + Speicher eine laufende Ladung über Wolken hinweg stützt. + + Return + ------ + bool: Der Speicher stützt gerade die Ladung. + """ + try: + if self.data.config.configured is False or self.data.get.fault_state != 0: + return False + config = data.data.general_data.data.chargemode_config.bat + if config.mode != BatConsiderationMode.MIN_SOC_BAT.value: + # hysteresis_discharge wird nur im Mindest-SoC-Modus gepflegt und ist in den + # anderen Modi ein Altwert. + return False + if self.data.set.hysteresis_discharge is False or self.data.set.power_limit is not None: + return False + # Aktive Steuerung nicht konfiguriert oder + # Aktive Steuerung + Preisgrenze aktiv + Grenze nicht unterschritten + # -> dann wird die erlaubte Speicherentladeleistung addiert + power_discharge_allowed = (self.data.config.bat_control_activated is False or + (self.data.config.power_limit_condition == + BatPowerLimitCondition.PRICE_LIMIT.value and + self.data.set.power_limit is None)) + return config.power_discharge_active and power_discharge_allowed + except Exception: + log.exception("Fehler im Bat-Modul") + return False + def power_for_bat_charging(self): """ gibt die Leistung zurück, die zum Laden verwendet werden kann. diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index 4f0a6f7cbe..c77a75207a 100644 --- a/packages/control/bat_all_test.py +++ b/packages/control/bat_all_test.py @@ -524,3 +524,47 @@ def test_time_charging_min_bat_soc_allowed_pricing(ep_configured: bool, # evaluation assert result == expected_result + + +@pytest.mark.parametrize( + "configured, fault_state, mode, hysteresis_discharge, power_discharge_active, power_limit, expected_result", + [ + pytest.param(True, 0, BatConsiderationMode.MIN_SOC_BAT.value, True, True, None, True, + id="Mindest-SoC-Modus, Hysterese aktiv, Entladeleistung freigegeben -> puffert"), + pytest.param(True, 0, BatConsiderationMode.MIN_SOC_BAT.value, False, True, None, False, + id="Hysterese nicht aktiv -> puffert nicht"), + pytest.param(True, 0, BatConsiderationMode.MIN_SOC_BAT.value, True, False, None, False, + id="keine Entladeleistung freigegeben -> puffert nicht"), + pytest.param(True, 0, BatConsiderationMode.MIN_SOC_BAT.value, True, True, 2000, False, + id="Speicher wird mit vorgegebener Leistung entladen -> puffert nicht"), + pytest.param(True, 0, BatConsiderationMode.BAT_MODE.value, True, True, None, False, + id="Speicher-Modus, hysteresis_discharge ist dort ein Altwert -> puffert nicht"), + pytest.param(True, 0, BatConsiderationMode.EV_MODE.value, True, True, None, False, + id="EV-Modus, hysteresis_discharge ist dort ein Altwert -> puffert nicht"), + pytest.param(True, 2, BatConsiderationMode.MIN_SOC_BAT.value, True, True, None, False, + id="Fehlerfall, Speicher nicht in der Regelung -> puffert nicht"), + pytest.param(False, 0, BatConsiderationMode.MIN_SOC_BAT.value, True, True, None, False, + id="kein Speicher konfiguriert -> puffert nicht"), + ]) +def test_is_buffering(configured: bool, + fault_state: int, + mode: str, + hysteresis_discharge: bool, + power_discharge_active: bool, + power_limit: Optional[float], + expected_result: bool): + # setup + b = BatAll() + b.data.config.configured = configured + b.data.config.bat_control_activated = False + b.data.get.fault_state = fault_state + b.data.set.hysteresis_discharge = hysteresis_discharge + b.data.set.power_limit = power_limit + data.data.general_data.data.chargemode_config.bat.mode = mode + data.data.general_data.data.chargemode_config.bat.power_discharge_active = power_discharge_active + + # execution + result = b.is_buffering() + + # evaluation + assert result == expected_result diff --git a/packages/control/counter.py b/packages/control/counter.py index 8736fc0150..d3d6d94747 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -520,6 +520,26 @@ def calc_switch_off(self, load: Load) -> Tuple[float, float]: f'freigegebener Überschuss {self.data.set.released_surplus}W') return switch_off_power, threshold + BAT_BUFFERING = "Die Ladung wird gehalten, da der Speicher sie puffert." + + def _bat_buffer_holds_charge(self, load: Load) -> bool: + """ prüft, ob der Speicher diese Ladung gegen die Abschaltschwelle stützt. + + Die freigegebene Entladeleistung wird als Überschuss angeboten, damit eine laufende Ladung + weiterläuft. Bei mehrphasigen LP mit automatischer Umschaltung verhindert das bereits die + Rückschaltung auf eine Phase; bei ein- und festphasigen LP gibt es keine Umschaltung, die + greifen könnte, und die Ladung liefe ohne dieses Veto in die Abschaltverzögerung. + + Nur für Ladepunkte mit tatsächlich fließender Ladung: der Puffer soll eine laufende Ladung + halten, aber keine neue starten. Verbraucher sind nicht gemeint, für sie gilt weiterhin die + Verbraucher-Abschaltschwelle. + """ + if isinstance(load, Chargepoint) is False: + return False + if bool(load.data.set.current_prev or load.data.get.charge_state) is False: + return False + return data.data.bat_all_data.is_buffering() + 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 @@ -537,6 +557,7 @@ def switch_off_check_threshold(self, load: Load) -> bool: timestamp_switch_on_off = control_parameter.timestamp_switch_on_off power_in_use, threshold = self.calc_switch_off(load) + bat_buffering = self._bat_buffer_holds_charge(load) if control_parameter.state == ChargepointState.SWITCH_OFF_DELAY: # Wenn automatische Phasenumschaltung aktiv, die Umschaltung abwarten, bevor die Abschaltschwelle # greift. @@ -547,10 +568,10 @@ def switch_off_check_threshold(self, load: Load) -> bool: "Diese wird abgewartet, bevor die Abschaltverzögerung gestartet wird.") # Wurde die Abschaltschwelle erreicht? # Eigene Leistung aus der freigegebenen Leistung herausrechnen. - if power_in_use + load.data.set.required_power < threshold: + if bat_buffering or power_in_use + load.data.set.required_power < threshold: timestamp_switch_on_off = None self.data.set.released_surplus -= load.data.set.required_power - msg = texts.exceeded + msg = self.BAT_BUFFERING if bat_buffering else texts.exceeded control_parameter.state = ChargepointState.CHARGING_ALLOWED else: # Wurde die Abschaltschwelle ggf. durch die Verzögerung anderer LP erreicht? @@ -564,7 +585,9 @@ def switch_off_check_threshold(self, load: Load) -> bool: self.data.set.reserved_surplus == 0)) 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(): + if bat_buffering: + msg = self.BAT_BUFFERING + elif 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 diff --git a/packages/control/counter_test.py b/packages/control/counter_test.py index c1bd1ff754..691942de9a 100644 --- a/packages/control/counter_test.py +++ b/packages/control/counter_test.py @@ -241,3 +241,62 @@ def test_reset_switch_on_off_releases_reserved_surplus_on_switch_on_delay(genera assert evu_counter.data.set.reserved_surplus == 0 assert consumer.data.control_parameter.timestamp_switch_on_off is None assert consumer.data.control_parameter.state == ChargepointState.NO_CHARGING_ALLOWED + + +@dataclass +class SwitchOffParams: + name: str + state: ChargepointState + charge_state: bool + is_buffering: bool + expected_state: ChargepointState + expected_msg: Optional[str] + expected_released_surplus: float + + +switch_off_cases = [ + SwitchOffParams("laufende Abschaltverzögerung wird abgebrochen, wenn der Speicher puffert", + ChargepointState.SWITCH_OFF_DELAY, True, True, + ChargepointState.CHARGING_ALLOWED, Counter.BAT_BUFFERING, 0), + SwitchOffParams("ohne Puffer läuft die Abschaltverzögerung weiter", + ChargepointState.SWITCH_OFF_DELAY, True, False, + ChargepointState.SWITCH_OFF_DELAY, None, 1500), + SwitchOffParams("es wird keine Abschaltverzögerung gestartet, wenn der Speicher puffert", + ChargepointState.CHARGING_ALLOWED, True, True, + ChargepointState.CHARGING_ALLOWED, Counter.BAT_BUFFERING, 0), + SwitchOffParams("ohne Puffer wird die Abschaltverzögerung gestartet", + ChargepointState.CHARGING_ALLOWED, True, False, + ChargepointState.SWITCH_OFF_DELAY, + Counter.SWITCH_OFF_TEXTS_CP.waiting.format("1 Min."), 1500), + SwitchOffParams("eine nicht fließende Ladung wird nicht gehalten", + ChargepointState.CHARGING_ALLOWED, False, True, + ChargepointState.NO_CHARGING_ALLOWED, Counter.SWITCH_OFF_TEXTS_CP.not_charging, 0), +] + + +@pytest.mark.parametrize("params", switch_off_cases, ids=[c.name for c in switch_off_cases]) +def test_switch_off_check_threshold_bat_buffering(params: SwitchOffParams, general_data_fixture, monkeypatch): + # setup + c = Counter(0) + c.data.set.released_surplus = 1500 if params.state == ChargepointState.SWITCH_OFF_DELAY else 0 + cp = Chargepoint(0, None) + ev = Ev(0) + ev.data.charge_template = ChargeTemplate() + cp.data.set.charging_ev_data = ev + cp.data.set.required_power = 1500 + cp.data.set.current_prev = 6 if params.charge_state else 0 + cp.data.get.charge_state = params.charge_state + cp.data.get.currents = [6, 6, 6] + cp.data.control_parameter.state = params.state + cp.data.control_parameter.min_current = 6 + # Abschaltschwelle überschritten: eigene Leistung herausgerechnet immer noch darüber + monkeypatch.setattr(Counter, "calc_switch_off", Mock(return_value=[2000, 0])) + monkeypatch.setattr(data.data.bat_all_data, "is_buffering", Mock(return_value=params.is_buffering)) + + # execution + c.switch_off_check_threshold(cp) + + # evaluation + assert cp.data.control_parameter.state == params.expected_state + assert cp.data.get.state_str == params.expected_msg + assert c.data.set.released_surplus == params.expected_released_surplus diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index 0a1e77abcf..9fb8883064 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -256,6 +256,7 @@ def check_min_max_current(self, + "Stromabweichung aus dem Fahrzeug-Profil/Minimalen Dauerstrom lädt.") ENOUGH_POWER = ", da ausreichend Überschuss für mehrphasiges Laden zur Verfügung steht." NOT_ENOUGH_POWER = ", da nicht ausreichend Überschuss für mehrphasiges Laden zur Verfügung steht." + BAT_BUFFERING = ", da der Speicher die Ladung puffert." def _check_phase_switch_conditions(self, control_parameter: ControlParameter, @@ -271,6 +272,15 @@ def _check_phase_switch_conditions(self, max_current = min(self.ev_template.data.max_current_single_phase, max_current_cp) max_current_range = max_current - self.ev_template.data.nominal_difference phases_in_use = control_parameter.phases + if (phases_in_use > 1 and + control_parameter.submode == Chargemode.PV_CHARGING and + data.data.bat_all_data.is_buffering()): + # Der Speicher stützt die Ladung gerade mit seiner freigegebenen Entladeleistung. Diese + # deckt bei mehrphasigem Laden nur einen Teil des Bedarfs, der Überschuss ist also <= 0 + # und damit die Rückschaltbedingung erfüllt - obwohl genau dieser Zustand der ist, den + # der Puffer überbrücken soll. Einphasig wäre die Ladeleistung ein Drittel und der + # Puffer damit wirkungslos. Die Hochschaltung 1 -> 3 bleibt unberührt. + return False, self.BAT_BUFFERING max_phases_ev = self.ev_template.data.max_phases required_surplus = control_parameter.min_current * max_phases_ev * 230 - get_power unbalanced_load_limit_reached = limit.limiting_value == LimitingValue.UNBALANCED_LOAD diff --git a/packages/control/ev/ev_test.py b/packages/control/ev/ev_test.py index e4e91b2bc7..42dfb1358c 100644 --- a/packages/control/ev/ev_test.py +++ b/packages/control/ev/ev_test.py @@ -1,11 +1,13 @@ -from typing import Optional +from typing import List, Optional, Tuple from unittest.mock import Mock import pytest from control import data +from control.chargemode import Chargemode from control.chargepoint.control_parameter import ControlParameter +from control.limiting_value import LoadmanagementLimit from control.ev.ev import Ev, get_ev_to_rfid from helpermodules import timecheck from modules.common.abstract_vehicle import VehicleUpdateData @@ -122,3 +124,44 @@ def test_ev_identification(rfid, vehicle_id, expected_result): # evaluation assert result == expected_result + + +@pytest.mark.parametrize( + "phases, submode, currents, surplus, is_buffering, expected_result", + [ + pytest.param(3, Chargemode.PV_CHARGING, [6, 6, 6], -500, True, (False, Ev.BAT_BUFFERING), + id="Speicher puffert -> keine Rückschaltung 3 -> 1"), + pytest.param(3, Chargemode.PV_CHARGING, [6, 6, 6], -500, False, (True, None), + id="Speicher puffert nicht -> Rückschaltung 3 -> 1 wie bisher"), + pytest.param(1, Chargemode.PV_CHARGING, [16, 0, 0], 5000, True, (True, None), + id="Speicher puffert, Hochschaltung 1 -> 3 bleibt unberührt"), + pytest.param(3, Chargemode.INSTANT_CHARGING, [6, 6, 6], -500, True, (True, None), + id="kein Überschuss-Laden -> Sperre greift nicht"), + ]) +def test_check_phase_switch_conditions_bat_buffering(phases: int, + submode: Chargemode, + currents: List[float], + surplus: float, + is_buffering: bool, + expected_result: Tuple[bool, Optional[str]], + monkeypatch): + # setup + ev = Ev(0) + control_parameter = ControlParameter() + control_parameter.phases = phases + control_parameter.submode = submode + control_parameter.min_current = 6 + control_parameter.required_current = 6 + monkeypatch.setattr(data.data.bat_all_data, "is_buffering", Mock(return_value=is_buffering)) + + # execution + result = ev._check_phase_switch_conditions(control_parameter, + evse_current=6, + get_currents=currents, + get_power=sum(currents) * 230, + max_current_cp=16, + limit=LoadmanagementLimit(None, None), + surplus=surplus) + + # evaluation + assert result == expected_result From def1d83ea128f483f520603e652216b61387ed19 Mon Sep 17 00:00:00 2001 From: s0170071 Date: Wed, 23 Sep 2026 09:31:13 +0200 Subject: [PATCH 2/2] Leerer Puffer: abschalten statt zurueckschalten Die Rueckschaltung hat Vorrang vor der Abschaltung: surplus_controlled.py ueberspringt switch_off_check_threshold(), solange phase_switch_necessary() gilt. Diese Bedingung ist rein statisch (HW kann umschalten, aktuell nicht einphasig, Automatikmodus), ein dreiphasiger Automatik-LP erreicht die Abschaltschwelle also nie. Wenn der Puffer leer laeuft, stapeln sich dadurch die Verzoegerungen: fuer 3 -> 1 gilt switch_off_delay, dazu der Puffer phase_switch_delay seit der letzten Umschaltung, und danach wartet die Abschaltschwelle noch einmal switch_off_delay ab. Die Ladung laeuft die ganze Zeit aus dem Netz, waehrend der Speicher sich eigentlich wieder aufladen soll. Unterhalb des Mindest-SoC ist die Rueckschaltung auch nicht die richtige Reaktion: einphasig weiterzuladen konkurriert weiter mit dem Speicher, der dort Vorrang hat. BatAll.is_buffer_depleted() macht diesen Zustand abfragbar; solange er gilt, - unterdrueckt Ev._check_phase_switch_conditions() die Rueckschaltung 3 -> 1, - laesst phase_switch_necessary() die Abschaltschwelle durch. Damit bleibt genau eine switch_off_delay stehen. Erholt sich der Puffer, entscheidet die Einschaltlogik neu, ob ein- oder dreiphasig gestartet wird. Co-Authored-By: Claude Opus 5 --- .../control/algorithm/surplus_controlled.py | 9 +++ .../algorithm/surplus_controlled_test.py | 37 ++++++++++ packages/control/bat_all.py | 71 +++++++++++++------ packages/control/bat_all_test.py | 30 ++++++++ packages/control/ev/ev.py | 27 ++++--- packages/control/ev/ev_test.py | 38 ++++++++++ 6 files changed, 182 insertions(+), 30 deletions(-) diff --git a/packages/control/algorithm/surplus_controlled.py b/packages/control/algorithm/surplus_controlled.py index 518890bcaf..5f91d2617b 100644 --- a/packages/control/algorithm/surplus_controlled.py +++ b/packages/control/algorithm/surplus_controlled.py @@ -135,6 +135,15 @@ def check_submode_pv_charging(self) -> None: for load in get_loads_by_chargemodes(CONSIDERED_CHARGE_MODES_PV_ONLY): try: def phase_switch_necessary() -> bool: + if data.data.bat_all_data.is_buffer_depleted(): + # Der Speicher-Puffer ist leer und der Speicher hat bis zum Mindest-SoC + # Vorrang. Die Rückschaltung 3 -> 1 ist dann nicht mehr die richtige + # Reaktion, sondern das Abschalten - sonst stapeln sich switch_off_delay + # (Rückschaltung), phase_switch_delay und noch einmal switch_off_delay + # (Abschaltung), und die Ladung laeuft die ganze Zeit aus dem Netz. + # Ev._check_phase_switch_conditions() unterdrückt die Rückschaltung + # passend dazu. + return False 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) diff --git a/packages/control/algorithm/surplus_controlled_test.py b/packages/control/algorithm/surplus_controlled_test.py index 3c537130b8..5b17b1575e 100644 --- a/packages/control/algorithm/surplus_controlled_test.py +++ b/packages/control/algorithm/surplus_controlled_test.py @@ -10,6 +10,7 @@ from control.chargemode import Chargemode from control.chargepoint.chargepoint import Chargepoint, ChargepointData from control.chargepoint.chargepoint_data import Get, Set +from control.chargepoint.chargepoint_state import ChargepointState from control.chargepoint.chargepoint_template import CpTemplate from control.chargepoint.control_parameter import ControlParameter from control.counter_all.counter_all import CounterAll @@ -158,3 +159,39 @@ def setup_cp(cp: Chargepoint, submode: str) -> Chargepoint: # assertion assert chargepoints == expected_chargepoints + + +@pytest.mark.parametrize( + "is_buffer_depleted, expected_switch_off_checked", + [ + pytest.param(True, True, + id="Puffer leer -> Abschaltschwelle greift trotz möglicher Rückschaltung"), + pytest.param(False, False, + id="Puffer nicht leer -> Rückschaltung hat wie bisher Vorrang"), + ]) +def test_check_submode_pv_charging_buffer_depleted(is_buffer_depleted: bool, + expected_switch_off_checked: bool, + mock_cp1: Chargepoint, + monkeypatch): + # setup: dreiphasig ladender LP im Automatikmodus, dessen Hardware umschalten kann + mock_cp1.data = ChargepointData() + mock_cp1.data.set.charging_ev_data = Ev(1) + mock_cp1.data.config.ev = 1 + mock_cp1.data.control_parameter.chargemode = Chargemode.PV_CHARGING + mock_cp1.data.control_parameter.submode = Chargemode.PV_CHARGING + mock_cp1.data.control_parameter.state = ChargepointState.CHARGING_ALLOWED + mock_cp1.data.control_parameter.template_phases = 0 + mock_cp1.data.get.phases_in_use = 3 + monkeypatch.setattr(Chargepoint, "cp_state_hw_support_phase_switch", Mock(return_value=True)) + data.data.cp_data = {"cp1": mock_cp1} + data.data.counter_all_data.data.get.loadmanagement_prios = [{"type": "vehicle", "id": 1}] + switch_off_check_threshold_mock = Mock() + evu_counter_mock = Mock(switch_off_check_threshold=switch_off_check_threshold_mock) + monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", Mock(return_value=evu_counter_mock)) + monkeypatch.setattr(data.data.bat_all_data, "is_buffer_depleted", Mock(return_value=is_buffer_depleted)) + + # execution + SurplusControlled().check_submode_pv_charging() + + # evaluation + assert switch_off_check_threshold_mock.called == expected_switch_off_checked diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index 69e2983c6a..b8fb4ddd0a 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -459,37 +459,66 @@ def get_charging_power_left_diff(self): except Exception: log.exception("Fehler im Bat-Modul") + def _buffer_configured(self) -> bool: + """ gibt zurück, ob der Speicher überhaupt als Puffer für die Ladung wirken kann. + + Nur im Modus "Mindest-SoC des Speichers" und nur, wenn eine Entladeleistung für die + Fahrzeuge freigegeben ist. Die Prüfung der Freigabe entspricht der in + get_charging_power_left_diff(), wo die erlaubte Entladeleistung dem Überschuss + zugeschlagen wird. + """ + if self.data.config.configured is False or self.data.get.fault_state != 0: + return False + config = data.data.general_data.data.chargemode_config.bat + if config.mode != BatConsiderationMode.MIN_SOC_BAT.value: + return False + # Aktive Steuerung nicht konfiguriert oder + # Aktive Steuerung + Preisgrenze aktiv + Grenze nicht unterschritten + # -> dann wird die erlaubte Speicherentladeleistung addiert + power_discharge_allowed = (self.data.config.bat_control_activated is False or + (self.data.config.power_limit_condition == + BatPowerLimitCondition.PRICE_LIMIT.value and + self.data.set.power_limit is None)) + return config.power_discharge_active and power_discharge_allowed + def is_buffering(self) -> bool: """ gibt zurück, ob der Speicher die Überschuss-Ladung gerade puffert. - Wahr im Modus "Mindest-SoC des Speichers" auf dem absteigenden Ast der Hysterese: der - Maximal-SoC wurde erreicht (hysteresis_discharge), der Mindest-SoC noch nicht wieder - unterschritten, und es ist eine Entladeleistung freigegeben. Genau in diesem Zustand gibt - get_charging_power_left_diff() die erlaubte Entladeleistung als Überschuss frei, damit der - Speicher eine laufende Ladung über Wolken hinweg stützt. + Wahr auf dem absteigenden Ast der Hysterese: der Maximal-SoC wurde erreicht + (hysteresis_discharge), der Mindest-SoC noch nicht wieder unterschritten. Genau in diesem + Zustand gibt get_charging_power_left_diff() die erlaubte Entladeleistung als Überschuss + frei, damit der Speicher eine laufende Ladung über Wolken hinweg stützt. + + hysteresis_discharge wird nur im Mindest-SoC-Modus gepflegt und ist in den anderen Modi ein + Altwert, deshalb die Modus-Prüfung in _buffer_configured(). Return ------ bool: Der Speicher stützt gerade die Ladung. """ try: - if self.data.config.configured is False or self.data.get.fault_state != 0: - return False + return (self._buffer_configured() and + self.data.set.hysteresis_discharge and + self.data.set.power_limit is None) + except Exception: + log.exception("Fehler im Bat-Modul") + return False + + def is_buffer_depleted(self) -> bool: + """ gibt zurück, ob der Puffer aufgebraucht ist und der Speicher Vorrang hat. + + Unterhalb des Mindest-SoC gibt get_charging_power_left_diff() keine Entladeleistung mehr + frei, setzt hysteresis_discharge zurück und regulate_up, damit der Speicher sich wieder + aufladen kann ("Speicher-Vorrang bis zum Min-Soc"). Eine laufende Fahrzeug-Ladung + konkurriert ab hier direkt mit dem Speicher. + + Return + ------ + bool: Der Puffer ist leer, der Speicher lädt sich wieder auf. + """ + try: config = data.data.general_data.data.chargemode_config.bat - if config.mode != BatConsiderationMode.MIN_SOC_BAT.value: - # hysteresis_discharge wird nur im Mindest-SoC-Modus gepflegt und ist in den - # anderen Modi ein Altwert. - return False - if self.data.set.hysteresis_discharge is False or self.data.set.power_limit is not None: - return False - # Aktive Steuerung nicht konfiguriert oder - # Aktive Steuerung + Preisgrenze aktiv + Grenze nicht unterschritten - # -> dann wird die erlaubte Speicherentladeleistung addiert - power_discharge_allowed = (self.data.config.bat_control_activated is False or - (self.data.config.power_limit_condition == - BatPowerLimitCondition.PRICE_LIMIT.value and - self.data.set.power_limit is None)) - return config.power_discharge_active and power_discharge_allowed + return self._buffer_configured() and self.data.get.soc < config.min_soc except Exception: log.exception("Fehler im Bat-Modul") return False diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index c77a75207a..2e558d9544 100644 --- a/packages/control/bat_all_test.py +++ b/packages/control/bat_all_test.py @@ -568,3 +568,33 @@ def test_is_buffering(configured: bool, # evaluation assert result == expected_result + + +@pytest.mark.parametrize( + "mode, soc, power_discharge_active, expected_result", + [ + pytest.param(BatConsiderationMode.MIN_SOC_BAT.value, 19, True, True, + id="unter Mindest-SoC, Speicher hat Vorrang -> Puffer leer"), + pytest.param(BatConsiderationMode.MIN_SOC_BAT.value, 20, True, False, + id="Mindest-SoC erreicht -> Puffer nicht leer"), + pytest.param(BatConsiderationMode.MIN_SOC_BAT.value, 19, False, False, + id="keine Entladeleistung freigegeben, es gibt keinen Puffer"), + pytest.param(BatConsiderationMode.BAT_MODE.value, 19, True, False, + id="Speicher-Modus, es gibt keinen Puffer"), + ]) +def test_is_buffer_depleted(mode: str, soc: int, power_discharge_active: bool, expected_result: bool): + # setup + b = BatAll() + b.data.config.configured = True + b.data.config.bat_control_activated = False + b.data.get.fault_state = 0 + b.data.get.soc = soc + data.data.general_data.data.chargemode_config.bat.mode = mode + data.data.general_data.data.chargemode_config.bat.min_soc = 20 + data.data.general_data.data.chargemode_config.bat.power_discharge_active = power_discharge_active + + # execution + result = b.is_buffer_depleted() + + # evaluation + assert result == expected_result diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index 9fb8883064..9694657223 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -257,6 +257,7 @@ def check_min_max_current(self, ENOUGH_POWER = ", da ausreichend Überschuss für mehrphasiges Laden zur Verfügung steht." NOT_ENOUGH_POWER = ", da nicht ausreichend Überschuss für mehrphasiges Laden zur Verfügung steht." BAT_BUFFERING = ", da der Speicher die Ladung puffert." + BAT_BUFFER_DEPLETED = ", da der Speicher-Puffer leer ist und der Speicher Vorrang hat." def _check_phase_switch_conditions(self, control_parameter: ControlParameter, @@ -272,15 +273,23 @@ def _check_phase_switch_conditions(self, max_current = min(self.ev_template.data.max_current_single_phase, max_current_cp) max_current_range = max_current - self.ev_template.data.nominal_difference phases_in_use = control_parameter.phases - if (phases_in_use > 1 and - control_parameter.submode == Chargemode.PV_CHARGING and - data.data.bat_all_data.is_buffering()): - # Der Speicher stützt die Ladung gerade mit seiner freigegebenen Entladeleistung. Diese - # deckt bei mehrphasigem Laden nur einen Teil des Bedarfs, der Überschuss ist also <= 0 - # und damit die Rückschaltbedingung erfüllt - obwohl genau dieser Zustand der ist, den - # der Puffer überbrücken soll. Einphasig wäre die Ladeleistung ein Drittel und der - # Puffer damit wirkungslos. Die Hochschaltung 1 -> 3 bleibt unberührt. - return False, self.BAT_BUFFERING + # Die Hochschaltung 1 -> 3 bleibt von beiden Sperren unberührt: ist genug Überschuss für + # drei Phasen da, soll unabhängig vom Speicher hochgeschaltet werden. + if phases_in_use > 1 and control_parameter.submode == Chargemode.PV_CHARGING: + if data.data.bat_all_data.is_buffering(): + # Der Speicher stützt die Ladung gerade mit seiner freigegebenen Entladeleistung. + # Diese deckt bei mehrphasigem Laden nur einen Teil des Bedarfs, der Überschuss ist + # also <= 0 und damit die Rückschaltbedingung erfüllt - obwohl genau dieser Zustand + # der ist, den der Puffer überbrücken soll. Einphasig wäre die Ladeleistung ein + # Drittel und der Puffer damit wirkungslos. + return False, self.BAT_BUFFERING + if data.data.bat_all_data.is_buffer_depleted(): + # Der Puffer ist leer, der Speicher hat bis zum Mindest-SoC Vorrang. Einphasig + # weiterzuladen würde den Speicher weiter am Laden hindern, deshalb wird nicht + # zurückgeschaltet, sondern die Abschaltschwelle greifen gelassen. Erholt sich der + # Puffer, wird beim Einschalten neu entschieden, ob ein- oder dreiphasig gestartet + # wird. + return False, self.BAT_BUFFER_DEPLETED max_phases_ev = self.ev_template.data.max_phases required_surplus = control_parameter.min_current * max_phases_ev * 230 - get_power unbalanced_load_limit_reached = limit.limiting_value == LimitingValue.UNBALANCED_LOAD diff --git a/packages/control/ev/ev_test.py b/packages/control/ev/ev_test.py index 42dfb1358c..836d7d3a8d 100644 --- a/packages/control/ev/ev_test.py +++ b/packages/control/ev/ev_test.py @@ -165,3 +165,41 @@ def test_check_phase_switch_conditions_bat_buffering(phases: int, # evaluation assert result == expected_result + + +@pytest.mark.parametrize( + "phases, submode, is_buffer_depleted, expected_result", + [ + pytest.param(3, Chargemode.PV_CHARGING, True, (False, Ev.BAT_BUFFER_DEPLETED), + id="Puffer leer -> keine Rückschaltung 3 -> 1, stattdessen Abschaltung"), + pytest.param(3, Chargemode.PV_CHARGING, False, (True, None), + id="Puffer nicht leer -> Rückschaltung 3 -> 1 wie bisher"), + pytest.param(1, Chargemode.PV_CHARGING, True, (False, Ev.NOT_ENOUGH_POWER), + id="einphasig, es gibt nichts zurückzuschalten"), + ]) +def test_check_phase_switch_conditions_bat_buffer_depleted(phases: int, + submode: Chargemode, + is_buffer_depleted: bool, + expected_result: Tuple[bool, Optional[str]], + monkeypatch): + # setup + ev = Ev(0) + control_parameter = ControlParameter() + control_parameter.phases = phases + control_parameter.submode = submode + control_parameter.min_current = 6 + control_parameter.required_current = 6 + monkeypatch.setattr(data.data.bat_all_data, "is_buffering", Mock(return_value=False)) + monkeypatch.setattr(data.data.bat_all_data, "is_buffer_depleted", Mock(return_value=is_buffer_depleted)) + + # execution + result = ev._check_phase_switch_conditions(control_parameter, + evse_current=6, + get_currents=[6, 6, 6], + get_power=4140, + max_current_cp=16, + limit=LoadmanagementLimit(None, None), + surplus=-500) + + # evaluation + assert result == expected_result