diff --git a/packages/control/chargepoint/chargepoint.py b/packages/control/chargepoint/chargepoint.py index 3d1f81e6ef..5b296da1a6 100644 --- a/packages/control/chargepoint/chargepoint.py +++ b/packages/control/chargepoint/chargepoint.py @@ -16,7 +16,7 @@ from control.ev.charge_template import ChargeTemplate from control.ev.ev import Ev from control import phase_switch -from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState +from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState, PHASE_SWITCH_STATES from control.limiting_value import loadmanagement_limit_factory from control.load_protocol import Load from control.text import BidiState @@ -188,7 +188,7 @@ def _process_charge_stop(self) -> None: self.data.set.ocpp_transaction_id = None # muss vor dem Zurücksetzen der control parameter aufgerufen werden self.data.set.charging_ev_data.reset_phase_switch(self.data.control_parameter) - self.data.set.charging_ev_data.reset_phase_switch_delay(self.data.control_parameter, self.get_max_phase_hw()) + self.data.set.charging_ev_data.reset_phase_switch_delay(self.data.control_parameter) self.reset_control_parameter_at_charge_stop() data.data.counter_all_data.get_evu_counter().reset_switch_on_off(self) if self.data.get.plug_state is False and self.data.set.plug_state_prev is True: @@ -232,10 +232,27 @@ def set_control_parameter(self, submode: Chargemode): self.data.control_parameter.chargemode = Chargemode.TIME_CHARGING else: self.data.control_parameter.chargemode = self.data.set.charge_template.data.chargemode.selected - if self.template.data.charging_type == ChargingType.AC.value: - self.data.control_parameter.min_current = self.data.set.charging_ev_data.ev_template.data.min_current - else: - self.data.control_parameter.min_current = self.data.set.charging_ev_data.ev_template.data.dc_min_current + # nicht während laufender Phasenumschaltung ändern, sonst verfälscht das reserved_surplus in + # auto_phase_switch() + if self.data.control_parameter.state not in PHASE_SWITCH_STATES: + if self.template.data.charging_type == ChargingType.AC.value: + self.data.control_parameter.min_current = ( + self.data.set.charging_ev_data.ev_template.data.min_current) + else: + self.data.control_parameter.min_current = ( + self.data.set.charging_ev_data.ev_template.data.dc_min_current) + # min_soc_current statt min_current als Phasenumschalt-Untergrenze; min_current bleibt der + # harte Lastmanagement-Boden (algorithm/min_current.py), sonst würde dort bei reduziertem + # Strom komplett abgeschaltet statt mit weniger geladen + pv_charging = self.data.set.charge_template.data.chargemode.pv_charging + soc = self.data.set.charging_ev_data.data.get.soc + if (self.data.control_parameter.chargemode == Chargemode.PV_CHARGING and + pv_charging.min_soc != 0 and soc is not None and soc < pv_charging.min_soc): + self.data.control_parameter.min_soc_current = ( + pv_charging.min_soc_current if self.template.data.charging_type == ChargingType.AC.value + else pv_charging.dc_min_soc_current) + else: + self.data.control_parameter.min_soc_current = self.data.control_parameter.min_current except Exception: log.exception("Fehler im LP-Modul "+str(self.num)) @@ -664,7 +681,7 @@ def update(self, ev_list: Dict[str, Ev]) -> None: if self.chargemode_changed or self.submode_changed: data.data.counter_all_data.get_evu_counter().reset_switch_on_off(self) - charging_ev.reset_phase_switch_delay(self.data.control_parameter, self.get_max_phase_hw()) + charging_ev.reset_phase_switch_delay(self.data.control_parameter) if self.chargemode_changed: self.data.control_parameter.failed_phase_switches = 0 message = message_ev if message_ev else message diff --git a/packages/control/chargepoint/chargepoint_test.py b/packages/control/chargepoint/chargepoint_test.py index c7a036e363..94b7225808 100644 --- a/packages/control/chargepoint/chargepoint_test.py +++ b/packages/control/chargepoint/chargepoint_test.py @@ -225,3 +225,90 @@ def test_process_charge_stop_reset_manual_soc(soc_module: Optional[ConfigurableV assert len(mock_pub.method_calls) - 1 == expected_calls if expected_calls > 0: assert mock_pub.method_calls[1].args == expected_pub_call + + +@pytest.mark.parametrize( + "chargemode_selected, min_soc, soc, charging_type, expected_min_current, expected_min_soc_current", + [ + pytest.param(Chargemode.PV_CHARGING, 50, 30, ChargingType.AC.value, 6, 16, + id="PV-Laden, unterhalb Mindest-SoC (AC): Mindest-SoC-Ladestrom als Phasenumschalt-Untergrenze"), + pytest.param(Chargemode.PV_CHARGING, 50, 30, ChargingType.DC.value, 100, 145, + id="PV-Laden, unterhalb Mindest-SoC (DC): Mindest-SoC-Ladestrom als Phasenumschalt-Untergrenze"), + pytest.param(Chargemode.PV_CHARGING, 50, 80, ChargingType.AC.value, 6, 6, + id="PV-Laden, Mindest-SoC bereits erreicht: allgemeiner Mindeststrom des Fahrzeugs"), + pytest.param(Chargemode.PV_CHARGING, 0, 30, ChargingType.AC.value, 6, 6, + id="PV-Laden, Mindest-SoC deaktiviert: allgemeiner Mindeststrom des Fahrzeugs"), + pytest.param(Chargemode.PV_CHARGING, 50, None, ChargingType.AC.value, 6, 6, + id="PV-Laden, kein SoC bekannt: allgemeiner Mindeststrom des Fahrzeugs"), + pytest.param(Chargemode.PV_CHARGING, 50, 80, ChargingType.DC.value, 100, 100, + id="PV-Laden, Mindest-SoC bereits erreicht (DC): allgemeiner Mindeststrom des Fahrzeugs"), + pytest.param(Chargemode.INSTANT_CHARGING, 50, 30, ChargingType.AC.value, 6, 6, + id="Sofortladen, Mindest-SoC-Werte vorhanden aber anderer Lademodus: unberührt"), + ]) +def test_set_control_parameter_min_soc_current_as_phase_switch_floor( + chargemode_selected, min_soc, soc, charging_type, expected_min_current, expected_min_soc_current, mock_data): + """ Mindest-SoC-Ladestrom als Phasenumschalt-Untergrenze landet in min_soc_current, nicht in min_current. """ + # setup + cp = Chargepoint(0, None) + cp.template = CpTemplate() + cp.template.data.charging_type = charging_type + ev = Ev(0) + ev.ev_template.data.min_current = 6 + ev.ev_template.data.dc_min_current = 100 + ev.data.get.soc = soc + cp.data.set.charging_ev_data = ev + cp.data.set.charge_template.data.chargemode.selected = chargemode_selected + cp.data.set.charge_template.data.chargemode.pv_charging.min_soc = min_soc + cp.data.set.charge_template.data.chargemode.pv_charging.min_soc_current = 16 + cp.data.set.charge_template.data.chargemode.pv_charging.dc_min_soc_current = 145 + + # execution + cp.set_control_parameter(Chargemode.INSTANT_CHARGING) + + # evaluation + assert cp.data.control_parameter.min_current == expected_min_current + assert cp.data.control_parameter.min_soc_current == expected_min_soc_current + + +@pytest.mark.parametrize( + "state", + [ + pytest.param(ChargepointState.PHASE_SWITCH_DELAY, id="Umschaltverzögerung läuft"), + pytest.param(ChargepointState.PERFORMING_PHASE_SWITCH, id="Umschaltung wird gerade durchgeführt"), + pytest.param(ChargepointState.WAIT_FOR_USING_PHASES, id="wartet auf Nutzung der neuen Phasenzahl"), + ]) +def test_set_control_parameter_min_soc_current_frozen_during_phase_switch(state, mock_data): + """ min_soc_current darf sich während einer laufenden Phasenumschaltung nicht ändern. """ + # setup + cp = Chargepoint(0, None) + cp.template = CpTemplate() + cp.template.data.charging_type = ChargingType.AC.value + ev = Ev(0) + ev.ev_template.data.min_current = 6 + ev.data.get.soc = 30 # noch unterhalb Mindest-SoC beim Start der Umschaltung + cp.data.set.charging_ev_data = ev + cp.data.set.charge_template.data.chargemode.selected = Chargemode.PV_CHARGING + cp.data.set.charge_template.data.chargemode.pv_charging.min_soc = 50 + cp.data.set.charge_template.data.chargemode.pv_charging.min_soc_current = 16 + + # Umschaltung auslösen, dabei greift die Mindest-SoC-Untergrenze + cp.set_control_parameter(Chargemode.INSTANT_CHARGING) + assert cp.data.control_parameter.min_current == 6 + assert cp.data.control_parameter.min_soc_current == 16 + + # Umschaltung läuft nun, Mindest-SoC wird währenddessen erreicht + cp.data.control_parameter.state = state + ev.data.get.soc = 60 + + # execution + cp.set_control_parameter(Chargemode.INSTANT_CHARGING) + + # evaluation: min_soc_current bleibt eingefroren, solange die Umschaltung läuft + assert cp.data.control_parameter.min_current == 6 + assert cp.data.control_parameter.min_soc_current == 16 + + # nach Abschluss der Umschaltung darf min_soc_current wieder aktualisiert werden + cp.data.control_parameter.state = ChargepointState.CHARGING_ALLOWED + cp.set_control_parameter(Chargemode.INSTANT_CHARGING) + assert cp.data.control_parameter.min_current == 6 + assert cp.data.control_parameter.min_soc_current == 6 diff --git a/packages/control/chargepoint/control_parameter.py b/packages/control/chargepoint/control_parameter.py index 17f5f11665..fab3b76d88 100644 --- a/packages/control/chargepoint/control_parameter.py +++ b/packages/control/chargepoint/control_parameter.py @@ -16,9 +16,14 @@ class ControlParameter: limit: Optional[LoadmanagementLimit] = field(default_factory=loadmanagement_limit_factory, metadata={ "topic": "control_parameter/limit"}) min_current: int = field(default=6, metadata={"topic": "control_parameter/min_current"}) + # wie min_current, aber inkl. Mindest-SoC-Boost - nur für auto_phase_switch() relevant + min_soc_current: int = field(default=6, metadata={"topic": "control_parameter/min_soc_current"}) phases: int = field(default=0, metadata={"topic": "control_parameter/phases"}) required_current: float = field(default=0, metadata={"topic": "control_parameter/required_current"}) required_currents: List[float] = field(default_factory=currents_list_factory) + # für auto_phase_switch(): die tatsächlich reservierte Leistung, damit das Zurücknehmen der Reservierung + # exakt zum Reservieren passt statt es (ggf. mit geänderten Werten) neu zu berechnen + reserved_power: float = field(default=0, metadata={"topic": "control_parameter/reserved_power"}) state: ChargepointState = field(default=ChargepointState.NO_CHARGING_ALLOWED, metadata={"topic": "control_parameter/state"}) submode: Chargemode_enum = field(default=Chargemode_enum.STOP, metadata={"topic": "control_parameter/submode"}) diff --git a/packages/control/ev/ev.py b/packages/control/ev/ev.py index 7e24057424..cff2e6edea 100644 --- a/packages/control/ev/ev.py +++ b/packages/control/ev/ev.py @@ -268,13 +268,13 @@ def _check_phase_switch_conditions(self, limit: LoadmanagementLimit, surplus: float) -> Tuple[bool, Optional[str]]: # Manche EV laden mit 6.1A bei 6A Soll-Strom - min_current = max(control_parameter.min_current, control_parameter.required_current) + min_current = max(control_parameter.min_soc_current, control_parameter.required_current) min_current_range = min_current + self.ev_template.data.nominal_difference 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 max_phases_ev = self.ev_template.data.max_phases - required_surplus = control_parameter.min_current * max_phases_ev * 230 - get_power + required_surplus = control_parameter.min_soc_current * max_phases_ev * 230 - get_power unbalanced_load_limit_reached = limit.limiting_value == LimitingValue.UNBALANCED_LOAD current_limit_reached = limit.limiting_value == LimitingValue.CURRENT condition_1_to_3 = ((((get_medium_charging_current(get_currents) > max_current_range or current_limit_reached) @@ -321,11 +321,11 @@ def auto_phase_switch(self, delay = surplus_config.vehicle.phase_switch_delay * 60 if phases_in_use == 1: direction_str = f"Umschaltung von 1 auf {max_phases}" - required_reserved_power = (control_parameter.min_current * max_phases * 230 - - self.ev_template.data.max_current_single_phase * 230) + required_reserved_power = max(0, control_parameter.min_soc_current * max_phases * 230 - + self.ev_template.data.max_current_single_phase * 230) new_phase = max_phases - new_current = control_parameter.min_current + new_current = control_parameter.min_soc_current waiting_time = surplus_config.vehicle.switch_on_delay else: direction_str = f"Umschaltung von {max_phases} auf 1" @@ -336,9 +336,9 @@ def auto_phase_switch(self, waiting_time = surplus_config.vehicle.switch_off_delay all_surplus = data.data.counter_all_data.get_evu_counter().get_usable_surplus() if control_parameter.state == ChargepointState.PHASE_SWITCH_DELAY: - # eigene reservierte Leistung addieren, wenn die Verzögerung für die Umschaltung läuft, - # da diese Leistung bereits für die Umschaltung reserviert ist. - surplus = all_surplus + max(0, required_reserved_power) + # mit der tatsächlich reservierten Leistung rechnen, nicht mit einer frisch berechneten - sonst + # können Reservieren und Zurücknehmen auseinanderlaufen, wenn sich zB max_phases währenddessen ändert + surplus = all_surplus + control_parameter.reserved_power else: surplus = all_surplus @@ -358,8 +358,9 @@ def auto_phase_switch(self, if condition: # Wenn nach der Umschaltung weniger Leistung benötigt wird, soll während der Verzögerung keine # neuen eingeschaltet werden. + control_parameter.reserved_power = required_reserved_power data.data.counter_all_data.get_evu_counter( - ).data.set.reserved_surplus += max(0, required_reserved_power) + ).data.set.reserved_surplus += control_parameter.reserved_power message = self.PHASE_SWITCH_DELAY_TEXT.format( direction_str, self._remaining_phase_switch_time(control_parameter, @@ -384,14 +385,16 @@ def auto_phase_switch(self, message = self.PHASE_SWITCH_DELAY_TEXT.format(direction_str, remaining_time) else: data.data.counter_all_data.get_evu_counter( - ).data.set.reserved_surplus -= max(0, required_reserved_power) + ).data.set.reserved_surplus -= control_parameter.reserved_power + control_parameter.reserved_power = 0 phases_to_use = new_phase current = new_current log.debug("Phasenumschaltung kann nun durchgeführt werden.") control_parameter.state = ChargepointState.PHASE_SWITCH_AWAITED else: data.data.counter_all_data.get_evu_counter( - ).data.set.reserved_surplus -= max(0, required_reserved_power) + ).data.set.reserved_surplus -= control_parameter.reserved_power + control_parameter.reserved_power = 0 message = f"Verzögerung für die {direction_str} Phasen abgebrochen{condition_msg}" control_parameter.state = ChargepointState.CHARGING_ALLOWED control_parameter.timestamp_phase_switch_buffer_start = None @@ -429,7 +432,7 @@ def _remaining_phase_switch_time(self, control_parameter: ControlParameter, control_parameter.timestamp_phase_switch_buffer_start = None return True, None - def reset_phase_switch_delay(self, control_parameter: ControlParameter, max_phases: int): + def reset_phase_switch_delay(self, control_parameter: ControlParameter): """ Zurücksetzen der Zeitstempel und reservierten Leistung. Die Phasenumschaltung kann nicht abgebrochen werden! @@ -441,8 +444,8 @@ def reset_phase_switch_delay(self, control_parameter: ControlParameter, max_phas # Wenn der Timer läuft, ist den Control-Parametern die alte Phasenzahl hinterlegt. # bei der Umschaltung 3p1p wird keine Leistung reserviert evu_counter = data.data.counter_all_data.get_evu_counter() - reserved = max(0, control_parameter.min_current * max_phases * 230 - - self.ev_template.data.max_current_single_phase * 230) + reserved = control_parameter.reserved_power + control_parameter.reserved_power = 0 evu_counter.data.set.reserved_surplus -= reserved log.debug(f"Zurücksetzen von {reserved}W reservierter Leistung für die Phasenumschaltung. " f"reservierte Leistung: {evu_counter.data.set.reserved_surplus} W") diff --git a/packages/control/ev/ev_test.py b/packages/control/ev/ev_test.py index e4e91b2bc7..7d68d33f92 100644 --- a/packages/control/ev/ev_test.py +++ b/packages/control/ev/ev_test.py @@ -5,8 +5,10 @@ from control import data +from control.chargepoint.chargepoint_state import ChargepointState from control.chargepoint.control_parameter import ControlParameter from control.ev.ev import Ev, get_ev_to_rfid +from control.limiting_value import LimitingValue, LoadmanagementLimit from helpermodules import timecheck from modules.common.abstract_vehicle import VehicleUpdateData from modules.vehicles.mqtt.config import MqttSocSetup @@ -77,6 +79,66 @@ def test_remaining_phase_switch_time(timestamp_last_phase_switch, assert result == expected_result +@pytest.mark.parametrize( + "surplus, expected_condition", + [ + pytest.param(6901, True, id="Überschuss reicht für min_soc_current auf allen Phasen: umschalten"), + pytest.param(5000, False, + id="Überschuss reicht für min_current, aber nicht für min_soc_current: nicht umschalten"), + ], +) +def test_check_phase_switch_conditions_uses_min_soc_current_for_required_surplus(surplus, expected_condition): + """ required_surplus muss auf min_soc_current basieren, nicht auf min_current. """ + # setup + ev = Ev(0) + ev.ev_template.data.max_phases = 3 + ev.ev_template.data.max_current_single_phase = 16 + ev.ev_template.data.nominal_difference = 1 + control_parameter = ControlParameter() + control_parameter.min_current = 6 + control_parameter.min_soc_current = 10 + control_parameter.phases = 1 + control_parameter.required_current = 6 + limit = LoadmanagementLimit(message=None, limiting_value=LimitingValue.CURRENT) + + # execution + condition, _ = ev._check_phase_switch_conditions( + control_parameter=control_parameter, + evse_current=0, + get_currents=[0, 0, 0], + get_power=0, + max_current_cp=32, + limit=limit, + surplus=surplus, + ) + + # evaluation: required_surplus = min_soc_current(10) * max_phases(3) * 230 = 6900 + assert condition == expected_condition + + +def test_reset_phase_switch_delay_uses_reserved_power_not_a_fresh_calculation(monkeypatch): + """ reset_phase_switch_delay() muss die beim Reservieren gespeicherte Leistung zurücknehmen, nicht neu + berechnen - sonst würde reserved_surplus driften, falls sich zB max_phases inzwischen geändert hat. """ + # setup + ev = Ev(0) + ev.ev_template.data.max_current_single_phase = 16 + control_parameter = ControlParameter() + control_parameter.state = ChargepointState.PHASE_SWITCH_DELAY + control_parameter.phases = 1 + control_parameter.reserved_power = 500 + evu_counter = Mock() + evu_counter.data.set.reserved_surplus = 1000 + monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", Mock(return_value=evu_counter)) + + # execution + ev.reset_phase_switch_delay(control_parameter) + + # evaluation + assert evu_counter.data.set.reserved_surplus == 500 + assert control_parameter.reserved_power == 0 + assert control_parameter.state == ChargepointState.CHARGING_ALLOWED + + @pytest.fixture(autouse=True) def data_module() -> None: data.data_init(Mock()) diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index d67736b248..700d443f81 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -549,6 +549,8 @@ def process_control_parameter_topics(self, msg): elif "/control_parameter/current_plan" in msg.topic: self._validate_value(msg, int) elif ("/control_parameter/min_current" in msg.topic or + "/control_parameter/min_soc_current" in msg.topic or + "/control_parameter/reserved_power" in msg.topic or "/control_parameter/timestamp_switch_on_off" in msg.topic or "/control_parameter/timestamp_charge_start" in msg.topic or "/control_parameter/timestamp_chargemode_changed" in msg.topic or diff --git a/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue b/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue index 2ba286e972..7f5958327e 100644 --- a/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue +++ b/packages/modules/display_themes/cards/source/src/views/ChargePointsView.vue @@ -1016,6 +1016,28 @@ export default { > Maximum + + Automatik + diff --git a/packages/modules/web_themes/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue b/packages/modules/web_themes/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue index d820678972..d10e27e7d0 100755 --- a/packages/modules/web_themes/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue +++ b/packages/modules/web_themes/colors/source/src/components/chargePointList/cpConfig/ConfigPv.vue @@ -76,10 +76,10 @@ diff --git a/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue b/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue index 9087f1e042..449b69a66b 100644 --- a/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue +++ b/packages/modules/web_themes/koala/source/src/components/ChargePointPvSettings.vue @@ -117,6 +117,7 @@ const phaseOptions = [ const phaseOptionsMinSoc = [ { value: 1, label: '1' }, { value: 3, label: 'Maximum' }, + { value: 0, label: 'Automatik' }, ]; const pvMinCurrent = computed(() => diff --git a/packages/modules/web_themes/standard_legacy/web/index.html b/packages/modules/web_themes/standard_legacy/web/index.html index 6208e79c60..61c7d694c8 100644 --- a/packages/modules/web_themes/standard_legacy/web/index.html +++ b/packages/modules/web_themes/standard_legacy/web/index.html @@ -955,6 +955,9 @@

Einstellungen für "PV"

+