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)