From a9116a4b13de27110dd1ecd2d69dd32237bf6fe1 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Wed, 23 Sep 2026 15:57:54 +0200 Subject: [PATCH 1/2] =?UTF-8?q?PV-Laden:=20bei=20ausreichend=20=C3=9Cbersc?= =?UTF-8?q?huss=20direkt=20mehrphasig=20statt=20einphasig=20starten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_phases_by_selected_chargemode() erzwang beim Ladestart unconditional phases=1, unabhängig vom Überschuss und davon, auf welcher Phasenzahl die Hardware (Rest vom vorherigen Ladevorgang) noch steht. Das löste eine unnötige Umschaltung aus, bevor überhaupt eine Einschaltverzögerung beginnt (state=NO_CHARGING_ALLOWED) - mit echtem Log belegt. Fix bewusst in Algorithm._check_phases_at_charging_start() statt in der Ladepunkt-Klasse: nur hier sind Reihenfolge und noch verfügbarer Überschuss über alle Ladepunkte hinweg bekannt, remaining_surplus wird lokal fortgeschrieben, damit nicht zwei gleichzeitig neu startende Ladepunkte denselben Überschuss beanspruchen. Co-Authored-By: Claude Sonnet 5 --- packages/control/algorithm/algorithm.py | 29 ++++ packages/control/algorithm/algorithm_test.py | 152 +++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 packages/control/algorithm/algorithm_test.py diff --git a/packages/control/algorithm/algorithm.py b/packages/control/algorithm/algorithm.py index fa4b01ed8c..6557e59bb0 100644 --- a/packages/control/algorithm/algorithm.py +++ b/packages/control/algorithm/algorithm.py @@ -8,6 +8,7 @@ from control.algorithm.min_current import MinCurrent from control.algorithm.no_current import NoCurrent from control.algorithm.surplus_controlled import SurplusControlled +from control.chargemode import Chargemode log = logging.getLogger(__name__) @@ -26,6 +27,7 @@ def calc_current(self) -> None: try: log.info("# Algorithmus") self.evu_counter = data.data.counter_all_data.get_evu_counter() + self._check_phases_at_charging_start() self._check_auto_phase_switch_delay() self.surplus_controlled.check_submode_pv_charging() common.reset_current() @@ -52,6 +54,33 @@ def calc_current(self) -> None: except Exception: log.exception("Fehler im Algorithmus-Modul") + def _check_phases_at_charging_start(self) -> None: + """ Ist vor Ladestart bereits genug Überschuss für mehrphasiges Laden vorhanden, direkt + mehrphasig starten. Sonst müsste erst einphasig gestartet und nach der Umschaltverzögerung + wieder hochgeschaltet werden, siehe Hilfetext zu "Pufferzeit zwischen automat. + Phasenumschaltungen". Bewusst hier im Algorithmus und nicht in der Ladepunkt-Klasse, da nur + hier die Reihenfolge und der noch verfügbare Überschuss über alle Ladepunkte hinweg bekannt sind: + remaining_surplus wird lokal fortgeschrieben, damit nicht zwei gleichzeitig neu startende + Ladepunkte unabhängig voneinander denselben Überschuss beanspruchen. + """ + remaining_surplus = self.evu_counter.get_usable_surplus() + for cp in data.data.cp_data.values(): + try: + control_parameter = cp.data.control_parameter + charging_ev = cp.data.set.charging_ev_data + if (control_parameter.submode == Chargemode.PV_CHARGING and + cp.data.get.charge_state is False and + cp.data.set.charge_template.data.chargemode.pv_charging.phases_to_use == 0 and + cp.hw_supports_phase_switch()): + max_phase_hw = cp.get_max_phase_hw() + if max_phase_hw > 1: + required_surplus = charging_ev.ev_template.data.min_current * max_phase_hw * 230 + if remaining_surplus > required_surplus: + control_parameter.phases = max_phase_hw + remaining_surplus -= required_surplus + except Exception: + log.exception(f"Fehler im Algorithmus-Modul für Ladepunkt{cp.num}") + def _check_auto_phase_switch_delay(self) -> None: """ geht alle LP durch und prüft, ob eine Ladung aktiv ist, ob automatische Phasenumschaltung möglich ist und ob ob ein Timer gestartet oder gestoppt werden muss oder ob diff --git a/packages/control/algorithm/algorithm_test.py b/packages/control/algorithm/algorithm_test.py new file mode 100644 index 0000000000..b9e96c7e22 --- /dev/null +++ b/packages/control/algorithm/algorithm_test.py @@ -0,0 +1,152 @@ +from threading import Event +from unittest.mock import Mock +import pytest + +from control import data +from control.algorithm.algorithm import Algorithm +from control.chargemode import Chargemode +from control.chargepoint.chargepoint import Chargepoint +from control.chargepoint.chargepoint_state import ChargepointState +from control.chargepoint.chargepoint_template import CpTemplate, get_chargepoint_template_default +from control.ev.ev import Ev +from control.general import General + + +@pytest.fixture(autouse=True) +def general() -> None: + data.data_init(Event()) + data.data.general_data = General() + data.data.cp_data = {} + + +def _make_cp(num: int) -> Chargepoint: + cp = Chargepoint(num, None) + cp.template = CpTemplate() + cp.template.data = get_chargepoint_template_default() + cp.data.set.charging_ev_data = Ev(0) + cp.data.config.connected_phases = 3 + cp.data.config.auto_phase_switch_hw = True + cp.data.get.charge_state = False + cp.data.control_parameter.submode = Chargemode.PV_CHARGING + cp.data.control_parameter.phases = 3 + cp.data.set.phases_to_use = 3 + cp.data.get.phases_in_use = 3 + cp.data.set.log.imported_since_plugged = 0 + cp.data.set.charge_template.data.chargemode.pv_charging.phases_to_use = 0 + cp.data.set.charging_ev_data.ev_template.data.prevent_phase_switch = False + cp.data.set.charging_ev_data.ev_template.data.min_current = 6 + data.data.cp_data[f"cp{num}"] = cp + return cp + + +class SurplusAtStartParams: + def __init__(self, + name: str, + submode: Chargemode, + phases_to_use_config: int, + usable_surplus: float, + min_current: float, + expected_phases: int) -> None: + self.name = name + self.submode = submode + self.phases_to_use_config = phases_to_use_config + self.usable_surplus = usable_surplus + self.min_current = min_current + self.expected_phases = expected_phases + + +surplus_at_start_cases = [ + SurplusAtStartParams("pv charging, enough surplus: start with max phases", submode=Chargemode.PV_CHARGING, + phases_to_use_config=0, usable_surplus=5000, min_current=6, expected_phases=3), + SurplusAtStartParams("pv charging, not enough surplus: start with 1 phase", submode=Chargemode.PV_CHARGING, + phases_to_use_config=0, usable_surplus=100, min_current=6, expected_phases=1), + SurplusAtStartParams("pv charging, fixed phases_to_use: no surplus check, start with 1 phase", + submode=Chargemode.PV_CHARGING, + phases_to_use_config=3, usable_surplus=3000, min_current=6, expected_phases=1), + SurplusAtStartParams("instant charging: no surplus check, start with 1 phase", submode=Chargemode.INSTANT_CHARGING, + phases_to_use_config=0, usable_surplus=3000, min_current=6, expected_phases=1), +] + + +@pytest.mark.parametrize("params", surplus_at_start_cases, ids=[c.name for c in surplus_at_start_cases]) +def test_check_phases_at_charging_start_uses_surplus(monkeypatch: pytest.MonkeyPatch, params: SurplusAtStartParams): + # setup + cp = _make_cp(0) + cp.data.control_parameter.submode = params.submode + cp.data.set.charge_template.data.chargemode.pv_charging.phases_to_use = params.phases_to_use_config + cp.data.set.charging_ev_data.ev_template.data.min_current = params.min_current + # phases_to_use=1 spiegelt das bestehende Default-Verhalten (get_phases_by_selected_chargemode() + # startet unconditional einphasig), diese Methode entscheidet, ob das auf max_phase_hw angehoben wird. + cp.data.control_parameter.phases = 1 + mock_evu = Mock() + monkeypatch.setattr(mock_evu, "get_usable_surplus", Mock(return_value=params.usable_surplus)) + monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", Mock(return_value=mock_evu)) + algorithm = Algorithm() + algorithm.evu_counter = mock_evu + + # execution + algorithm._check_phases_at_charging_start() + + # evaluation + assert cp.data.control_parameter.phases == params.expected_phases + + +def test_check_phases_at_charging_start_does_not_double_count_surplus_across_chargepoints(monkeypatch): + """ Zwei Ladepunkte starten im selben Zyklus, der Überschuss reicht nur für einen davon + dreiphasig - der zweite darf nicht denselben, bereits vom ersten beanspruchten Überschuss + nochmal für sich behaupten. """ + # setup + cp1 = _make_cp(1) + cp2 = _make_cp(2) + # Überschuss reicht für genau einen Ladepunkt (min_current=6 * 3 Phasen * 230V = 4140W), + # aber nicht für zwei. + usable_surplus = 5000 + for cp in (cp1, cp2): + cp.data.control_parameter.phases = 1 + cp.data.set.charging_ev_data.ev_template.data.min_current = 6 + mock_evu = Mock() + monkeypatch.setattr(mock_evu, "get_usable_surplus", Mock(return_value=usable_surplus)) + monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", Mock(return_value=mock_evu)) + algorithm = Algorithm() + algorithm.evu_counter = mock_evu + + # execution + algorithm._check_phases_at_charging_start() + + # evaluation + phases = sorted([cp1.data.control_parameter.phases, cp2.data.control_parameter.phases]) + assert phases == [1, 3] + + +def test_check_phases_at_charging_start_reproduces_matts_log(monkeypatch): + """ Exakte Werte aus einem realen Log (Matt, 2026-08-08 12:35:43): CUPRA Born (min_current=6, + max_phases=3) wird an einem 3-phasigen Ladepunkt neu erkannt, Automatik-Umschaltung, noch nicht + ladend. "Verbleibende Leistung an Zähler 0: 32243.87W" zu diesem Zeitpunkt. Auf master wurde hier + unconditional phases=1 erzwungen, was trotz der Hardware auf 3 Phasen die unnötige 3->1-Umschaltung + ausgelöst hat ("LP 4: Umschaltung von 3 auf 1 Phase, dafür wird die Ladung unterbrochen."). """ + # setup + cp = _make_cp(4) + cp.data.control_parameter.phases = 1 # Default-Startwert, wie get_phases_by_selected_chargemode() ihn liefert + cp.data.set.charging_ev_data.ev_template.data.min_current = 6 + cp.data.set.charging_ev_data.ev_template.data.max_phases = 3 + mock_evu = Mock() + monkeypatch.setattr(mock_evu, "get_usable_surplus", Mock(return_value=32243.87)) + monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", Mock(return_value=mock_evu)) + algorithm = Algorithm() + algorithm.evu_counter = mock_evu + + # execution + algorithm._check_phases_at_charging_start() + + # evaluation + assert cp.data.control_parameter.phases == 3 + + # Downstream-Kette: mit control_parameter.phases jetzt korrekt auf 3 (statt 1, wie auf master), + # findet _is_phase_switch_required() keinen Mismatch zur Hardware mehr - genau der Aufruf, der in + # process.py nach dem Algorithmus die eigentliche Umschaltung 3->1 ausgelöst hätte. + cp.data.control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED + cp.data.set.phases_to_use = 3 # von _process_charge_stop() beim Abstecken auf phases_in_use gesetzt + cp.data.get.phases_in_use = 3 + cp.data.set.current = 6 + cp.data.get.charge_state = False + assert cp._is_phase_switch_required() is False From 497dc294e6742fc38b6e3d57ce9701a9d820ad8e Mon Sep 17 00:00:00 2001 From: seaspotter Date: Thu, 24 Sep 2026 15:46:35 +0200 Subject: [PATCH 2/2] =?UTF-8?q?PV-Laden:=20Phasenumschaltung=20am=20Ladest?= =?UTF-8?q?art=20=C3=BCber=20den=20bestehenden=20Weg=20statt=20paralleler?= =?UTF-8?q?=20Pr=C3=BCfung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ersetzt Algorithm._check_phases_at_charging_start() (paralleler Weg, von Lena zurecht als nicht wartungsfreundlich kritisiert) 1. get_phases_by_selected_chargemode() erzwingt vor Ladestart nicht mehr phases=1, sondern behält die bisherige Phasenzahl bei. Das war der eigentliche Bug: stand die Hardware noch auf 3 Phasen (Rest vom vorherigen Ladevorgang), löste das erzwungene phases=1 eine unnötige 3->1-Umschaltung aus - noch bevor überhaupt eine Einschaltverzögerung beginnt, im Zustand NO_CHARGING_ALLOWED (_is_phase_switch_required() hat dafür einen expliziten Zweig). 2. get_pv_config_py_load() berechnet die Einschaltschwelle jetzt bewusst immer für eine Phase, unabhängig von control_parameter.phases. 3. switch_on_timer_expired() setzt die Phasenzahl jetzt explizit in beiden Fällen (ausreichend/nicht ausreichend Überschuss für die maximale Phasenzahl), nicht mehr nur im "genug Überschuss"-Fall. Die Entscheidung bleibt damit vollständig an der bestehenden Stelle in counter.py, inklusive der schon vorhandenen Gleichzeitigkeits-Behandlung über reserved_surplus. Tests aktualisiert: die jetzt korrekt niedrigere Einschaltschwelle (immer 1-phasig statt der vorherigen, von der Phasenzahl abhängigen Schwelle) lässt in den Integrationstests teils mehr Ladepunkte gleichzeitig die Verzögerung starten - das ist beabsichtigt und wurde einzeln nachvollzogen, nicht blind an die neuen Werte angepasst. --- packages/control/algorithm/algorithm.py | 29 ---- packages/control/algorithm/algorithm_test.py | 152 ------------------ .../integration_test/pv_charging_test.py | 14 +- packages/control/chargepoint/chargepoint.py | 19 ++- .../control/chargepoint/chargepoint_test.py | 69 +++++++- .../control/chargepoint/get_phases_test.py | 5 +- packages/control/counter.py | 24 +-- 7 files changed, 105 insertions(+), 207 deletions(-) delete mode 100644 packages/control/algorithm/algorithm_test.py diff --git a/packages/control/algorithm/algorithm.py b/packages/control/algorithm/algorithm.py index 6557e59bb0..fa4b01ed8c 100644 --- a/packages/control/algorithm/algorithm.py +++ b/packages/control/algorithm/algorithm.py @@ -8,7 +8,6 @@ from control.algorithm.min_current import MinCurrent from control.algorithm.no_current import NoCurrent from control.algorithm.surplus_controlled import SurplusControlled -from control.chargemode import Chargemode log = logging.getLogger(__name__) @@ -27,7 +26,6 @@ def calc_current(self) -> None: try: log.info("# Algorithmus") self.evu_counter = data.data.counter_all_data.get_evu_counter() - self._check_phases_at_charging_start() self._check_auto_phase_switch_delay() self.surplus_controlled.check_submode_pv_charging() common.reset_current() @@ -54,33 +52,6 @@ def calc_current(self) -> None: except Exception: log.exception("Fehler im Algorithmus-Modul") - def _check_phases_at_charging_start(self) -> None: - """ Ist vor Ladestart bereits genug Überschuss für mehrphasiges Laden vorhanden, direkt - mehrphasig starten. Sonst müsste erst einphasig gestartet und nach der Umschaltverzögerung - wieder hochgeschaltet werden, siehe Hilfetext zu "Pufferzeit zwischen automat. - Phasenumschaltungen". Bewusst hier im Algorithmus und nicht in der Ladepunkt-Klasse, da nur - hier die Reihenfolge und der noch verfügbare Überschuss über alle Ladepunkte hinweg bekannt sind: - remaining_surplus wird lokal fortgeschrieben, damit nicht zwei gleichzeitig neu startende - Ladepunkte unabhängig voneinander denselben Überschuss beanspruchen. - """ - remaining_surplus = self.evu_counter.get_usable_surplus() - for cp in data.data.cp_data.values(): - try: - control_parameter = cp.data.control_parameter - charging_ev = cp.data.set.charging_ev_data - if (control_parameter.submode == Chargemode.PV_CHARGING and - cp.data.get.charge_state is False and - cp.data.set.charge_template.data.chargemode.pv_charging.phases_to_use == 0 and - cp.hw_supports_phase_switch()): - max_phase_hw = cp.get_max_phase_hw() - if max_phase_hw > 1: - required_surplus = charging_ev.ev_template.data.min_current * max_phase_hw * 230 - if remaining_surplus > required_surplus: - control_parameter.phases = max_phase_hw - remaining_surplus -= required_surplus - except Exception: - log.exception(f"Fehler im Algorithmus-Modul für Ladepunkt{cp.num}") - def _check_auto_phase_switch_delay(self) -> None: """ geht alle LP durch und prüft, ob eine Ladung aktiv ist, ob automatische Phasenumschaltung möglich ist und ob ob ein Timer gestartet oder gestoppt werden muss oder ob diff --git a/packages/control/algorithm/algorithm_test.py b/packages/control/algorithm/algorithm_test.py deleted file mode 100644 index b9e96c7e22..0000000000 --- a/packages/control/algorithm/algorithm_test.py +++ /dev/null @@ -1,152 +0,0 @@ -from threading import Event -from unittest.mock import Mock -import pytest - -from control import data -from control.algorithm.algorithm import Algorithm -from control.chargemode import Chargemode -from control.chargepoint.chargepoint import Chargepoint -from control.chargepoint.chargepoint_state import ChargepointState -from control.chargepoint.chargepoint_template import CpTemplate, get_chargepoint_template_default -from control.ev.ev import Ev -from control.general import General - - -@pytest.fixture(autouse=True) -def general() -> None: - data.data_init(Event()) - data.data.general_data = General() - data.data.cp_data = {} - - -def _make_cp(num: int) -> Chargepoint: - cp = Chargepoint(num, None) - cp.template = CpTemplate() - cp.template.data = get_chargepoint_template_default() - cp.data.set.charging_ev_data = Ev(0) - cp.data.config.connected_phases = 3 - cp.data.config.auto_phase_switch_hw = True - cp.data.get.charge_state = False - cp.data.control_parameter.submode = Chargemode.PV_CHARGING - cp.data.control_parameter.phases = 3 - cp.data.set.phases_to_use = 3 - cp.data.get.phases_in_use = 3 - cp.data.set.log.imported_since_plugged = 0 - cp.data.set.charge_template.data.chargemode.pv_charging.phases_to_use = 0 - cp.data.set.charging_ev_data.ev_template.data.prevent_phase_switch = False - cp.data.set.charging_ev_data.ev_template.data.min_current = 6 - data.data.cp_data[f"cp{num}"] = cp - return cp - - -class SurplusAtStartParams: - def __init__(self, - name: str, - submode: Chargemode, - phases_to_use_config: int, - usable_surplus: float, - min_current: float, - expected_phases: int) -> None: - self.name = name - self.submode = submode - self.phases_to_use_config = phases_to_use_config - self.usable_surplus = usable_surplus - self.min_current = min_current - self.expected_phases = expected_phases - - -surplus_at_start_cases = [ - SurplusAtStartParams("pv charging, enough surplus: start with max phases", submode=Chargemode.PV_CHARGING, - phases_to_use_config=0, usable_surplus=5000, min_current=6, expected_phases=3), - SurplusAtStartParams("pv charging, not enough surplus: start with 1 phase", submode=Chargemode.PV_CHARGING, - phases_to_use_config=0, usable_surplus=100, min_current=6, expected_phases=1), - SurplusAtStartParams("pv charging, fixed phases_to_use: no surplus check, start with 1 phase", - submode=Chargemode.PV_CHARGING, - phases_to_use_config=3, usable_surplus=3000, min_current=6, expected_phases=1), - SurplusAtStartParams("instant charging: no surplus check, start with 1 phase", submode=Chargemode.INSTANT_CHARGING, - phases_to_use_config=0, usable_surplus=3000, min_current=6, expected_phases=1), -] - - -@pytest.mark.parametrize("params", surplus_at_start_cases, ids=[c.name for c in surplus_at_start_cases]) -def test_check_phases_at_charging_start_uses_surplus(monkeypatch: pytest.MonkeyPatch, params: SurplusAtStartParams): - # setup - cp = _make_cp(0) - cp.data.control_parameter.submode = params.submode - cp.data.set.charge_template.data.chargemode.pv_charging.phases_to_use = params.phases_to_use_config - cp.data.set.charging_ev_data.ev_template.data.min_current = params.min_current - # phases_to_use=1 spiegelt das bestehende Default-Verhalten (get_phases_by_selected_chargemode() - # startet unconditional einphasig), diese Methode entscheidet, ob das auf max_phase_hw angehoben wird. - cp.data.control_parameter.phases = 1 - mock_evu = Mock() - monkeypatch.setattr(mock_evu, "get_usable_surplus", Mock(return_value=params.usable_surplus)) - monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", Mock(return_value=mock_evu)) - algorithm = Algorithm() - algorithm.evu_counter = mock_evu - - # execution - algorithm._check_phases_at_charging_start() - - # evaluation - assert cp.data.control_parameter.phases == params.expected_phases - - -def test_check_phases_at_charging_start_does_not_double_count_surplus_across_chargepoints(monkeypatch): - """ Zwei Ladepunkte starten im selben Zyklus, der Überschuss reicht nur für einen davon - dreiphasig - der zweite darf nicht denselben, bereits vom ersten beanspruchten Überschuss - nochmal für sich behaupten. """ - # setup - cp1 = _make_cp(1) - cp2 = _make_cp(2) - # Überschuss reicht für genau einen Ladepunkt (min_current=6 * 3 Phasen * 230V = 4140W), - # aber nicht für zwei. - usable_surplus = 5000 - for cp in (cp1, cp2): - cp.data.control_parameter.phases = 1 - cp.data.set.charging_ev_data.ev_template.data.min_current = 6 - mock_evu = Mock() - monkeypatch.setattr(mock_evu, "get_usable_surplus", Mock(return_value=usable_surplus)) - monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", Mock(return_value=mock_evu)) - algorithm = Algorithm() - algorithm.evu_counter = mock_evu - - # execution - algorithm._check_phases_at_charging_start() - - # evaluation - phases = sorted([cp1.data.control_parameter.phases, cp2.data.control_parameter.phases]) - assert phases == [1, 3] - - -def test_check_phases_at_charging_start_reproduces_matts_log(monkeypatch): - """ Exakte Werte aus einem realen Log (Matt, 2026-08-08 12:35:43): CUPRA Born (min_current=6, - max_phases=3) wird an einem 3-phasigen Ladepunkt neu erkannt, Automatik-Umschaltung, noch nicht - ladend. "Verbleibende Leistung an Zähler 0: 32243.87W" zu diesem Zeitpunkt. Auf master wurde hier - unconditional phases=1 erzwungen, was trotz der Hardware auf 3 Phasen die unnötige 3->1-Umschaltung - ausgelöst hat ("LP 4: Umschaltung von 3 auf 1 Phase, dafür wird die Ladung unterbrochen."). """ - # setup - cp = _make_cp(4) - cp.data.control_parameter.phases = 1 # Default-Startwert, wie get_phases_by_selected_chargemode() ihn liefert - cp.data.set.charging_ev_data.ev_template.data.min_current = 6 - cp.data.set.charging_ev_data.ev_template.data.max_phases = 3 - mock_evu = Mock() - monkeypatch.setattr(mock_evu, "get_usable_surplus", Mock(return_value=32243.87)) - monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", Mock(return_value=mock_evu)) - algorithm = Algorithm() - algorithm.evu_counter = mock_evu - - # execution - algorithm._check_phases_at_charging_start() - - # evaluation - assert cp.data.control_parameter.phases == 3 - - # Downstream-Kette: mit control_parameter.phases jetzt korrekt auf 3 (statt 1, wie auf master), - # findet _is_phase_switch_required() keinen Mismatch zur Hardware mehr - genau der Aufruf, der in - # process.py nach dem Algorithmus die eigentliche Umschaltung 3->1 ausgelöst hätte. - cp.data.control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED - cp.data.set.phases_to_use = 3 # von _process_charge_stop() beim Abstecken auf phases_in_use gesetzt - cp.data.get.phases_in_use = 3 - cp.data.set.current = 6 - cp.data.get.charge_state = False - assert cp._is_phase_switch_required() is False diff --git a/packages/control/algorithm/integration_test/pv_charging_test.py b/packages/control/algorithm/integration_test/pv_charging_test.py index 8e53d7c591..cb0c811095 100644 --- a/packages/control/algorithm/integration_test/pv_charging_test.py +++ b/packages/control/algorithm/integration_test/pv_charging_test.py @@ -125,7 +125,10 @@ def assert_counter_set(params: ParamsExpectedCounterSet): def test_start_pv_delay(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatch): - # alle 3 im PV-laden, keine Ladung -> bei zwei die Verzögerung starten, für den 3. reicht es nicht + # alle 3 im PV-laden, keine Ladung, Überschuss reicht für alle 3 -> Verzögerung startet für alle 3. + # Die Einschaltschwelle wird seit PR #3899 bewusst immer für eine Phase berechnet (nicht mehr für die + # zuvor unconditional auf 1 gesetzte, hier aber unveränderte control_parameter.phases=3), daher reicht + # derselbe Überschuss jetzt für alle 3 statt nur für 2 von 3. # setup data.data.counter_data["counter0"].data.set.raw_power_left = 32090 data.data.counter_data["counter0"].data.set.raw_currents_left = [32, 30, 31] @@ -140,7 +143,7 @@ def test_start_pv_delay(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatch) assert data.data.cp_data[f"cp{i}"].data.set.current == 0 assert data.data.consumer_data["consumer7"].data.set.current == 0 assert data.data.cp_data[ - "cp3"].data.control_parameter.timestamp_switch_on_off is None + "cp3"].data.control_parameter.timestamp_switch_on_off == 1652683252.0 assert data.data.cp_data[ "cp4"].data.control_parameter.timestamp_switch_on_off == 1652683252.0 assert data.data.cp_data[ @@ -149,7 +152,7 @@ def test_start_pv_delay(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatch) "consumer7"].data.control_parameter.timestamp_switch_on_off == 1652683252.0 assert data.data.counter_data["counter0"].data.set.raw_power_left == 32090 assert data.data.counter_data["counter0"].data.set.surplus_power_left == -690 - assert data.data.counter_data["counter0"].data.set.reserved_surplus == 9115 + assert data.data.counter_data["counter0"].data.set.reserved_surplus == 4615.0 def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatch): @@ -182,6 +185,9 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc 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 + # switch_on_timer_expired() setzt die Phasenzahl jetzt explizit (PR #3899, statt sich auf einen + # zuvor von außen gesetzten Wert zu verlassen) - bei ausreichend Überschuss direkt auf max_phases. + assert data.data.cp_data["cp3"].data.control_parameter.phases == 3 assert data.data.cp_data[ "cp3"].data.control_parameter.timestamp_switch_on_off is None assert data.data.cp_data[ @@ -190,7 +196,7 @@ def test_pv_delay_expired(all_cp_pv_charging_3p, all_cp_not_charging, monkeypatc "cp5"].data.control_parameter.timestamp_switch_on_off is None assert data.data.counter_data["counter0"].data.set.raw_power_left == 24185 assert data.data.counter_data["counter0"].data.set.surplus_power_left == -690 - assert data.data.counter_data["counter0"].data.set.reserved_surplus == 0 + assert data.data.counter_data["counter0"].data.set.reserved_surplus == 6000.0 cases_limit = [ diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index db95b7666c..b23f483a8f 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -458,7 +458,6 @@ def initiate_phase_switch(self): log.exception("Fehler in der Ladepunkt-Klasse von "+str(self.num)) def get_phases_by_selected_chargemode(self, phases_chargemode: int) -> int: - charging_ev = self.data.set.charging_ev_data if self.data.get.evse_signaling == EvseSignaling.HLC: phases = self.data.get.phases_in_use elif self.data.config.auto_phase_switch_hw is False and self.data.get.charge_state: @@ -475,16 +474,16 @@ def get_phases_by_selected_chargemode(self, phases_chargemode: int) -> int: if self.data.get.charge_state: phases = self.data.get.phases_in_use else: - if ((not charging_ev.ev_template.data.prevent_phase_switch or - self.data.set.log.imported_since_plugged == 0) and - self.data.config.auto_phase_switch_hw): - phases = 1 + # Vor Ladestart keine Phasenzahl erzwingen, sondern die bisherige beibehalten, bis + # switch_on_timer_expired() (counter.py) nach Ablauf der Einschaltverzögerung die + # endgültige Phasenzahl anhand des tatsächlichen Überschusses festlegt (siehe PR #3899: + # ein unconditional phases=1 hier löste eine unnötige Umschaltung noch vor jeder + # Einschaltverzögerung aus, wenn die Hardware zufällig noch auf mehr Phasen stand). + if self.data.set.phases_to_use != 0: + phases = self.data.set.phases_to_use else: - if self.data.set.phases_to_use != 0: - phases = self.data.set.phases_to_use - else: - # phases_target - phases = self.data.config.connected_phases + # phases_target + phases = self.data.config.connected_phases log.debug(f"Phasenzahl Lademodus: {phases}") else: if phases_chargemode == 0: diff --git a/packages/control/chargepoint/chargepoint_test.py b/packages/control/chargepoint/chargepoint_test.py index 57a746649b..eb589aa2ba 100644 --- a/packages/control/chargepoint/chargepoint_test.py +++ b/packages/control/chargepoint/chargepoint_test.py @@ -5,9 +5,10 @@ from control import data from control.chargelog import chargelog +from control.chargemode import Chargemode from control.chargepoint.chargepoint import Chargepoint from control.chargepoint.chargepoint_state import ChargepointState -from control.chargepoint.chargepoint_template import CpTemplate +from control.chargepoint.chargepoint_template import CpTemplate, get_chargepoint_template_default from control.counter import Counter from control.ev.ev import Ev from modules.common.configurable_vehicle import ConfigurableVehicle @@ -131,6 +132,33 @@ class Params: charge_state=True, phase_switch_required=True ), + # Reproduziert den in PR #3899 beschriebenen Bug: get_phases_by_selected_chargemode() erzwang vor + # get_phases_test.py's Fix unconditional phases=1 beim Ladestart, auch wenn die Hardware (Rest vom + # vorherigen Ladevorgang) noch auf 3 Phasen steht. Das führt hier - noch bevor überhaupt eine + # Einschaltverzögerung beginnt (state=NO_CHARGING_ALLOWED, charge_state=False) - bereits zu einer + # unnötigen Umschaltung 3->1. + Params( + name="Ladestart, Hardware noch auf 3 Phasen: unnötige Umschaltung ohne den Fix", + state=ChargepointState.NO_CHARGING_ALLOWED, + phases_to_use=1, + phases_in_use=3, + control_parameter_phases=1, + set_current=6, + set_current_prev=0, + charge_state=False, + phase_switch_required=True + ), + Params( + name="Ladestart, Hardware noch auf 3 Phasen: keine Umschaltung mit dem Fix (Überschuss reicht)", + state=ChargepointState.NO_CHARGING_ALLOWED, + phases_to_use=3, + phases_in_use=3, + control_parameter_phases=3, + set_current=6, + set_current_prev=0, + charge_state=False, + phase_switch_required=False + ), ] @@ -158,6 +186,45 @@ def test_is_phase_switch_required(params: Params): assert ret == params.phase_switch_required +def test_get_phases_and_is_phase_switch_required_no_switch_on_stale_hardware_phases(mock_data): + """ Exakte Werte aus einem realen Log (2026-08-08 12:35:43, PR #3899): CUPRA Born + (min_current=6, max_phases=3) wird an einem 3-phasigen Ladepunkt neu erkannt, Automatik- + Umschaltung, noch nicht ladend, Hardware steht noch auf 3 Phasen (Rest vom vorherigen + Ladevorgang). Vor dem Fix erzwang get_phases_by_selected_chargemode() hier unconditional + phases=1, was noch vor jeder Einschaltverzögerung (state=NO_CHARGING_ALLOWED) eine unnötige + 3->1-Umschaltung auslöste ("LP 4: Umschaltung von 3 auf 1 Phase, dafür wird die Ladung + unterbrochen."). """ + # setup + cp = Chargepoint(4, None) + cp.template = CpTemplate() + cp.template.data = get_chargepoint_template_default() + cp.data.set.charging_ev_data = Ev(0) + cp.data.config.connected_phases = 3 + cp.data.config.auto_phase_switch_hw = True + cp.data.get.charge_state = False + cp.data.control_parameter.submode = Chargemode.PV_CHARGING + cp.data.control_parameter.state = ChargepointState.NO_CHARGING_ALLOWED + cp.data.set.phases_to_use = 3 # von _process_charge_stop() beim Abstecken auf phases_in_use gesetzt + cp.data.get.phases_in_use = 3 + cp.data.set.log.imported_since_plugged = 0 + cp.data.set.charge_template.data.chargemode.pv_charging.phases_to_use = 0 + cp.data.set.charging_ev_data.ev_template.data.prevent_phase_switch = False + cp.data.set.charging_ev_data.ev_template.data.min_current = 6 + cp.data.set.charging_ev_data.ev_template.data.max_phases = 3 + cp.data.set.current = 6 + + # execution + phases = cp.get_phases_by_selected_chargemode(0) + cp.data.control_parameter.phases = phases + + # evaluation: bisherige Phasenzahl (3) wird beibehalten statt auf 1 erzwungen + assert phases == 3 + # Downstream-Kette: da control_parameter.phases jetzt korrekt 3 ist (statt 1, wie vor dem Fix), + # findet _is_phase_switch_required() keinen Mismatch zur Hardware mehr - genau der Aufruf, der in + # process.py nach dem Algorithmus die eigentliche Umschaltung 3->1 ausgelöst hätte. + assert cp._is_phase_switch_required() is False + + @pytest.mark.parametrize( "soc_module, reset_after_unplug, expected_calls, expected_pub_call", [ diff --git a/packages/control/chargepoint/get_phases_test.py b/packages/control/chargepoint/get_phases_test.py index e4bf1283f4..1875e2ab93 100644 --- a/packages/control/chargepoint/get_phases_test.py +++ b/packages/control/chargepoint/get_phases_test.py @@ -76,9 +76,10 @@ def __init__(self, Params("auto phase before charge no hw switch 1", connected_phases=3, auto_phase_switch_hw=False, prevent_phase_switch=False, chargemode_phases=0, phases_in_use=1, imported_since_plugged=0, expected_phases=1, charge_state=False), - Params("auto phase use min phase at start", connected_phases=3, auto_phase_switch_hw=True, + Params("auto phase before charge start: keep current phase count, don't force 1 (PR #3899)", + connected_phases=3, auto_phase_switch_hw=True, prevent_phase_switch=False, chargemode_phases=0, phases_in_use=3, imported_since_plugged=0, - expected_phases=1, charge_state=False), + expected_phases=3, charge_state=False), ] diff --git a/packages/control/counter.py b/packages/control/counter.py index 4266315049..5010f4d591 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -403,10 +403,12 @@ def calc_switch_on_power(self, switch_on_threshold: float) -> Tuple[float, float def get_pv_config_py_load(self, load: Load) -> Tuple[float, float, float]: surplus_config = data.data.general_data.data.chargemode_config.surplus if isinstance(load, Chargepoint): - control_parameter = load.data.control_parameter switch_on_delay = surplus_config.vehicle.switch_on_delay - switch_on_threshold = surplus_config.vehicle.switch_on_threshold * control_parameter.phases - power_to_reserve = surplus_config.vehicle.switch_on_threshold*control_parameter.phases + # Einschaltschwelle wird bewusst immer für eine Phase berechnet, unabhängig von der + # aktuellen/vorherigen Phasenzahl - switch_on_timer_expired() legt die endgültige Phasenzahl + # erst anhand des dann tatsächlich verfügbaren Überschusses fest. + switch_on_threshold = surplus_config.vehicle.switch_on_threshold + power_to_reserve = surplus_config.vehicle.switch_on_threshold else: switch_on_delay = surplus_config.consumer.switch_on_delay switch_on_threshold = (load.data.control_parameter.required_current * @@ -484,15 +486,19 @@ def switch_on_timer_expired(self, load: Load) -> None: if isinstance(load, Chargepoint): charging_ev_data = load.data.set.charging_ev_data - # bei ausreichend Überschuss direkt mit max. Phasen laden ev_template = charging_ev_data.ev_template - max_phases_power = ev_template.data.min_current * ev_template.data.max_phases * 230 if (control_parameter.submode == Chargemode.PV_CHARGING and load.data.set.charge_template.data.chargemode.pv_charging.phases_to_use == 0 and - load.hw_supports_phase_switch() and - self.get_usable_surplus() > max_phases_power): - control_parameter.phases = ev_template.data.max_phases - msg += texts.max_phases.format(ev_template.data.max_phases) + load.hw_supports_phase_switch()): + # Phasenzahl hier in jedem Fall explizit setzen, nicht dem Wert überlassen, den + # get_phases_by_selected_chargemode() vor der Einschaltverzögerung gesetzt hatte - + # bei ausreichend Überschuss direkt mit max. Phasen laden, sonst explizit einphasig. + max_phases_power = ev_template.data.min_current * ev_template.data.max_phases * 230 + if self.get_usable_surplus() > max_phases_power: + control_parameter.phases = ev_template.data.max_phases + msg += texts.max_phases.format(ev_template.data.max_phases) + else: + control_parameter.phases = 1 elif isinstance(load, Consumer): control_parameter.state = ChargepointState.CHARGING_ALLOWED load.set_state_and_log(msg)