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
+