From 7c29d92a005d2519eb4934572a40eb29cb2a4296 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 2 Oct 2026 12:26:23 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Fix=20Minimal-Strom=20bei=20An/Aus-Ger?= =?UTF-8?q?=C3=A4ten=20in=20control=5Fparameter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/control/consumer/consumer.py | 38 ++++++++++++---------- packages/control/consumer/consumer_test.py | 32 ++++++++++++++++++ packages/control/consumer/usage.py | 1 + 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 4820255337..0822d32d99 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -3,7 +3,7 @@ from typing import Callable, Dict, List, Optional, Tuple from control import data -from control.consumer.usage import NOT_CONTROLLED +from control.consumer.usage import NOT_CONTROLLED, ON_OFF from control.algorithm.utils import get_medium_charging_current from control.chargemode import Chargemode from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState @@ -60,11 +60,11 @@ def update(self): self.data.get.charge_state = True if self.data.get.power > 0 else False self.reset_chargemode_at_time() self.is_switch_interval_elapsed() - min_current, required_current, message, mode, submode = self.get_parameter() + required_current, message, mode, submode = self.get_parameter() self.set_mode_changed(submode, mode) if self.chargemode_changed or self.submode_changed: data.data.counter_all_data.get_evu_counter().reset_switch_on_off(self) - self.set_control_parameter(min_current, required_current, + self.set_control_parameter(required_current, self.data.config.connected_phases, submode, mode) self.set_state_and_log(message) self.process_on_time() @@ -74,6 +74,15 @@ def update(self): except Exception: log.exception(f"Fehler bei Verbraucher {self.num}") + def _get_min_current(self): + if self.data.usage.type == ConsumerUsage.SUSPENDABLE_TUNABLE: + return self.data.config.min_current + else: + if self.data.get.charge_state: + return get_medium_charging_current(self.data.get.currents) + else: + return self._convert_power_to_current(self.data.config.max_power) + def process_on_time(self): if self.data.get.charge_state: now = timecheck.create_timestamp() @@ -105,7 +114,7 @@ def is_switch_interval_elapsed(self): SURPLUS_CONTINOUS_STILL_RUNNING = ("Verbraucher läuft ggf auch ohne ausreichend Überschuss weiter, da der " "Verbraucher nicht abgeschaltet werden darf.") - def get_parameter(self) -> Tuple[float, float, Optional[str], Optional[Chargemode], Chargemode]: + def get_parameter(self) -> Tuple[float, Optional[str], Optional[Chargemode], Chargemode]: if self.data.set.switch_interval_elapsed is False: log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") return (0, @@ -114,10 +123,6 @@ def get_parameter(self) -> Tuple[float, float, Optional[str], Optional[Chargemod self.data.control_parameter.chargemode, self.data.control_parameter.submode) - if self.data.usage.type == ConsumerUsage.SUSPENDABLE_TUNABLE: - min_current = self.data.config.min_current - else: - min_current = self._convert_power_to_current(self.data.config.max_power) required_current = 0 submode = Chargemode.STOP mode = self.data.usage.chargemode @@ -144,10 +149,10 @@ def get_parameter(self) -> Tuple[float, float, Optional[str], Optional[Chargemod message = f"{message or ''} {tmp_message or ''}".strip() if self.data.usage.chargemode == Chargemode.STOP: required_current, message, submode = self.stop() - return min_current, required_current, message, mode, submode + return required_current, message, mode, submode def _parse_required_current_by_usage(self, required_current: float) -> float: - if self.data.usage.type in [ConsumerUsage.CONTINUOUS, ConsumerUsage.SUSPENDABLE_ONOFF]: + if self.data.usage.type in ON_OFF: if self.data.get.charge_state: return get_medium_charging_current(self.data.get.currents) else: @@ -306,7 +311,7 @@ def convert_loading_hours_to_string(hour_list: List[datetime.datetime]) -> str: else: message = self.SCHEDULED_CHARGING_EXPENSIVE_HOUR.format(get_hours_message()) submode = Chargemode.PV_CHARGING - required_current = self._parse_required_current_by_usage(self.data.config.min_current) + required_current = self._parse_required_current_by_usage(self._get_min_current()) else: now = datetime.datetime.today() start_time = now + datetime.timedelta(seconds=remaining_time) @@ -317,7 +322,7 @@ def convert_loading_hours_to_string(hour_list: List[datetime.datetime]) -> str: message = self.SCHEDULED_CHARGING_USE_PV.format( f"am {start_time.strftime('%d.%m')} um {start_time.strftime('%-H:%M')} Uhr") submode = Chargemode.PV_CHARGING - required_current = self._parse_required_current_by_usage(self.data.config.min_current) + required_current = self._parse_required_current_by_usage(self._get_min_current()) return required_current, message, submode TIME_CHARGING_MIN_BAT_SOC_REACHED = ("Betrieb mit Zeitladen nach Speicher-SoC nicht möglich, da der SoC des" @@ -380,18 +385,18 @@ def eco_charging(self) -> Tuple[float, Optional[str], Chargemode]: message = self.CHARGING_PRICE_LOW submode = Chargemode.INSTANT_CHARGING else: - required_current = self._parse_required_current_by_usage(self.data.config.min_current) + required_current = self._parse_required_current_by_usage(self._get_min_current()) message = self.CHARGING_PRICE_EXCEEDED if self.data.control_parameter.state in CHARGING_STATES: message += "Lädt mit Überschuss. " submode = Chargemode.PV_CHARGING else: - required_current = self._parse_required_current_by_usage(self.data.config.min_current) + required_current = self._parse_required_current_by_usage(self._get_min_current()) submode = Chargemode.PV_CHARGING return required_current, message, submode def pv_charging(self) -> Tuple[float, Optional[str], Chargemode]: - required_current = self._parse_required_current_by_usage(self.data.config.min_current) + required_current = self._parse_required_current_by_usage(self._get_min_current()) message = None submode = Chargemode.PV_CHARGING return required_current, message, submode @@ -481,12 +486,11 @@ def reset_chargemode_at_time(self): self.data.usage.chargemode = self.data.usage.reset_chargemode.chargemode def set_control_parameter(self, - min_current: float, required_current: float, phases: int, submode: Chargemode, mode: Optional[Chargemode]): - self.data.control_parameter.min_current = min_current + self.data.control_parameter.min_current = self._get_min_current() self.data.control_parameter.required_current = required_current self.data.control_parameter.phases = phases self.data.control_parameter.submode = submode diff --git a/packages/control/consumer/consumer_test.py b/packages/control/consumer/consumer_test.py index a54e62344a..e55ddae2f0 100644 --- a/packages/control/consumer/consumer_test.py +++ b/packages/control/consumer/consumer_test.py @@ -260,6 +260,38 @@ def test_pv_charging(consumer: Consumer): assert ret == (10, None, Chargemode.PV_CHARGING) +@pytest.mark.parametrize( + "usage_type, charge_state, currents, expected_min_current", + [ + pytest.param(ConsumerUsage.CONTINUOUS, True, [13, 13, 13], 13, id="continuous-while-running"), + pytest.param(ConsumerUsage.CONTINUOUS, False, [13, 13, 13], 10, id="continuous-while-stopped"), + pytest.param(ConsumerUsage.SUSPENDABLE_TUNABLE, True, [13, 13, 13], 6, id="tunable-while-running"), + pytest.param(ConsumerUsage.SUSPENDABLE_TUNABLE, False, [13, 13, 13], 6, id="tunable-while-stopped"), + ], +) +def test_set_control_parameter_sets_min_current_by_usage_and_charge_state( + consumer: Consumer, + usage_type: ConsumerUsage, + charge_state: bool, + currents: List[float], + expected_min_current: float): + # setup + consumer.data.usage.type = usage_type + consumer.data.get.charge_state = charge_state + consumer.data.get.currents = currents + + # execution + consumer.set_control_parameter( + required_current=11, + phases=1, + submode=Chargemode.PV_CHARGING, + mode=Chargemode.PV_CHARGING, + ) + + # evaluation + assert consumer.data.control_parameter.min_current == expected_min_current + + @pytest.mark.parametrize( "on_time, plan_duration, diff_end_date, expected", [ diff --git a/packages/control/consumer/usage.py b/packages/control/consumer/usage.py index 3bb50e0166..9f77b6a5f6 100644 --- a/packages/control/consumer/usage.py +++ b/packages/control/consumer/usage.py @@ -10,3 +10,4 @@ class ConsumerUsage(Enum): NOT_CONTROLLED = (ConsumerUsage.METER_ONLY, ConsumerUsage.SELF_CONTROLLED) +ON_OFF = (ConsumerUsage.SUSPENDABLE_ONOFF, ConsumerUsage.CONTINUOUS) From bd4990180aa3d23eba738299ffc660e68395cd4f Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 2 Oct 2026 12:43:18 +0200 Subject: [PATCH 2/4] review --- packages/control/consumer/consumer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 0822d32d99..da5c081bb1 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -74,7 +74,7 @@ def update(self): except Exception: log.exception(f"Fehler bei Verbraucher {self.num}") - def _get_min_current(self): + def _get_min_current(self) -> float: if self.data.usage.type == ConsumerUsage.SUSPENDABLE_TUNABLE: return self.data.config.min_current else: @@ -118,7 +118,6 @@ def get_parameter(self) -> Tuple[float, Optional[str], Optional[Chargemode], Cha if self.data.set.switch_interval_elapsed is False: log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") return (0, - 0, None, self.data.control_parameter.chargemode, self.data.control_parameter.submode) From 79b3c77e89dbc1fe5a7bef9dda8f0529c2e292cc Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 2 Oct 2026 12:51:05 +0200 Subject: [PATCH 3/4] add standby threshold --- packages/control/consumer/consumer.py | 8 +++-- packages/control/consumer/consumer_test.py | 41 +++++++++++++++++++++- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index da5c081bb1..56bfedc54c 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -20,6 +20,7 @@ class Consumer(Load): PAUSE_BETWEEN_WAIT_FOR_START_TEST_RUNS = 3600 + STANDBY_THRESHOLD = 0.15 # 35 Watt pro Phase def __init__(self, index: int): self.num = index @@ -415,10 +416,11 @@ def wait_for_start_handler( self, func: Callable[[], Tuple[float, Optional[str], Chargemode]] ) -> Tuple[float, Optional[str], Chargemode, bool]: chargemode_defined_by_wait_for_start_handler = False + standby_threshold = self.STANDBY_THRESHOLD if self.data.usage.type in ON_OFF else self.data.config.min_current if self.data.usage.wait_for_start_active: if self.data.set.wait_for_start_state == WaitForStartStates.WAIT_FOR_DEVICE_START: # mit Minimalstrom prüfen, damit Standby-Geräte nicht als laufend erkannt werden - if max(self.data.get.currents) > self.data.config.min_current: + if max(self.data.get.currents) > standby_threshold: self.data.set.wait_for_start_state = WaitForStartStates.WAIT_FOR_STOPPED_DEVICE required_current = 0 message = self.WAIT_FOR_STOPPED_DEVICE @@ -432,7 +434,7 @@ def wait_for_start_handler( chargemode_defined_by_wait_for_start_handler = True elif self.data.set.wait_for_start_state == WaitForStartStates.WAIT_FOR_STOPPED_DEVICE: # mit Minimalstrom prüfen, damit Standby-Geräte nicht als laufend erkannt werden - if max(self.data.get.currents) < self.data.config.min_current: + if max(self.data.get.currents) < standby_threshold: self.data.set.wait_for_start_state = WaitForStartStates.DEVICE_WAITING_FOR_START required_current, message, submode = func() message = self.DEVICE_WAITING_FOR_START + " " + (message if message else "") @@ -444,7 +446,7 @@ def wait_for_start_handler( chargemode_defined_by_wait_for_start_handler = True elif self.data.set.wait_for_start_state == WaitForStartStates.DEVICE_WAITING_FOR_START: # mit Minimalstrom prüfen, damit Standby-Geräte nicht als laufend erkannt werden - if max(self.data.get.currents) > self.data.config.min_current: + if max(self.data.get.currents) > standby_threshold: self.data.set.wait_for_start_state = WaitForStartStates.START_SIGNAL_RECEIVED required_current, message, submode = func() else: diff --git a/packages/control/consumer/consumer_test.py b/packages/control/consumer/consumer_test.py index e55ddae2f0..11cfbf6d36 100644 --- a/packages/control/consumer/consumer_test.py +++ b/packages/control/consumer/consumer_test.py @@ -42,7 +42,7 @@ def test_get_parameter_clears_previous_state_string_when_switch_interval_is_acti consumer.data.set.state_str_prev = "previous state" # execution - _, _, state_string, _, _ = consumer.get_parameter() + _, state_string, _, _ = consumer.get_parameter() # evaluation assert state_string is None @@ -143,6 +143,45 @@ def test_wait_for_start_handler( assert charging_func.call_count == func_calls +@pytest.mark.parametrize( + "usage_type, expected_result, expected_state", + [ + pytest.param( + ConsumerUsage.SUSPENDABLE_ONOFF, + (0, Consumer.WAIT_FOR_STOPPED_DEVICE, Chargemode.STOP, True), + WaitForStartStates.WAIT_FOR_STOPPED_DEVICE, + id="on-off-uses-standby-threshold", + ), + pytest.param( + ConsumerUsage.SUSPENDABLE_TUNABLE, + (10, Consumer.WAIT_FOR_DEVICE_START, Chargemode.INSTANT_CHARGING, True), + WaitForStartStates.WAIT_FOR_DEVICE_START, + id="tunable-uses-min-current-threshold", + ), + ], +) +def test_wait_for_start_handler_standby_threshold_depends_on_usage_type( + consumer: Consumer, + usage_type: ConsumerUsage, + expected_result: Tuple[float, str, Chargemode, bool], + expected_state: WaitForStartStates): + # setup + consumer.data.usage.wait_for_start_active = True + consumer.data.usage.type = usage_type + consumer.data.set.wait_for_start_state = WaitForStartStates.WAIT_FOR_DEVICE_START + # 0.2 A liegt über STANDBY_THRESHOLD (0.15), aber unter min_current (6 A) + consumer.data.get.currents = [0.2, 0.2, 0.2] + charging_func = Mock(return_value=(11, "ok", Chargemode.PV_CHARGING)) + + # execution + result = consumer.wait_for_start_handler(charging_func) + + # evaluation + assert result == expected_result + assert consumer.data.set.wait_for_start_state == expected_state + assert charging_func.call_count == 0 + + @pytest.mark.parametrize( "plans, plan_found, expected", [ From 8c33a39d7b2f3a6b9e0692cf38d234b567e49981 Mon Sep 17 00:00:00 2001 From: LKuemmel Date: Fri, 2 Oct 2026 14:19:54 +0200 Subject: [PATCH 4/4] improve standby --- packages/control/consumer/consumer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 56bfedc54c..2a2cc2513f 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -58,7 +58,7 @@ def update(self): self.data.get.voltages[i]) self.data.get.phases_in_use = self.data.config.connected_phases self.data.set.phases_to_use = self.data.config.connected_phases - self.data.get.charge_state = True if self.data.get.power > 0 else False + self.data.get.charge_state = max(self.data.get.currents) > self.STANDBY_THRESHOLD self.reset_chargemode_at_time() self.is_switch_interval_elapsed() required_current, message, mode, submode = self.get_parameter()