From 23b57673f179e0161eb262c3ac98655e538cc44e Mon Sep 17 00:00:00 2001 From: seaspotter Date: Wed, 16 Sep 2026 16:56:52 +0200 Subject: [PATCH 1/8] PV/Speicher/Verbraucher: nach 60s Fehler nicht mehr mit altem Wert rechnen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analog zum bestehenden Mechanismus beim Zähler (MAX_EVU_ERROR_DURATION): wenn eine PV-, Speicher- oder Verbraucher-Komponente dauerhaft im Fehlerzustand ist, wird sie nach 60s nicht mehr mit ihrem letzten bekannten Leistungswert in die jeweilige Summe eingerechnet, sondern mit 0W. Der eigene get.power-Wert der Komponente bleibt unverändert (gleiches Verhalten wie beim Zähler) - nur die Summenbildung in PvAll/BatAll/AllConsumers sowie Counter._set_power_left berücksichtigt den Fehlerfall. --- packages/control/bat.py | 1 + packages/control/bat_all.py | 6 +- packages/control/bat_all_test.py | 20 ++++++ packages/control/consumer/consumer_all.py | 6 +- .../control/consumer/consumer_all_test.py | 23 +++++++ packages/control/consumer/consumer_data.py | 1 + packages/control/counter.py | 6 +- packages/control/counter_test.py | 23 +++++++ packages/control/error_state.py | 34 ++++++++++ packages/control/error_state_test.py | 47 ++++++++++++++ packages/control/pv.py | 12 +++- packages/control/pv_all.py | 6 +- packages/control/pv_all_test.py | 62 +++++++++++++++++++ 13 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 packages/control/consumer/consumer_all_test.py create mode 100644 packages/control/error_state.py create mode 100644 packages/control/error_state_test.py create mode 100644 packages/control/pv_all_test.py diff --git a/packages/control/bat.py b/packages/control/bat.py index f3ae1e7d9f..885a0341c8 100644 --- a/packages/control/bat.py +++ b/packages/control/bat.py @@ -42,6 +42,7 @@ def get_factory() -> Get: @dataclass class Set: power_limit: Optional[int] = field(default=None, metadata={"topic": "set/power_limit"}) + error_timer: Optional[float] = field(default=None, metadata={"topic": "set/error_timer"}) def set_factory() -> Set: diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index af3cab264e..365736d736 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -23,6 +23,7 @@ from typing import List, Optional, Tuple from control import data +from control.error_state import effective_power from helpermodules.constants import NO_ERROR from modules.common.abstract_device import AbstractDevice from modules.common.component_context import SingleComponentUpdateContext @@ -150,7 +151,10 @@ def calc_power_for_all_components(self): fault_state = 0 for battery in data.data.bat_data.values(): try: - power += battery.data.get.power + result = effective_power( + battery.data.get.power, battery.data.get.fault_state, battery.data.set.error_timer) + battery.data.set.error_timer = result.error_timer + power += result.power except Exception: log.exception(f"Fehler im Bat-Modul {battery.num}") imported += battery.data.get.imported diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index b877c058ac..3dd79132ea 100644 --- a/packages/control/bat_all_test.py +++ b/packages/control/bat_all_test.py @@ -12,6 +12,8 @@ from control.chargepoint.chargepoint_all import AllChargepointData, AllChargepoints, AllGet from control.general import ChargemodeConfigBat, General from control.pv import Config, Get, Pv, PvData +from helpermodules import timecheck +from modules.common.fault_state_level import FaultStateLevel from modules.devices.generic.mqtt.bat import MqttBat from modules.devices.generic.mqtt.config import MqttBatSetup @@ -62,6 +64,24 @@ def test_get_charging_power_left_diff_hybrid(bat_power: int, assert b_all.data.set.charging_power_left == expected_power +def test_calc_power_for_all_components_excludes_battery_after_60s(data_): + # setup + error_timer = timecheck.create_timestamp() - 61 + data.data.bat_data = {"bat1": Bat(1), "bat2": Bat(2)} + data.data.bat_data["bat1"].data.get.power = -500 + data.data.bat_data["bat2"].data.get.power = -2000 + data.data.bat_data["bat2"].data.get.fault_state = FaultStateLevel.ERROR + data.data.bat_data["bat2"].data.set.error_timer = error_timer + b_all = BatAll() + + # execution + b_all.calc_power_for_all_components() + + # evaluation + assert b_all.data.get.power == -500 + assert data.data.bat_data["bat2"].data.set.error_timer == error_timer + + @pytest.mark.parametrize( "hybrid_bat_ids, non_hybrid_bat_ids, hybrid_inverter_ids, expected_power", [ diff --git a/packages/control/consumer/consumer_all.py b/packages/control/consumer/consumer_all.py index c33d036a2b..04f65bdb0b 100644 --- a/packages/control/consumer/consumer_all.py +++ b/packages/control/consumer/consumer_all.py @@ -2,6 +2,7 @@ import logging from control import data +from control.error_state import effective_power from helpermodules.constants import NO_ERROR @@ -41,7 +42,10 @@ def get_consumer_sum(self): try: for consumer in data.data.consumer_data.values(): try: - power = power + consumer.data.get.power + result = effective_power( + consumer.data.get.power, consumer.data.get.fault_state, consumer.data.set.error_timer) + consumer.data.set.error_timer = result.error_timer + power = power + result.power except Exception: log.exception(f"Fehler in der allgemeinen Verbaucher-Klasse für Verbaucher {consumer}") imported = imported + consumer.data.get.imported diff --git a/packages/control/consumer/consumer_all_test.py b/packages/control/consumer/consumer_all_test.py new file mode 100644 index 0000000000..1e3e797e0a --- /dev/null +++ b/packages/control/consumer/consumer_all_test.py @@ -0,0 +1,23 @@ +from control import data +from control.consumer.consumer import Consumer +from control.consumer.consumer_all import AllConsumers +from helpermodules import timecheck +from modules.common.fault_state_level import FaultStateLevel + + +def test_get_consumer_sum_excludes_consumer_after_60s(data_): + # setup + error_timer = timecheck.create_timestamp() - 61 + data.data.consumer_data = {"consumer1": Consumer(1), "consumer2": Consumer(2)} + data.data.consumer_data["consumer1"].data.get.power = 500 + data.data.consumer_data["consumer2"].data.get.power = 2000 + data.data.consumer_data["consumer2"].data.get.fault_state = FaultStateLevel.ERROR + data.data.consumer_data["consumer2"].data.set.error_timer = error_timer + all_consumers = AllConsumers() + + # execution + all_consumers.get_consumer_sum() + + # evaluation + assert all_consumers.data.get.power == 500 + assert data.data.consumer_data["consumer2"].data.set.error_timer == error_timer diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index 42d5c75029..a319881e0e 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -89,6 +89,7 @@ class Get: @dataclass class Set: current: float = field(default=0, metadata={"topic": "set/current"}) + error_timer: Optional[float] = field(default=None, metadata={"topic": "set/error_timer"}) phases_to_use: int = field(default=1, metadata={"topic": "set/phases_to_use"}) plug_time: Optional[float] = field(default=None, metadata={"topic": "set/plug_time"}) required_power: float = field(default=0) diff --git a/packages/control/counter.py b/packages/control/counter.py index 0ed7e75910..9d9845681a 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -21,6 +21,7 @@ from control import data from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE +from control.error_state import effective_power log = logging.getLogger(__name__) @@ -276,7 +277,10 @@ def _set_power_left(self, loadmanagement_available: bool) -> None: # Wenn der Verbraucher nicht angesteuert werden darf, im LM als nicht veränderbaren Verbrauch # berücksichtigen. if consumer.data.set.switch_interval_elapsed: - power_raw -= consumer.data.get.power + # error_timer wird von ConsumerAll.get_consumer_sum() verwaltet, hier nur lesend verwendet. + power_raw -= effective_power( + consumer.data.get.power, consumer.data.get.fault_state, consumer.data.set.error_timer + ).power else: log.debug(f"Verbraucher {consumer.num} als unveränderlichen Verbrauch im LM " f"mit {consumer.data.get.power}W berücksichtigt.") diff --git a/packages/control/counter_test.py b/packages/control/counter_test.py index b8550a815a..6b564d0ec9 100644 --- a/packages/control/counter_test.py +++ b/packages/control/counter_test.py @@ -13,6 +13,7 @@ from control.ev.charge_template import ChargeTemplate from control.general import General from control.chargepoint.chargepoint_state import ChargepointState +from helpermodules import timecheck from modules.common.fault_state import FaultStateLevel @@ -135,6 +136,28 @@ def test_set_current_left_nets_out_own_current_despite_noise_on_unused_phase(mon assert counter.data.set.raw_currents_left == [45, 40.05, 37.95] +def test_set_power_left_excludes_consumer_power_after_60s(monkeypatch, data_): + # setup + get_evu_counter_str_mock = Mock(return_value="counter0") + monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter_str", get_evu_counter_str_mock) + counter = Counter(0) + counter.data.config.max_total_power = 24000 + counter.data.get.power = 1000 + data.data.cp_data = {} + data.data.consumer_data = {"consumer1": Consumer(1)} + data.data.consumer_data["consumer1"].data.get.power = 2000 + data.data.consumer_data["consumer1"].data.get.fault_state = FaultStateLevel.ERROR + data.data.consumer_data["consumer1"].data.set.error_timer = timecheck.create_timestamp() - 61 + data.data.consumer_data["consumer1"].data.set.switch_interval_elapsed = True + + # execution + counter._set_power_left(True) + + # evaluation + # power_raw = 1000 - 0 (kein Verbraucher wird nach 60s Fehler mehr abgezogen) -> raw_power_left = 24000 - 1000 + assert counter.data.set.raw_power_left == 23000 + + @dataclass class Params: name: str diff --git a/packages/control/error_state.py b/packages/control/error_state.py new file mode 100644 index 0000000000..5110898640 --- /dev/null +++ b/packages/control/error_state.py @@ -0,0 +1,34 @@ +""" Hilfsfunktion, um bei andauerndem Fehlerzustand einer Komponente nach Ablauf einer Wartezeit nicht mehr mit +deren letztem bekannten Wert weiterzurechnen. Analog zu Counter._get_loadmanagement_state, aber für Komponenten, +die im Fehlerfall nicht auf einen konfigurierten Ersatzwert zurückfallen, sondern schlicht mit 0W nicht mehr +berücksichtigt werden sollen. +""" +from dataclasses import dataclass +from typing import Optional + +from helpermodules import timecheck +from modules.common.fault_state_level import FaultStateLevel + +MAX_ERROR_DURATION = 60 # Sekunden, wie Counter.MAX_EVU_ERROR_DURATION + + +@dataclass +class EffectivePowerResult: + power: float + error_timer: Optional[float] + just_expired: bool # True genau in dem Zyklus, in dem die Wartezeit abgelaufen ist + + +def effective_power(power: float, fault_state: int, error_timer: Optional[float]) -> EffectivePowerResult: + """ ermittelt, mit welcher Leistung für eine Komponente weitergerechnet werden soll. + `power` selbst wird nicht verändert - nur der Rückgabewert. + """ + if fault_state == FaultStateLevel.ERROR: + if error_timer is None: + return EffectivePowerResult(power, timecheck.create_timestamp(), False) + elif timecheck.check_timestamp(error_timer, MAX_ERROR_DURATION) is False: + return EffectivePowerResult(0, error_timer, power != 0) + else: + return EffectivePowerResult(power, error_timer, False) + else: + return EffectivePowerResult(power, None, False) diff --git a/packages/control/error_state_test.py b/packages/control/error_state_test.py new file mode 100644 index 0000000000..ab5eafadbe --- /dev/null +++ b/packages/control/error_state_test.py @@ -0,0 +1,47 @@ +from control.error_state import MAX_ERROR_DURATION, effective_power +from helpermodules import timecheck +from modules.common.fault_state_level import FaultStateLevel + + +def test_effective_power_no_error_resets_timer(): + result = effective_power(1000, FaultStateLevel.NO_ERROR, 123.0) + + assert result.power == 1000 + assert result.error_timer is None + assert result.just_expired is False + + +def test_effective_power_first_error_starts_timer(): + result = effective_power(1000, FaultStateLevel.ERROR, None) + + assert result.power == 1000 + assert result.error_timer is not None + assert result.just_expired is False + + +def test_effective_power_within_grace_period_keeps_power(monkeypatch): + now = timecheck.create_timestamp() + result = effective_power(1000, FaultStateLevel.ERROR, now - (MAX_ERROR_DURATION - 1)) + + assert result.power == 1000 + assert result.error_timer == now - (MAX_ERROR_DURATION - 1) + assert result.just_expired is False + + +def test_effective_power_expired_returns_zero_and_flags_transition(): + error_timer = timecheck.create_timestamp() - (MAX_ERROR_DURATION + 1) + + result = effective_power(1000, FaultStateLevel.ERROR, error_timer) + + assert result.power == 0 + assert result.error_timer == error_timer + assert result.just_expired is True + + +def test_effective_power_expired_does_not_re_flag_transition(): + error_timer = timecheck.create_timestamp() - (MAX_ERROR_DURATION + 1) + + result = effective_power(0, FaultStateLevel.ERROR, error_timer) + + assert result.power == 0 + assert result.just_expired is False diff --git a/packages/control/pv.py b/packages/control/pv.py index 6dec696046..956ecf594b 100644 --- a/packages/control/pv.py +++ b/packages/control/pv.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field import logging -from typing import List +from typing import List, Optional from dataclass_utils.factories import currents_list_factory @@ -43,10 +43,20 @@ def get_factory() -> Get: return Get() +@dataclass +class Set: + error_timer: Optional[float] = field(default=None, metadata={"topic": "set/error_timer"}) + + +def set_factory() -> Set: + return Set() + + @dataclass class PvData: config: Config = field(default_factory=config_factory) get: Get = field(default_factory=get_factory) + set: Set = field(default_factory=set_factory) class Pv: diff --git a/packages/control/pv_all.py b/packages/control/pv_all.py index 63a0047b89..50bd980a0a 100644 --- a/packages/control/pv_all.py +++ b/packages/control/pv_all.py @@ -8,6 +8,7 @@ import logging from control import data +from control.error_state import effective_power from helpermodules.constants import NO_ERROR log = logging.getLogger(__name__) @@ -59,7 +60,10 @@ def calc_power_for_all_components(self) -> None: fault_state = 0 for module in data.data.pv_data.values(): try: - power += module.data.get.power + result = effective_power( + module.data.get.power, module.data.get.fault_state, module.data.set.error_timer) + module.data.set.error_timer = result.error_timer + power += result.power except Exception: log.exception(f"Fehler im allgemeinen PV-Modul für pv{module.num}") exported += module.data.get.exported diff --git a/packages/control/pv_all_test.py b/packages/control/pv_all_test.py new file mode 100644 index 0000000000..b8c12c379b --- /dev/null +++ b/packages/control/pv_all_test.py @@ -0,0 +1,62 @@ +from unittest.mock import Mock + +from control import data +from control.limiting_value import loadmanagement_limit_factory +from control.pv import Pv +from control.pv_all import PvAll +from helpermodules import timecheck +from modules.common.fault_state_level import FaultStateLevel + + +def _setup_io_actions(): + data.data.io_actions = Mock(stepwise_control=Mock(return_value=(None, loadmanagement_limit_factory()))) + + +def test_calc_power_for_all_components_counts_healthy_module(data_): + # setup + data.data.pv_data = {"pv1": Pv(1)} + data.data.pv_data["pv1"].data.get.power = -500 + _setup_io_actions() + pv_all = PvAll() + + # execution + pv_all.calc_power_for_all_components() + + # evaluation + assert pv_all.data.get.power == -500 + + +def test_calc_power_for_all_components_counts_module_within_grace_period(data_): + # setup + data.data.pv_data = {"pv1": Pv(1)} + data.data.pv_data["pv1"].data.get.power = -500 + data.data.pv_data["pv1"].data.get.fault_state = FaultStateLevel.ERROR + data.data.pv_data["pv1"].data.set.error_timer = timecheck.create_timestamp() - 30 + _setup_io_actions() + pv_all = PvAll() + + # execution + pv_all.calc_power_for_all_components() + + # evaluation + assert pv_all.data.get.power == -500 + + +def test_calc_power_for_all_components_excludes_module_after_60s(data_): + # setup + error_timer = timecheck.create_timestamp() - 61 + data.data.pv_data = {"pv1": Pv(1), "pv2": Pv(2)} + data.data.pv_data["pv1"].data.get.power = -500 + data.data.pv_data["pv2"].data.get.power = -2000 + data.data.pv_data["pv2"].data.get.fault_state = FaultStateLevel.ERROR + data.data.pv_data["pv2"].data.set.error_timer = error_timer + _setup_io_actions() + pv_all = PvAll() + + # execution + pv_all.calc_power_for_all_components() + + # evaluation + assert pv_all.data.get.power == -500 + # error_timer bleibt für die "wieviel Zeit ist bereits vergangen"-Logik erhalten, nicht zurückgesetzt + assert data.data.pv_data["pv2"].data.set.error_timer == error_timer From f6d1f4dcc7cdc7c240b57c07503b74d8921b0b37 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Wed, 23 Sep 2026 22:09:58 +0200 Subject: [PATCH 2/8] =?UTF-8?q?PV/Speicher/Verbraucher=20konsistent=20mit?= =?UTF-8?q?=20Z=C3=A4hler/Ladepunkt:=20nach=2060s=20Fehler=20nicht=20mehr?= =?UTF-8?q?=20mit=20altem=20Wert=20rechnen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vereinheitlicht den Fehlerfall-Mechanismus über Counter/Chargepoint/PV/Bat/Consumer: gemeinsame COMPONENT_ERROR_DURATION-Konstante und error_duration_exceeded()-Prüfung. PV/Bat setzen get.power nach Ablauf der Gnadenfrist direkt auf 0 (wie Counter/Chargepoint bereits), pro Komponente einzeln. Consumer.update() ist jetzt die einzige Stelle, die den Fehlerfall abbildet - behebt eine ~1-Zyklus-Verzögerung zwischen Counter._set_power_left() und ConsumerAll.get_consumer_sum(). Zusätzliche Gates verhindern, dass die aktive Speichersteuerung (BatAll._set_bat_power_active_control(), inkl. Ausschluss fehlerhafter Speicher aus der Kapazitäts-Summe) bzw. die Verbraucher-Ansteuerung (Consumer.get_parameter()) noch auf Basis veralteter Werte weiterarbeiten. --- packages/control/bat_all.py | 21 ++++- packages/control/bat_all_test.py | 80 +++++++++++++++++ packages/control/consumer/consumer.py | 11 +++ packages/control/consumer/consumer_all.py | 7 +- .../control/consumer/consumer_all_test.py | 12 +-- packages/control/consumer/consumer_test.py | 85 +++++++++++++++++++ packages/control/counter.py | 12 ++- packages/control/counter_test.py | 46 ++++++++-- packages/control/error_state.py | 9 +- packages/control/error_state_test.py | 29 +++++-- packages/control/pv_all.py | 3 +- packages/control/pv_all_test.py | 22 +++++ packages/helpermodules/constants.py | 4 + .../helpermodules/utils/error_handling.py | 4 +- 14 files changed, 306 insertions(+), 39 deletions(-) diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index 365736d736..20b2e50d43 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -23,10 +23,11 @@ from typing import List, Optional, Tuple from control import data -from control.error_state import effective_power +from control.error_state import effective_power, error_duration_exceeded from helpermodules.constants import NO_ERROR from modules.common.abstract_device import AbstractDevice from modules.common.component_context import SingleComponentUpdateContext +from modules.common.utils.component_parser import get_component_name_by_id log = logging.getLogger(__name__) @@ -154,7 +155,8 @@ def calc_power_for_all_components(self): result = effective_power( battery.data.get.power, battery.data.get.fault_state, battery.data.set.error_timer) battery.data.set.error_timer = result.error_timer - power += result.power + battery.data.get.power = result.power + power += battery.data.get.power except Exception: log.exception(f"Fehler im Bat-Modul {battery.num}") imported += battery.data.get.imported @@ -229,6 +231,9 @@ def _set_bat_power_active_control(self, power): if power is not None: for bat_component in controllable_bat_components: bat_component_data = data.data.bat_data[f"bat{bat_component.component_config.id}"].data + if error_duration_exceeded(bat_component_data.get.fault_state, bat_component_data.set.error_timer): + # SoC/Kapazität dieses Speichers sind nicht mehr aktuell, nicht in die Summen einrechnen. + continue if bat_component_data.get.soc < self.data.config.bat_control_max_soc: max_charge_power_total += bat_component_data.get.max_charge_power bat_ready_to_charge += 1 @@ -245,8 +250,16 @@ def _set_bat_power_active_control(self, power): # Leistung an einzelne Speicher übergeben for bat_component in controllable_bat_components: bat_component_data = data.data.bat_data[f"bat{bat_component.component_config.id}"].data - # Falls keine Leistung übergeben wird greift die Eigenregelung der Speicher - if power is None: + # Falls keine Leistung übergeben wird oder der Speicher seit COMPONENT_ERROR_DURATION Sekunden im + # Fehlerzustand ist, greift die Eigenregelung der Speicher. + if error_duration_exceeded(bat_component_data.get.fault_state, bat_component_data.set.error_timer): + power_limit = None + bat_component_data.get.state_str = ( + f"Fehler beim Auslesen des Speichers {get_component_name_by_id(bat_component.component_config.id)}" + ". Die aktive Speichersteuerung berücksichtigt diesen Speicher nicht mehr.") + data.data.bat_data[f"bat{bat_component.component_config.id}"].data.set.power_limit = power_limit + continue + elif power is None: power_limit = None bat_component_data.get.state_str = "Keine Steuerung" log.debug(("Speichersteuerung: Eigenregelung - Speicher " diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index 3dd79132ea..47839ec90e 100644 --- a/packages/control/bat_all_test.py +++ b/packages/control/bat_all_test.py @@ -80,6 +80,86 @@ def test_calc_power_for_all_components_excludes_battery_after_60s(data_): # evaluation assert b_all.data.get.power == -500 assert data.data.bat_data["bat2"].data.set.error_timer == error_timer + # eigener get.power-Wert wird jetzt ebenfalls abgebildet (wie bei Counter/Chargepoint), nicht nur die Summe + assert data.data.bat_data["bat2"].data.get.power == 0 + + +def test_calc_power_for_all_components_recovers_after_error_clears(data_): + # setup + # Speicher war im Fehlerzustand (error_timer noch gesetzt), liefert jetzt aber wieder gültige Werte + # (fault_state == 0) - der alte error_timer darf nicht mehr dazu führen, dass weiterhin 0 genutzt wird. + data.data.bat_data = {"bat1": Bat(1)} + data.data.bat_data["bat1"].data.get.power = -800 + data.data.bat_data["bat1"].data.get.fault_state = 0 + data.data.bat_data["bat1"].data.set.error_timer = timecheck.create_timestamp() - 61 + b_all = BatAll() + + # execution + b_all.calc_power_for_all_components() + + # evaluation + assert data.data.bat_data["bat1"].data.get.power == -800 + assert data.data.bat_data["bat1"].data.set.error_timer is None + assert b_all.data.get.power == -800 + + +def test_set_bat_power_active_control_ignores_battery_after_60s(data_, monkeypatch): + # setup + # Speicher seit über 60s im Fehlerzustand - die aktive Speichersteuerung darf sich nicht mehr auf dessen + # (möglicherweise veraltete) SoC/Kapazität stützen und muss ihn auf Eigenregelung stellen. + error_timer = timecheck.create_timestamp() - 61 + b_all = BatAll() + b_all.data.config.bat_control_max_soc = 95 + b_all.data.config.bat_control_min_soc = 5 + data.data.bat_data["bat2"].data.get.soc = 50 + data.data.bat_data["bat2"].data.get.max_charge_power = 3000 + data.data.bat_data["bat2"].data.get.max_discharge_power = 3000 + data.data.bat_data["bat2"].data.get.fault_state = FaultStateLevel.ERROR + data.data.bat_data["bat2"].data.set.error_timer = error_timer + get_bat_components_by_controllability_mock = Mock(return_value=([MqttBat(MqttBatSetup(id=2), device_id=0)], [])) + monkeypatch.setattr(bat_all, "get_bat_components_by_controllability", + get_bat_components_by_controllability_mock) + monkeypatch.setattr(bat_all, "get_component_name_by_id", Mock(return_value="Speicher 2")) + + # execution + b_all._set_bat_power_active_control(1000) + + # evaluation + assert data.data.bat_data["bat2"].data.set.power_limit is None + assert "Speicher 2" in data.data.bat_data["bat2"].data.get.state_str + assert data.data.bat_data["bat2"].data.set.error_timer == error_timer + + +def test_set_bat_power_active_control_excludes_faulted_battery_from_capacity_totals(data_, monkeypatch): + # setup + # bat2 seit über 60s im Fehlerzustand - dessen (evtl. veraltete) max_charge_power darf nicht in die + # Kapazitäts-Summe einfließen, mit der die Leistung auf die gesunden Speicher verteilt wird. + error_timer = timecheck.create_timestamp() - 61 + b_all = BatAll() + b_all.data.config.bat_control_max_soc = 95 + b_all.data.config.bat_control_min_soc = 5 + data.data.bat_data["bat1"] = Bat(1) + data.data.bat_data["bat1"].data.get.soc = 50 + data.data.bat_data["bat1"].data.get.max_charge_power = 2000 + data.data.bat_data["bat1"].data.get.max_discharge_power = 2000 + data.data.bat_data["bat2"].data.get.soc = 50 + data.data.bat_data["bat2"].data.get.max_charge_power = 3000 + data.data.bat_data["bat2"].data.get.max_discharge_power = 3000 + data.data.bat_data["bat2"].data.get.fault_state = FaultStateLevel.ERROR + data.data.bat_data["bat2"].data.set.error_timer = error_timer + get_bat_components_by_controllability_mock = Mock(return_value=( + [MqttBat(MqttBatSetup(id=1), device_id=0), MqttBat(MqttBatSetup(id=2), device_id=0)], [])) + monkeypatch.setattr(bat_all, "get_bat_components_by_controllability", + get_bat_components_by_controllability_mock) + monkeypatch.setattr(bat_all, "get_component_name_by_id", Mock(return_value="Speicher 2")) + + # execution - 1000W laden anfordern + b_all._set_bat_power_active_control(1000) + + # evaluation + # bat1 bekommt die volle angeforderte Leistung, da bat2s Kapazität nicht mit eingerechnet wird + assert data.data.bat_data["bat1"].data.set.power_limit == 1000 + assert data.data.bat_data["bat2"].data.set.power_limit is None @pytest.mark.parametrize( diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 2a2cc2513f..1dffd8e373 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -8,12 +8,14 @@ from control.chargemode import Chargemode from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState from control.consumer.consumer_data import ConsumerData, ConsumerUsage, ResetModes, WaitForStartStates +from control.error_state import effective_power, error_duration_exceeded from control.load_protocol import Load from control.text import format_next_time_charging_start from helpermodules import timecheck from helpermodules.abstract_plans import ScheduledPlanConsumer from helpermodules.phase_handling import convert_single_evu_phase_to_cp_phase, voltages_mean from modules.common.configurable_consumer import ConfigurableConsumer +from modules.common.utils.component_parser import get_component_name_by_id log = logging.getLogger(__name__) @@ -45,6 +47,11 @@ def setup_values_at_start(self): def update(self): try: self.setup_values_at_start() + # Einzige Stelle, die error_timer schreibt (ConsumerAll.get_consumer_sum() und + # Counter._set_power_left() lesen anschließend nur noch das bereits abgebildete get.power). + result = effective_power(self.data.get.power, self.data.get.fault_state, self.data.set.error_timer) + self.data.set.error_timer = result.error_timer + self.data.get.power = result.power if self.data.usage.type in NOT_CONTROLLED: return else: @@ -116,6 +123,10 @@ def is_switch_interval_elapsed(self): "Verbraucher nicht abgeschaltet werden darf.") def get_parameter(self) -> Tuple[float, Optional[str], Optional[Chargemode], Chargemode]: + if error_duration_exceeded(self.data.get.fault_state, self.data.set.error_timer): + message = (f"Fehler beim Auslesen des Verbrauchers {get_component_name_by_id(self.num)}. " + "Es wird nicht mehr angesteuert.") + return (0, message, self.data.control_parameter.chargemode, Chargemode.STOP) if self.data.set.switch_interval_elapsed is False: log.debug("Intervall für neuen Schaltbefehl nicht abgelaufen.") return (0, diff --git a/packages/control/consumer/consumer_all.py b/packages/control/consumer/consumer_all.py index 04f65bdb0b..8442949f86 100644 --- a/packages/control/consumer/consumer_all.py +++ b/packages/control/consumer/consumer_all.py @@ -2,7 +2,6 @@ import logging from control import data -from control.error_state import effective_power from helpermodules.constants import NO_ERROR @@ -42,10 +41,8 @@ def get_consumer_sum(self): try: for consumer in data.data.consumer_data.values(): try: - result = effective_power( - consumer.data.get.power, consumer.data.get.fault_state, consumer.data.set.error_timer) - consumer.data.set.error_timer = result.error_timer - power = power + result.power + # consumer.data.get.power ist bereits durch Consumer.update() auf den Fehlerfall abgebildet. + power = power + consumer.data.get.power except Exception: log.exception(f"Fehler in der allgemeinen Verbaucher-Klasse für Verbaucher {consumer}") imported = imported + consumer.data.get.imported diff --git a/packages/control/consumer/consumer_all_test.py b/packages/control/consumer/consumer_all_test.py index 1e3e797e0a..22487ecf4b 100644 --- a/packages/control/consumer/consumer_all_test.py +++ b/packages/control/consumer/consumer_all_test.py @@ -1,23 +1,19 @@ from control import data from control.consumer.consumer import Consumer from control.consumer.consumer_all import AllConsumers -from helpermodules import timecheck -from modules.common.fault_state_level import FaultStateLevel -def test_get_consumer_sum_excludes_consumer_after_60s(data_): +def test_get_consumer_sum_sums_power(data_): # setup - error_timer = timecheck.create_timestamp() - 61 + # Der Fehlerfall (nach COMPONENT_ERROR_DURATION s power=0) wird bereits von Consumer.update() abgebildet - + # get_consumer_sum() summiert nur noch, ohne eigene Fehlerfall-Logik. data.data.consumer_data = {"consumer1": Consumer(1), "consumer2": Consumer(2)} data.data.consumer_data["consumer1"].data.get.power = 500 data.data.consumer_data["consumer2"].data.get.power = 2000 - data.data.consumer_data["consumer2"].data.get.fault_state = FaultStateLevel.ERROR - data.data.consumer_data["consumer2"].data.set.error_timer = error_timer all_consumers = AllConsumers() # execution all_consumers.get_consumer_sum() # evaluation - assert all_consumers.data.get.power == 500 - assert data.data.consumer_data["consumer2"].data.set.error_timer == error_timer + assert all_consumers.data.get.power == 2500 diff --git a/packages/control/consumer/consumer_test.py b/packages/control/consumer/consumer_test.py index 11cfbf6d36..8f100af6df 100644 --- a/packages/control/consumer/consumer_test.py +++ b/packages/control/consumer/consumer_test.py @@ -12,9 +12,11 @@ from control.general import General from control.bat_all import BatAll from control.chargemode import Chargemode +from control.consumer import consumer as consumer_module from control.consumer.consumer import Consumer from control.consumer.consumer_data import WaitForStartStates from control.consumer.usage import ConsumerUsage +from modules.common.fault_state_level import FaultStateLevel @pytest.fixture(autouse=True) @@ -48,6 +50,89 @@ def test_get_parameter_clears_previous_state_string_when_switch_interval_is_acti assert state_string is None +def test_get_parameter_stops_consumer_after_60s_error(consumer: Consumer, monkeypatch): + # setup + error_timer = timecheck.create_timestamp() - 61 + consumer.data.get.fault_state = FaultStateLevel.ERROR + consumer.data.set.error_timer = error_timer + consumer.data.set.switch_interval_elapsed = True + monkeypatch.setattr(consumer_module, "get_component_name_by_id", Mock(return_value="Verbraucher 1")) + + # execution + min_current, required_current, message, mode, submode = consumer.get_parameter() + + # evaluation + assert min_current == 0 + assert required_current == 0 + assert submode == Chargemode.STOP + assert "Verbraucher 1" in message + + +def test_get_parameter_continues_normally_within_grace_period(consumer: Consumer): + # setup + # Fehler erst seit 30s - noch innerhalb der Gnadenfrist, get_parameter() darf noch nicht auf STOP gehen. + # switch_interval_elapsed=True, damit nur das neue Fehlerfall-Gate über den Rückgabewert entscheidet. + error_timer = timecheck.create_timestamp() - 30 + consumer.data.get.fault_state = FaultStateLevel.ERROR + consumer.data.set.error_timer = error_timer + consumer.data.set.switch_interval_elapsed = True + + # execution + _, _, _, _, submode = consumer.get_parameter() + + # evaluation + assert submode != Chargemode.STOP + + +def test_update_zeroes_power_after_60s_error(consumer: Consumer): + # setup + error_timer = timecheck.create_timestamp() - 61 + consumer.data.usage.type = ConsumerUsage.METER_ONLY # NOT_CONTROLLED, kurzer Rückweg nach der Zuordnung + consumer.data.get.power = 2000 + consumer.data.get.fault_state = FaultStateLevel.ERROR + consumer.data.set.error_timer = error_timer + + # execution + consumer.update() + + # evaluation + assert consumer.data.get.power == 0 + # error_timer bleibt für die "wieviel Zeit ist bereits vergangen"-Logik erhalten, nicht zurückgesetzt + assert consumer.data.set.error_timer == error_timer + + +def test_update_keeps_power_within_grace_period(consumer: Consumer): + # setup + error_timer = timecheck.create_timestamp() - 30 + consumer.data.usage.type = ConsumerUsage.METER_ONLY + consumer.data.get.power = 2000 + consumer.data.get.fault_state = FaultStateLevel.ERROR + consumer.data.set.error_timer = error_timer + + # execution + consumer.update() + + # evaluation + assert consumer.data.get.power == 2000 + + +def test_update_recovers_after_error_clears(consumer: Consumer): + # setup + # Verbraucher war im Fehlerzustand (error_timer noch gesetzt), liefert jetzt aber wieder gültige Werte + # (fault_state == 0) - der alte error_timer darf nicht mehr dazu führen, dass weiterhin 0 genutzt wird. + consumer.data.usage.type = ConsumerUsage.METER_ONLY + consumer.data.get.power = 1800 + consumer.data.get.fault_state = 0 + consumer.data.set.error_timer = timecheck.create_timestamp() - 61 + + # execution + consumer.update() + + # evaluation + assert consumer.data.get.power == 1800 + assert consumer.data.set.error_timer is None + + @pytest.mark.parametrize( ("wait_for_start_active", "state", "currents", diff --git a/packages/control/counter.py b/packages/control/counter.py index 9d9845681a..35320bec73 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -3,7 +3,7 @@ from modules.common.utils.component_parser import get_component_name_by_id from modules.common.fault_state import FaultStateLevel from helpermodules.phase_handling import convert_cp_currents_to_evu_currents -from helpermodules.constants import NO_ERROR +from helpermodules.constants import COMPONENT_ERROR_DURATION, NO_ERROR from helpermodules import timecheck from dataclass_utils.factories import currents_list_factory, voltages_list_factory from control.load_protocol import Load @@ -21,7 +21,6 @@ from control import data from control.algorithm.chargemodes import CONSIDERED_CHARGE_MODES_BIDI_DISCHARGE -from control.error_state import effective_power log = logging.getLogger(__name__) @@ -133,7 +132,7 @@ class SwitchOnTexts: class Counter: - MAX_EVU_ERROR_DURATION = 60 + MAX_EVU_ERROR_DURATION = COMPONENT_ERROR_DURATION def __init__(self, index): try: @@ -277,10 +276,9 @@ def _set_power_left(self, loadmanagement_available: bool) -> None: # Wenn der Verbraucher nicht angesteuert werden darf, im LM als nicht veränderbaren Verbrauch # berücksichtigen. if consumer.data.set.switch_interval_elapsed: - # error_timer wird von ConsumerAll.get_consumer_sum() verwaltet, hier nur lesend verwendet. - power_raw -= effective_power( - consumer.data.get.power, consumer.data.get.fault_state, consumer.data.set.error_timer - ).power + # consumer.data.get.power ist bereits durch Consumer.update() auf den Fehlerfall + # abgebildet (0 nach COMPONENT_ERROR_DURATION s andauerndem Fehler). + power_raw -= consumer.data.get.power else: log.debug(f"Verbraucher {consumer.num} als unveränderlichen Verbrauch im LM " f"mit {consumer.data.get.power}W berücksichtigt.") diff --git a/packages/control/counter_test.py b/packages/control/counter_test.py index 6b564d0ec9..79619fa672 100644 --- a/packages/control/counter_test.py +++ b/packages/control/counter_test.py @@ -8,12 +8,13 @@ from control import data from control.chargepoint.chargepoint import Chargepoint from control.consumer.consumer import Consumer +from control.consumer.consumer_all import AllConsumers +from control.consumer.usage import ConsumerUsage from control.counter import Counter, CounterData, Get from control.ev.ev import Ev from control.ev.charge_template import ChargeTemplate from control.general import General from control.chargepoint.chargepoint_state import ChargepointState -from helpermodules import timecheck from modules.common.fault_state import FaultStateLevel @@ -136,8 +137,12 @@ def test_set_current_left_nets_out_own_current_despite_noise_on_unused_phase(mon assert counter.data.set.raw_currents_left == [45, 40.05, 37.95] -def test_set_power_left_excludes_consumer_power_after_60s(monkeypatch, data_): +def test_set_power_left_reads_consumer_power_directly(monkeypatch, data_): # setup + # Der Fehlerfall (nach COMPONENT_ERROR_DURATION s power=0) wird bereits von Consumer.update() abgebildet - + # _set_power_left() liest hier nur noch das bereits abgebildete consumer.data.get.power, ohne eigene + # Fehlerfall-Logik. Simuliert einen Verbraucher, der seit über 60s im Fehlerzustand ist: Consumer.update() + # hätte get.power bereits auf 0 gesetzt. get_evu_counter_str_mock = Mock(return_value="counter0") monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter_str", get_evu_counter_str_mock) counter = Counter(0) @@ -145,19 +150,48 @@ def test_set_power_left_excludes_consumer_power_after_60s(monkeypatch, data_): counter.data.get.power = 1000 data.data.cp_data = {} data.data.consumer_data = {"consumer1": Consumer(1)} - data.data.consumer_data["consumer1"].data.get.power = 2000 - data.data.consumer_data["consumer1"].data.get.fault_state = FaultStateLevel.ERROR - data.data.consumer_data["consumer1"].data.set.error_timer = timecheck.create_timestamp() - 61 + data.data.consumer_data["consumer1"].data.get.power = 0 data.data.consumer_data["consumer1"].data.set.switch_interval_elapsed = True # execution counter._set_power_left(True) # evaluation - # power_raw = 1000 - 0 (kein Verbraucher wird nach 60s Fehler mehr abgezogen) -> raw_power_left = 24000 - 1000 + # power_raw = 1000 - 0 -> raw_power_left = 24000 - 1000 assert counter.data.set.raw_power_left == 23000 +def test_set_power_left_and_consumer_sum_agree_within_same_cycle(monkeypatch, data_): + """ Regressionstest für die vormalige ~1-Zyklus-Verzögerung: Counter._set_power_left() las + consumer.data.set.error_timer, bevor ConsumerAll.get_consumer_sum() (der einzige Schreiber) ihn in + diesem Zyklus aktualisiert hatte. Jetzt schreibt einzig Consumer.update() (läuft laut prepare.py vor + beiden) - _set_power_left() und get_consumer_sum() sehen im selben Zyklus denselben Wert. """ + # setup + get_evu_counter_str_mock = Mock(return_value="counter0") + monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter_str", get_evu_counter_str_mock) + counter = Counter(0) + counter.data.config.max_total_power = 24000 + counter.data.get.power = 1000 + data.data.cp_data = {} + data.data.consumer_data = {"consumer1": Consumer(1)} + consumer = data.data.consumer_data["consumer1"] + consumer.data.usage.type = ConsumerUsage.METER_ONLY # NOT_CONTROLLED, kurzer Rückweg in update() + consumer.data.get.power = 2000 + consumer.data.get.fault_state = FaultStateLevel.ERROR + consumer.data.set.error_timer = timecheck.create_timestamp() - 61 + consumer.data.set.switch_interval_elapsed = True + + # execution - Reihenfolge wie in prepare.py: Consumer.update() vor Counter.setup_counter()/get_consumer_sum() + consumer.update() + counter._set_power_left(True) + all_consumers = AllConsumers() + all_consumers.get_consumer_sum() + + # evaluation - beide sehen im selben Zyklus den bereits auf 0 abgebildeten Wert + assert counter.data.set.raw_power_left == 24000 - 1000 + assert all_consumers.data.get.power == 0 + + @dataclass class Params: name: str diff --git a/packages/control/error_state.py b/packages/control/error_state.py index 5110898640..7520be9d5e 100644 --- a/packages/control/error_state.py +++ b/packages/control/error_state.py @@ -7,9 +7,14 @@ from typing import Optional from helpermodules import timecheck +from helpermodules.constants import COMPONENT_ERROR_DURATION from modules.common.fault_state_level import FaultStateLevel -MAX_ERROR_DURATION = 60 # Sekunden, wie Counter.MAX_EVU_ERROR_DURATION + +def error_duration_exceeded(fault_state: int, error_timer: Optional[float]) -> bool: + """ analog zu ErrorTimerContext.error_counter_exceeded() (helpermodules/utils/error_handling.py) """ + return (fault_state == FaultStateLevel.ERROR and error_timer is not None and + timecheck.check_timestamp(error_timer, COMPONENT_ERROR_DURATION) is False) @dataclass @@ -26,7 +31,7 @@ def effective_power(power: float, fault_state: int, error_timer: Optional[float] if fault_state == FaultStateLevel.ERROR: if error_timer is None: return EffectivePowerResult(power, timecheck.create_timestamp(), False) - elif timecheck.check_timestamp(error_timer, MAX_ERROR_DURATION) is False: + elif error_duration_exceeded(fault_state, error_timer): return EffectivePowerResult(0, error_timer, power != 0) else: return EffectivePowerResult(power, error_timer, False) diff --git a/packages/control/error_state_test.py b/packages/control/error_state_test.py index ab5eafadbe..62772b5285 100644 --- a/packages/control/error_state_test.py +++ b/packages/control/error_state_test.py @@ -1,5 +1,6 @@ -from control.error_state import MAX_ERROR_DURATION, effective_power +from control.error_state import effective_power, error_duration_exceeded from helpermodules import timecheck +from helpermodules.constants import COMPONENT_ERROR_DURATION from modules.common.fault_state_level import FaultStateLevel @@ -21,15 +22,15 @@ def test_effective_power_first_error_starts_timer(): def test_effective_power_within_grace_period_keeps_power(monkeypatch): now = timecheck.create_timestamp() - result = effective_power(1000, FaultStateLevel.ERROR, now - (MAX_ERROR_DURATION - 1)) + result = effective_power(1000, FaultStateLevel.ERROR, now - (COMPONENT_ERROR_DURATION - 1)) assert result.power == 1000 - assert result.error_timer == now - (MAX_ERROR_DURATION - 1) + assert result.error_timer == now - (COMPONENT_ERROR_DURATION - 1) assert result.just_expired is False def test_effective_power_expired_returns_zero_and_flags_transition(): - error_timer = timecheck.create_timestamp() - (MAX_ERROR_DURATION + 1) + error_timer = timecheck.create_timestamp() - (COMPONENT_ERROR_DURATION + 1) result = effective_power(1000, FaultStateLevel.ERROR, error_timer) @@ -39,9 +40,27 @@ def test_effective_power_expired_returns_zero_and_flags_transition(): def test_effective_power_expired_does_not_re_flag_transition(): - error_timer = timecheck.create_timestamp() - (MAX_ERROR_DURATION + 1) + error_timer = timecheck.create_timestamp() - (COMPONENT_ERROR_DURATION + 1) result = effective_power(0, FaultStateLevel.ERROR, error_timer) assert result.power == 0 assert result.just_expired is False + + +def test_error_duration_exceeded_no_error(): + assert error_duration_exceeded(FaultStateLevel.NO_ERROR, None) is False + + +def test_error_duration_exceeded_error_no_timer_yet(): + assert error_duration_exceeded(FaultStateLevel.ERROR, None) is False + + +def test_error_duration_exceeded_within_grace_period(): + error_timer = timecheck.create_timestamp() - (COMPONENT_ERROR_DURATION - 1) + assert error_duration_exceeded(FaultStateLevel.ERROR, error_timer) is False + + +def test_error_duration_exceeded_after_grace_period(): + error_timer = timecheck.create_timestamp() - (COMPONENT_ERROR_DURATION + 1) + assert error_duration_exceeded(FaultStateLevel.ERROR, error_timer) is True diff --git a/packages/control/pv_all.py b/packages/control/pv_all.py index 50bd980a0a..06dc12a100 100644 --- a/packages/control/pv_all.py +++ b/packages/control/pv_all.py @@ -63,7 +63,8 @@ def calc_power_for_all_components(self) -> None: result = effective_power( module.data.get.power, module.data.get.fault_state, module.data.set.error_timer) module.data.set.error_timer = result.error_timer - power += result.power + module.data.get.power = result.power + power += module.data.get.power except Exception: log.exception(f"Fehler im allgemeinen PV-Modul für pv{module.num}") exported += module.data.get.exported diff --git a/packages/control/pv_all_test.py b/packages/control/pv_all_test.py index b8c12c379b..aa1d024f1d 100644 --- a/packages/control/pv_all_test.py +++ b/packages/control/pv_all_test.py @@ -60,3 +60,25 @@ def test_calc_power_for_all_components_excludes_module_after_60s(data_): assert pv_all.data.get.power == -500 # error_timer bleibt für die "wieviel Zeit ist bereits vergangen"-Logik erhalten, nicht zurückgesetzt assert data.data.pv_data["pv2"].data.set.error_timer == error_timer + # eigener get.power-Wert wird jetzt ebenfalls abgebildet (wie bei Counter/Chargepoint), nicht nur die Summe + assert data.data.pv_data["pv2"].data.get.power == 0 + + +def test_calc_power_for_all_components_recovers_after_error_clears(data_): + # setup + # Modul war im Fehlerzustand (error_timer noch gesetzt), liefert jetzt aber wieder gültige Werte + # (fault_state == 0) - der alte error_timer darf nicht mehr dazu führen, dass weiterhin 0 genutzt wird. + data.data.pv_data = {"pv1": Pv(1)} + data.data.pv_data["pv1"].data.get.power = -1500 + data.data.pv_data["pv1"].data.get.fault_state = 0 + data.data.pv_data["pv1"].data.set.error_timer = timecheck.create_timestamp() - 61 + _setup_io_actions() + pv_all = PvAll() + + # execution + pv_all.calc_power_for_all_components() + + # evaluation + assert data.data.pv_data["pv1"].data.get.power == -1500 + assert data.data.pv_data["pv1"].data.set.error_timer is None + assert pv_all.data.get.power == -1500 diff --git a/packages/helpermodules/constants.py b/packages/helpermodules/constants.py index c364a8a948..cc24d0dc46 100644 --- a/packages/helpermodules/constants.py +++ b/packages/helpermodules/constants.py @@ -4,6 +4,10 @@ NO_ERROR = "Kein Fehler." +# Sekunden, nach denen ein andauernder Fehlerzustand einer Komponente dazu führt, dass nicht mehr mit deren +# letztem bekannten Wert weitergerechnet bzw. weiter gesteuert wird (Counter, Chargepoint, PV, Bat, Consumer). +COMPONENT_ERROR_DURATION = 60 + RAMDISK_PATH = Path(__file__).resolve().parents[2] / "ramdisk" diff --git a/packages/helpermodules/utils/error_handling.py b/packages/helpermodules/utils/error_handling.py index 7e8b8c69c5..acfc5ae214 100644 --- a/packages/helpermodules/utils/error_handling.py +++ b/packages/helpermodules/utils/error_handling.py @@ -2,6 +2,7 @@ import time from helpermodules import timecheck +from helpermodules.constants import COMPONENT_ERROR_DURATION from helpermodules.messaging import MessageType from helpermodules.pub import Pub @@ -12,7 +13,8 @@ class ErrorTimerContext: - def __init__(self, topic: str, exceeded_msg: str, timeout: int = 60, hide_exception: bool = False): + def __init__(self, topic: str, exceeded_msg: str, timeout: int = COMPONENT_ERROR_DURATION, + hide_exception: bool = False): self.topic = topic self.timeout = timeout self.hide_exception = hide_exception From 27ec7caded9674a97a2daed2f680f3796bbb1a04 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Wed, 23 Sep 2026 22:40:47 +0200 Subject: [PATCH 3/8] pv/bat/consumer: set/error_timer wurde nie validiert/geroutet, dadurch nie persistiert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setdata.py kannte /set/error_timer nur für counter, wurde für pv/bat/consumer per __unknown_topic verworfen. subdata.py routete /pv//set/ zusätzlich gar nicht erst in Pv.data.set. Da control/data.py jeden Zyklus frisch aus SubData kopiert, wurde error_timer dadurch bei jedem Zyklus auf None zurückgesetzt - der Fehlerzustand konnte nie 60s andauern. Live auf Testsystem gefunden (error_timer stieg bei jedem Print statt konstant zu bleiben). --- packages/helpermodules/setdata.py | 6 ++++++ packages/helpermodules/subdata.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/packages/helpermodules/setdata.py b/packages/helpermodules/setdata.py index d67736b248..775d5c86ab 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -647,6 +647,8 @@ def process_pv_topic(self, msg: mqtt.MQTTMessage): elif subdata.SubData.pv_data.get(f"pv{get_index(msg.topic)}"): if "/get/fault_state" in msg.topic: self._validate_value(msg, int, [(0, 2)]) + elif "/set/error_timer" in msg.topic: + self._validate_value(msg, float, [(0, float("inf"))]) elif ("/get/fault_str" in msg.topic or "/get/serial_number" in msg.topic): self._validate_value(msg, str) @@ -736,6 +738,8 @@ def process_bat_topic(self, msg: mqtt.MQTTMessage): self._validate_value(msg, float, [(0, 100)]) elif "/get/fault_state" in msg.topic: self._validate_value(msg, int, [(0, 2)]) + elif "/set/error_timer" in msg.topic: + self._validate_value(msg, float, [(0, float("inf"))]) elif ("/get/fault_str" in msg.topic or "/get/serial_number" in msg.topic): self._validate_value(msg, str) @@ -1268,6 +1272,8 @@ def process_consumer_topic(self, msg): re.search("consumer/[0-9]+/set/timestamp_wrote_last_on_time$", msg.topic) is not None or re.search("consumer/[0-9]+/get/set_power$", msg.topic) is not None): self._validate_value(msg, float) + elif re.search("consumer/[0-9]+/set/error_timer$", msg.topic) is not None: + self._validate_value(msg, float, [(0, float("inf"))]) elif (re.search("consumer/[0-9]+/get/currents$", msg.topic) is not None or re.search("consumer/[0-9]+/get/voltages$", msg.topic) is not None or re.search("consumer/[0-9]+/get/powers$", msg.topic) is not None or diff --git a/packages/helpermodules/subdata.py b/packages/helpermodules/subdata.py index 6ed8592708..75e2cbb152 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -584,6 +584,8 @@ def process_pv_topic(self, var: Dict[str, pv.Pv], msg: mqtt.MQTTMessage): self.set_json_payload_class(var["pv"+index].data.config, msg) elif re.search("/pv/[0-9]+/get/", msg.topic) is not None: self.set_json_payload_class(var["pv"+index].data.get, msg) + elif re.search("/pv/[0-9]+/set/", msg.topic) is not None: + self.set_json_payload_class(var["pv"+index].data.set, msg) elif re.search("/pv/", msg.topic) is not None: if re.search("/pv/config/", msg.topic) is not None: self.set_json_payload_class(self.pv_all_data.data.config, msg) From fd970527ee2633d6474c1b5d3f994513f3a1bbc9 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Wed, 23 Sep 2026 22:52:54 +0200 Subject: [PATCH 4/8] =?UTF-8?q?consumer:=20get/power=20ohne=20MQTT-Topic-M?= =?UTF-8?q?etadaten,=20dadurch=20nie=20an=20Broker/UI=20zur=C3=BCckgemelde?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anders als bei PV/Bat fehlte bei Consumer.Get.power die metadata={"topic": "get/power"}. changed_values_handler publiziert Feldänderungen nur für Felder mit Topic-Metadaten - der vom Fehlerfall-Mechanismus berechnete Wert (0 nach 60s) blieb dadurch rein im Python-Prozess hängen und erreichte nie den Broker bzw. die Status-Karte in openwb-ui-settings (ConsumerCard.vue liest direkt von openWB/consumer//get/power). Die Summe (Alle Verbraucher) war davon nicht betroffen, da consumer_all.py's power-Feld die Metadaten bereits hatte. --- packages/control/consumer/consumer_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index a319881e0e..549ff8fc22 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -78,7 +78,7 @@ class Get: fault_state: int = 0 imported: float = 0 phases_in_use: int = 0 - power: float = 0 + power: float = field(default=0, metadata={"topic": "get/power"}) powers: Optional[List[Optional[float]]] = None set_power: Optional[float] = None state: Optional[bool] = False From 792f706de492d66d27a36a5cb081ec3ac2d8e398 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Wed, 23 Sep 2026 23:07:20 +0200 Subject: [PATCH 5/8] counter: MAX_EVU_ERROR_DURATION-Alias entfernt, direkt COMPONENT_ERROR_DURATION nutzen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit War nur noch ein Alias auf COMPONENT_ERROR_DURATION mit genau einer Verwendungsstelle - konsequent zu Ende geführt, damit über alle Module hinweg dieselbe Konstante sichtbar ist. --- packages/control/counter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/control/counter.py b/packages/control/counter.py index 35320bec73..15bf62ba10 100644 --- a/packages/control/counter.py +++ b/packages/control/counter.py @@ -132,8 +132,6 @@ class SwitchOnTexts: class Counter: - MAX_EVU_ERROR_DURATION = COMPONENT_ERROR_DURATION - def __init__(self, index): try: self.data = CounterData() @@ -161,7 +159,7 @@ def _get_loadmanagement_state(self) -> bool: if self.data.set.error_timer is None: self.data.set.error_timer = timecheck.create_timestamp() return True - elif timecheck.check_timestamp(self.data.set.error_timer, self.MAX_EVU_ERROR_DURATION) is False: + elif timecheck.check_timestamp(self.data.set.error_timer, COMPONENT_ERROR_DURATION) is False: for cp in connected_cps: if self.num == data.data.counter_all_data.get_id_evu_counter(): data.data.cp_data[cp].set_state_and_log( From ebe66ce722a08c87f48282cc993ff9535d7b305d Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 3 Oct 2026 10:45:30 +0200 Subject: [PATCH 6/8] Kostal Plenticore, Fronius HTTP API: Fehlerzustand pro Komponente isolieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analog zu RCT (#3958): ein Fehler bei nur einer Komponente (falsches Register/Endpoint) markiert nicht mehr fälschlich die anderen als defekt. --- .../fronius/fronius_http_api/device.py | 23 +++++++++++-------- .../kostal/kostal_plenticore/device.py | 5 +++- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/packages/modules/devices/fronius/fronius_http_api/device.py b/packages/modules/devices/fronius/fronius_http_api/device.py index 3c57352bd5..01e30d43fa 100644 --- a/packages/modules/devices/fronius/fronius_http_api/device.py +++ b/packages/modules/devices/fronius/fronius_http_api/device.py @@ -6,6 +6,7 @@ from modules.common import req from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_context import SingleComponentUpdateContext from modules.common.configurable_device import ComponentFactoryByType, ConfigurableDevice, MultiComponentUpdater from modules.devices.fronius.fronius_http_api.bat import FroniusBat from modules.devices.fronius.fronius_http_api.config import ( @@ -67,17 +68,19 @@ def get_meter_system_response(): timeout=5).json() return meter_system_response + # Verbindungsfehler betrifft alle Komponenten, ein Fehler pro Komponente nur diese eine. for component in components: - component_type = component.component_config.type - if component_type in ("inverter", "bat"): - component.update(get_powerflow_response()) - elif component_type == "counter": - variant = component.component_config.configuration.variant - component.update(get_powerflow_response(), - get_meter_system_response() if variant == 2 else None) - elif component_type == "inverter_production_meter": - variant = component.component_config.configuration.variant - component.update(get_meter_system_response() if variant == 2 else None) + with SingleComponentUpdateContext(component.fault_state): + component_type = component.component_config.type + if component_type in ("inverter", "bat"): + component.update(get_powerflow_response()) + elif component_type == "counter": + variant = component.component_config.configuration.variant + component.update(get_powerflow_response(), + get_meter_system_response() if variant == 2 else None) + elif component_type == "inverter_production_meter": + variant = component.component_config.configuration.variant + component.update(get_meter_system_response() if variant == 2 else None) return ConfigurableDevice( device_config=device_config, diff --git a/packages/modules/devices/kostal/kostal_plenticore/device.py b/packages/modules/devices/kostal/kostal_plenticore/device.py index 329347194b..9c728d7480 100644 --- a/packages/modules/devices/kostal/kostal_plenticore/device.py +++ b/packages/modules/devices/kostal/kostal_plenticore/device.py @@ -3,6 +3,7 @@ from typing import Iterable, Union from modules.common.abstract_device import DeviceDescriptor +from modules.common.component_context import SingleComponentUpdateContext from modules.common.configurable_device import ConfigurableDevice, ComponentFactoryByType, MultiComponentUpdater from modules.common.modbus import ModbusTcpClient_ from modules.devices.kostal.kostal_plenticore.bat import KostalPlenticoreBat @@ -37,9 +38,11 @@ def create_inverter_component(component_config: KostalPlenticoreInverterSetup): def update_components( components: Iterable[Union[KostalPlenticoreBat, KostalPlenticoreCounter, KostalPlenticoreInverter]]): + # Verbindungsfehler betrifft alle Komponenten, ein Fehler pro Komponente nur diese eine. with client: for component in components: - component.update() + with SingleComponentUpdateContext(component.fault_state): + component.update() def initializer(): nonlocal client From b2d8aa0069d554b2f4db3bbbac71389e717d9455 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 3 Oct 2026 12:37:56 +0200 Subject: [PATCH 7/8] PV/Bat/Consumer: Leistung nach andauerndem Fehler im Lese-Zyklus nullen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FaultState bekommt einen on_sustained_error-Callback, der aus store_error() heraus feuert - also im Lese-Zyklus (SingleComponentUpdateContext/ MultiComponentUpdateContext, ConfigurableDevice.update()), nicht erst beim späteren Publizieren. Der Lese-Zyklus läuft unconditioned jeden Durchlauf, unabhängig davon, ob eine Komponente bereits als fehlerhaft bekannt ist - loadvars.py überspringt genau das für bereits fehlerhafte Komponenten beim Publizieren (get_finished_component_obj_by_id()), was die Nullung sonst nie erreicht hätte. Verdrahtet einmalig, zentral: ConfigurableDevice.add_component() für PV/Bat, ConfigurableConsumer.__init__ für Verbraucher - pro Komponente, kein Gerätemodul angefasst. Dabei mitbehoben: - ConfigurableConsumer.update() hat Exceptions verschluckt, bevor error_handler() sie je sah (reraise=False) - Verbraucher bekamen dadurch nie dessen Fehlerbehandlung. - PurgeInverterState.update() korrigierte bei Hybrid-WR den zuletzt gesetzten Wert in-place und lief auch ohne vorheriges set() bei einem Lesefehler jeden Zyklus erneut - die Speicherleistung wurde dadurch bei jedem Fehler erneut vom Wechselrichter-Wert abgezogen. update() korrigiert jetzt eine Kopie. - Verbraucher mit externem Zähler (extra_meter) richten die Nullung nach dem Fehlerzustand des Zählers selbst, nicht des Verbrauchers (dessen eigener Fehler ohnehin nur als Statusmeldung relevant ist, da die Leistung komplett vom Zähler kommt). control/: effective_power()/EffectivePowerResult entfernt - pv_all.py/ bat_all.py/consumer.py vertrauen jetzt direkt auf get.power. Aktive Speichersteuerung und Verbraucher-Schaltbefehl-Gate bleiben in control/ (eigene Entscheidung, keine Werte mehr aktiv zu steuern), dafür neu: tick_error_timer(). --- packages/control/bat_all.py | 10 +- packages/control/bat_all_test.py | 14 +- packages/control/consumer/consumer.py | 10 +- packages/control/consumer/consumer_all.py | 2 +- .../control/consumer/consumer_all_test.py | 4 +- packages/control/consumer/consumer_test.py | 30 +--- packages/control/counter_test.py | 38 +---- packages/control/error_state.py | 37 +---- packages/control/error_state_test.py | 47 +----- packages/control/pv.py | 4 +- packages/control/pv_all.py | 6 +- packages/control/pv_all_test.py | 48 +----- .../modules/common/configurable_consumer.py | 33 ++-- .../modules/common/configurable_device.py | 15 ++ .../common/configurable_device_test.py | 68 ++++++++ packages/modules/common/fault_state.py | 22 ++- packages/modules/common/fault_state_test.py | 154 ++++++++++++++++++ packages/modules/common/store/_api.py | 15 ++ packages/modules/common/store/_api_test.py | 69 ++++++++ packages/modules/common/store/_battery.py | 6 + packages/modules/common/store/_consumer.py | 24 ++- .../modules/common/store/_consumer_test.py | 85 ++++++++++ packages/modules/common/store/_inverter.py | 24 ++- .../modules/common/store/_inverter_test.py | 58 +++++++ 24 files changed, 597 insertions(+), 226 deletions(-) create mode 100644 packages/modules/common/configurable_device_test.py create mode 100644 packages/modules/common/fault_state_test.py create mode 100644 packages/modules/common/store/_api_test.py create mode 100644 packages/modules/common/store/_consumer_test.py diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index 20b2e50d43..b5565f03e2 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -23,7 +23,7 @@ from typing import List, Optional, Tuple from control import data -from control.error_state import effective_power, error_duration_exceeded +from control.error_state import error_duration_exceeded, tick_error_timer from helpermodules.constants import NO_ERROR from modules.common.abstract_device import AbstractDevice from modules.common.component_context import SingleComponentUpdateContext @@ -152,10 +152,10 @@ def calc_power_for_all_components(self): fault_state = 0 for battery in data.data.bat_data.values(): try: - result = effective_power( - battery.data.get.power, battery.data.get.fault_state, battery.data.set.error_timer) - battery.data.set.error_timer = result.error_timer - battery.data.get.power = result.power + # power ist bei andauerndem Fehler bereits vom Modul auf 0 gesetzt; error_timer + # wird hier nur noch für die aktive Speichersteuerung getickt. + battery.data.set.error_timer = tick_error_timer( + battery.data.get.fault_state, battery.data.set.error_timer) power += battery.data.get.power except Exception: log.exception(f"Fehler im Bat-Modul {battery.num}") diff --git a/packages/control/bat_all_test.py b/packages/control/bat_all_test.py index 47839ec90e..5c0509f444 100644 --- a/packages/control/bat_all_test.py +++ b/packages/control/bat_all_test.py @@ -64,14 +64,13 @@ def test_get_charging_power_left_diff_hybrid(bat_power: int, assert b_all.data.set.charging_power_left == expected_power -def test_calc_power_for_all_components_excludes_battery_after_60s(data_): +def test_calc_power_for_all_components_trusts_already_zeroed_battery_but_ticks_error_timer_for_active_control(data_): # setup - error_timer = timecheck.create_timestamp() - 61 + # bat2 ist seit über 60s im Fehlerzustand, power schon vom Modul genullt - BatAll summiert nur noch. data.data.bat_data = {"bat1": Bat(1), "bat2": Bat(2)} data.data.bat_data["bat1"].data.get.power = -500 - data.data.bat_data["bat2"].data.get.power = -2000 + data.data.bat_data["bat2"].data.get.power = 0 data.data.bat_data["bat2"].data.get.fault_state = FaultStateLevel.ERROR - data.data.bat_data["bat2"].data.set.error_timer = error_timer b_all = BatAll() # execution @@ -79,15 +78,14 @@ def test_calc_power_for_all_components_excludes_battery_after_60s(data_): # evaluation assert b_all.data.get.power == -500 - assert data.data.bat_data["bat2"].data.set.error_timer == error_timer - # eigener get.power-Wert wird jetzt ebenfalls abgebildet (wie bei Counter/Chargepoint), nicht nur die Summe - assert data.data.bat_data["bat2"].data.get.power == 0 + assert data.data.bat_data["bat2"].data.set.error_timer is not None def test_calc_power_for_all_components_recovers_after_error_clears(data_): # setup # Speicher war im Fehlerzustand (error_timer noch gesetzt), liefert jetzt aber wieder gültige Werte - # (fault_state == 0) - der alte error_timer darf nicht mehr dazu führen, dass weiterhin 0 genutzt wird. + # (fault_state == 0) - der alte error_timer darf nicht mehr dazu führen, dass die aktive Speichersteuerung + # ihn weiterhin als fehlerhaft behandelt. data.data.bat_data = {"bat1": Bat(1)} data.data.bat_data["bat1"].data.get.power = -800 data.data.bat_data["bat1"].data.get.fault_state = 0 diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index 1dffd8e373..1c3a990a69 100644 --- a/packages/control/consumer/consumer.py +++ b/packages/control/consumer/consumer.py @@ -8,7 +8,7 @@ from control.chargemode import Chargemode from control.chargepoint.chargepoint_state import CHARGING_STATES, ChargepointState from control.consumer.consumer_data import ConsumerData, ConsumerUsage, ResetModes, WaitForStartStates -from control.error_state import effective_power, error_duration_exceeded +from control.error_state import error_duration_exceeded, tick_error_timer from control.load_protocol import Load from control.text import format_next_time_charging_start from helpermodules import timecheck @@ -47,11 +47,9 @@ def setup_values_at_start(self): def update(self): try: self.setup_values_at_start() - # Einzige Stelle, die error_timer schreibt (ConsumerAll.get_consumer_sum() und - # Counter._set_power_left() lesen anschließend nur noch das bereits abgebildete get.power). - result = effective_power(self.data.get.power, self.data.get.fault_state, self.data.set.error_timer) - self.data.set.error_timer = result.error_timer - self.data.get.power = result.power + # power ist bei andauerndem Fehler bereits vom Modul auf 0 gesetzt; error_timer wird hier + # nur noch für das Schaltbefehl-Gate in get_parameter() getickt. + self.data.set.error_timer = tick_error_timer(self.data.get.fault_state, self.data.set.error_timer) if self.data.usage.type in NOT_CONTROLLED: return else: diff --git a/packages/control/consumer/consumer_all.py b/packages/control/consumer/consumer_all.py index 8442949f86..f09ab1fd4a 100644 --- a/packages/control/consumer/consumer_all.py +++ b/packages/control/consumer/consumer_all.py @@ -41,7 +41,7 @@ def get_consumer_sum(self): try: for consumer in data.data.consumer_data.values(): try: - # consumer.data.get.power ist bereits durch Consumer.update() auf den Fehlerfall abgebildet. + # Fehlerfall ist bereits vom Modul selbst auf get.power abgebildet. power = power + consumer.data.get.power except Exception: log.exception(f"Fehler in der allgemeinen Verbaucher-Klasse für Verbaucher {consumer}") diff --git a/packages/control/consumer/consumer_all_test.py b/packages/control/consumer/consumer_all_test.py index 22487ecf4b..96e64e17e8 100644 --- a/packages/control/consumer/consumer_all_test.py +++ b/packages/control/consumer/consumer_all_test.py @@ -4,9 +4,7 @@ def test_get_consumer_sum_sums_power(data_): - # setup - # Der Fehlerfall (nach COMPONENT_ERROR_DURATION s power=0) wird bereits von Consumer.update() abgebildet - - # get_consumer_sum() summiert nur noch, ohne eigene Fehlerfall-Logik. + # setup - Fehlerfall ist bereits vom Modul selbst abgebildet, get_consumer_sum() summiert nur noch data.data.consumer_data = {"consumer1": Consumer(1), "consumer2": Consumer(2)} data.data.consumer_data["consumer1"].data.get.power = 500 data.data.consumer_data["consumer2"].data.get.power = 2000 diff --git a/packages/control/consumer/consumer_test.py b/packages/control/consumer/consumer_test.py index 8f100af6df..97f534de8b 100644 --- a/packages/control/consumer/consumer_test.py +++ b/packages/control/consumer/consumer_test.py @@ -59,10 +59,9 @@ def test_get_parameter_stops_consumer_after_60s_error(consumer: Consumer, monkey monkeypatch.setattr(consumer_module, "get_component_name_by_id", Mock(return_value="Verbraucher 1")) # execution - min_current, required_current, message, mode, submode = consumer.get_parameter() + required_current, message, mode, submode = consumer.get_parameter() # evaluation - assert min_current == 0 assert required_current == 0 assert submode == Chargemode.STOP assert "Verbraucher 1" in message @@ -78,14 +77,16 @@ def test_get_parameter_continues_normally_within_grace_period(consumer: Consumer consumer.data.set.switch_interval_elapsed = True # execution - _, _, _, _, submode = consumer.get_parameter() + _, _, _, submode = consumer.get_parameter() # evaluation assert submode != Chargemode.STOP -def test_update_zeroes_power_after_60s_error(consumer: Consumer): +def test_update_ticks_error_timer_without_touching_already_zeroed_power(consumer: Consumer): # setup + # Verbraucher seit über 60s im Fehlerzustand, power schon vom Modul genullt - update() fasst es nicht + # mehr an, tickt aber weiterhin error_timer für get_parameter(). error_timer = timecheck.create_timestamp() - 61 consumer.data.usage.type = ConsumerUsage.METER_ONLY # NOT_CONTROLLED, kurzer Rückweg nach der Zuordnung consumer.data.get.power = 2000 @@ -95,31 +96,16 @@ def test_update_zeroes_power_after_60s_error(consumer: Consumer): # execution consumer.update() - # evaluation - assert consumer.data.get.power == 0 - # error_timer bleibt für die "wieviel Zeit ist bereits vergangen"-Logik erhalten, nicht zurückgesetzt - assert consumer.data.set.error_timer == error_timer - - -def test_update_keeps_power_within_grace_period(consumer: Consumer): - # setup - error_timer = timecheck.create_timestamp() - 30 - consumer.data.usage.type = ConsumerUsage.METER_ONLY - consumer.data.get.power = 2000 - consumer.data.get.fault_state = FaultStateLevel.ERROR - consumer.data.set.error_timer = error_timer - - # execution - consumer.update() - # evaluation assert consumer.data.get.power == 2000 + assert consumer.data.set.error_timer == error_timer def test_update_recovers_after_error_clears(consumer: Consumer): # setup # Verbraucher war im Fehlerzustand (error_timer noch gesetzt), liefert jetzt aber wieder gültige Werte - # (fault_state == 0) - der alte error_timer darf nicht mehr dazu führen, dass weiterhin 0 genutzt wird. + # (fault_state == 0) - der alte error_timer darf nicht mehr dazu führen, dass get_parameter() ihn + # weiterhin als fehlerhaft behandelt. consumer.data.usage.type = ConsumerUsage.METER_ONLY consumer.data.get.power = 1800 consumer.data.get.fault_state = 0 diff --git a/packages/control/counter_test.py b/packages/control/counter_test.py index 79619fa672..c931f59c47 100644 --- a/packages/control/counter_test.py +++ b/packages/control/counter_test.py @@ -8,8 +8,6 @@ from control import data from control.chargepoint.chargepoint import Chargepoint from control.consumer.consumer import Consumer -from control.consumer.consumer_all import AllConsumers -from control.consumer.usage import ConsumerUsage from control.counter import Counter, CounterData, Get from control.ev.ev import Ev from control.ev.charge_template import ChargeTemplate @@ -139,10 +137,7 @@ def test_set_current_left_nets_out_own_current_despite_noise_on_unused_phase(mon def test_set_power_left_reads_consumer_power_directly(monkeypatch, data_): # setup - # Der Fehlerfall (nach COMPONENT_ERROR_DURATION s power=0) wird bereits von Consumer.update() abgebildet - - # _set_power_left() liest hier nur noch das bereits abgebildete consumer.data.get.power, ohne eigene - # Fehlerfall-Logik. Simuliert einen Verbraucher, der seit über 60s im Fehlerzustand ist: Consumer.update() - # hätte get.power bereits auf 0 gesetzt. + # Fehlerfall ist bereits vom Verbraucher-Modul selbst abgebildet - _set_power_left() liest nur noch. get_evu_counter_str_mock = Mock(return_value="counter0") monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter_str", get_evu_counter_str_mock) counter = Counter(0) @@ -161,37 +156,6 @@ def test_set_power_left_reads_consumer_power_directly(monkeypatch, data_): assert counter.data.set.raw_power_left == 23000 -def test_set_power_left_and_consumer_sum_agree_within_same_cycle(monkeypatch, data_): - """ Regressionstest für die vormalige ~1-Zyklus-Verzögerung: Counter._set_power_left() las - consumer.data.set.error_timer, bevor ConsumerAll.get_consumer_sum() (der einzige Schreiber) ihn in - diesem Zyklus aktualisiert hatte. Jetzt schreibt einzig Consumer.update() (läuft laut prepare.py vor - beiden) - _set_power_left() und get_consumer_sum() sehen im selben Zyklus denselben Wert. """ - # setup - get_evu_counter_str_mock = Mock(return_value="counter0") - monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter_str", get_evu_counter_str_mock) - counter = Counter(0) - counter.data.config.max_total_power = 24000 - counter.data.get.power = 1000 - data.data.cp_data = {} - data.data.consumer_data = {"consumer1": Consumer(1)} - consumer = data.data.consumer_data["consumer1"] - consumer.data.usage.type = ConsumerUsage.METER_ONLY # NOT_CONTROLLED, kurzer Rückweg in update() - consumer.data.get.power = 2000 - consumer.data.get.fault_state = FaultStateLevel.ERROR - consumer.data.set.error_timer = timecheck.create_timestamp() - 61 - consumer.data.set.switch_interval_elapsed = True - - # execution - Reihenfolge wie in prepare.py: Consumer.update() vor Counter.setup_counter()/get_consumer_sum() - consumer.update() - counter._set_power_left(True) - all_consumers = AllConsumers() - all_consumers.get_consumer_sum() - - # evaluation - beide sehen im selben Zyklus den bereits auf 0 abgebildeten Wert - assert counter.data.set.raw_power_left == 24000 - 1000 - assert all_consumers.data.get.power == 0 - - @dataclass class Params: name: str diff --git a/packages/control/error_state.py b/packages/control/error_state.py index 7520be9d5e..92489fdb85 100644 --- a/packages/control/error_state.py +++ b/packages/control/error_state.py @@ -1,9 +1,6 @@ -""" Hilfsfunktion, um bei andauerndem Fehlerzustand einer Komponente nach Ablauf einer Wartezeit nicht mehr mit -deren letztem bekannten Wert weiterzurechnen. Analog zu Counter._get_loadmanagement_state, aber für Komponenten, -die im Fehlerfall nicht auf einen konfigurierten Ersatzwert zurückfallen, sondern schlicht mit 0W nicht mehr -berücksichtigt werden sollen. -""" -from dataclasses import dataclass +""" Gate für Komponenten, die nach andauerndem Fehler nicht mehr aktiv angesteuert werden sollen (zB aktive +Speichersteuerung, Verbraucher-Schaltbefehle). Das Nullen der Leistung selbst passiert im Modul +(modules/common/fault_state.py::FaultState.on_sustained_error). """ from typing import Optional from helpermodules import timecheck @@ -11,29 +8,13 @@ from modules.common.fault_state_level import FaultStateLevel +def tick_error_timer(fault_state: int, error_timer: Optional[float]) -> Optional[float]: + if fault_state == FaultStateLevel.ERROR: + return error_timer if error_timer is not None else timecheck.create_timestamp() + return None + + def error_duration_exceeded(fault_state: int, error_timer: Optional[float]) -> bool: """ analog zu ErrorTimerContext.error_counter_exceeded() (helpermodules/utils/error_handling.py) """ return (fault_state == FaultStateLevel.ERROR and error_timer is not None and timecheck.check_timestamp(error_timer, COMPONENT_ERROR_DURATION) is False) - - -@dataclass -class EffectivePowerResult: - power: float - error_timer: Optional[float] - just_expired: bool # True genau in dem Zyklus, in dem die Wartezeit abgelaufen ist - - -def effective_power(power: float, fault_state: int, error_timer: Optional[float]) -> EffectivePowerResult: - """ ermittelt, mit welcher Leistung für eine Komponente weitergerechnet werden soll. - `power` selbst wird nicht verändert - nur der Rückgabewert. - """ - if fault_state == FaultStateLevel.ERROR: - if error_timer is None: - return EffectivePowerResult(power, timecheck.create_timestamp(), False) - elif error_duration_exceeded(fault_state, error_timer): - return EffectivePowerResult(0, error_timer, power != 0) - else: - return EffectivePowerResult(power, error_timer, False) - else: - return EffectivePowerResult(power, None, False) diff --git a/packages/control/error_state_test.py b/packages/control/error_state_test.py index 62772b5285..f46b16bc5c 100644 --- a/packages/control/error_state_test.py +++ b/packages/control/error_state_test.py @@ -1,51 +1,20 @@ -from control.error_state import effective_power, error_duration_exceeded +from control.error_state import error_duration_exceeded, tick_error_timer from helpermodules import timecheck from helpermodules.constants import COMPONENT_ERROR_DURATION from modules.common.fault_state_level import FaultStateLevel -def test_effective_power_no_error_resets_timer(): - result = effective_power(1000, FaultStateLevel.NO_ERROR, 123.0) +def test_tick_error_timer_no_error_resets_timer(): + assert tick_error_timer(FaultStateLevel.NO_ERROR, 123.0) is None - assert result.power == 1000 - assert result.error_timer is None - assert result.just_expired is False +def test_tick_error_timer_first_error_starts_timer(): + assert tick_error_timer(FaultStateLevel.ERROR, None) is not None -def test_effective_power_first_error_starts_timer(): - result = effective_power(1000, FaultStateLevel.ERROR, None) - assert result.power == 1000 - assert result.error_timer is not None - assert result.just_expired is False - - -def test_effective_power_within_grace_period_keeps_power(monkeypatch): - now = timecheck.create_timestamp() - result = effective_power(1000, FaultStateLevel.ERROR, now - (COMPONENT_ERROR_DURATION - 1)) - - assert result.power == 1000 - assert result.error_timer == now - (COMPONENT_ERROR_DURATION - 1) - assert result.just_expired is False - - -def test_effective_power_expired_returns_zero_and_flags_transition(): - error_timer = timecheck.create_timestamp() - (COMPONENT_ERROR_DURATION + 1) - - result = effective_power(1000, FaultStateLevel.ERROR, error_timer) - - assert result.power == 0 - assert result.error_timer == error_timer - assert result.just_expired is True - - -def test_effective_power_expired_does_not_re_flag_transition(): - error_timer = timecheck.create_timestamp() - (COMPONENT_ERROR_DURATION + 1) - - result = effective_power(0, FaultStateLevel.ERROR, error_timer) - - assert result.power == 0 - assert result.just_expired is False +def test_tick_error_timer_keeps_existing_timer(): + error_timer = timecheck.create_timestamp() - 30 + assert tick_error_timer(FaultStateLevel.ERROR, error_timer) == error_timer def test_error_duration_exceeded_no_error(): diff --git a/packages/control/pv.py b/packages/control/pv.py index 956ecf594b..d10d246cd7 100644 --- a/packages/control/pv.py +++ b/packages/control/pv.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field import logging -from typing import List, Optional +from typing import List from dataclass_utils.factories import currents_list_factory @@ -45,7 +45,7 @@ def get_factory() -> Get: @dataclass class Set: - error_timer: Optional[float] = field(default=None, metadata={"topic": "set/error_timer"}) + pass def set_factory() -> Set: diff --git a/packages/control/pv_all.py b/packages/control/pv_all.py index 06dc12a100..5f3f66bfec 100644 --- a/packages/control/pv_all.py +++ b/packages/control/pv_all.py @@ -8,7 +8,6 @@ import logging from control import data -from control.error_state import effective_power from helpermodules.constants import NO_ERROR log = logging.getLogger(__name__) @@ -60,10 +59,7 @@ def calc_power_for_all_components(self) -> None: fault_state = 0 for module in data.data.pv_data.values(): try: - result = effective_power( - module.data.get.power, module.data.get.fault_state, module.data.set.error_timer) - module.data.set.error_timer = result.error_timer - module.data.get.power = result.power + # power ist bei andauerndem Fehler bereits vom Modul selbst auf 0 gesetzt. power += module.data.get.power except Exception: log.exception(f"Fehler im allgemeinen PV-Modul für pv{module.num}") diff --git a/packages/control/pv_all_test.py b/packages/control/pv_all_test.py index aa1d024f1d..c4d5d7841c 100644 --- a/packages/control/pv_all_test.py +++ b/packages/control/pv_all_test.py @@ -4,7 +4,6 @@ from control.limiting_value import loadmanagement_limit_factory from control.pv import Pv from control.pv_all import PvAll -from helpermodules import timecheck from modules.common.fault_state_level import FaultStateLevel @@ -26,30 +25,13 @@ def test_calc_power_for_all_components_counts_healthy_module(data_): assert pv_all.data.get.power == -500 -def test_calc_power_for_all_components_counts_module_within_grace_period(data_): +def test_calc_power_for_all_components_trusts_already_zeroed_module(data_): # setup - data.data.pv_data = {"pv1": Pv(1)} - data.data.pv_data["pv1"].data.get.power = -500 - data.data.pv_data["pv1"].data.get.fault_state = FaultStateLevel.ERROR - data.data.pv_data["pv1"].data.set.error_timer = timecheck.create_timestamp() - 30 - _setup_io_actions() - pv_all = PvAll() - - # execution - pv_all.calc_power_for_all_components() - - # evaluation - assert pv_all.data.get.power == -500 - - -def test_calc_power_for_all_components_excludes_module_after_60s(data_): - # setup - error_timer = timecheck.create_timestamp() - 61 + # pv2 ist seit über 60s im Fehlerzustand, power schon vom Modul genullt - PvAll summiert nur noch. data.data.pv_data = {"pv1": Pv(1), "pv2": Pv(2)} data.data.pv_data["pv1"].data.get.power = -500 - data.data.pv_data["pv2"].data.get.power = -2000 + data.data.pv_data["pv2"].data.get.power = 0 data.data.pv_data["pv2"].data.get.fault_state = FaultStateLevel.ERROR - data.data.pv_data["pv2"].data.set.error_timer = error_timer _setup_io_actions() pv_all = PvAll() @@ -58,27 +40,3 @@ def test_calc_power_for_all_components_excludes_module_after_60s(data_): # evaluation assert pv_all.data.get.power == -500 - # error_timer bleibt für die "wieviel Zeit ist bereits vergangen"-Logik erhalten, nicht zurückgesetzt - assert data.data.pv_data["pv2"].data.set.error_timer == error_timer - # eigener get.power-Wert wird jetzt ebenfalls abgebildet (wie bei Counter/Chargepoint), nicht nur die Summe - assert data.data.pv_data["pv2"].data.get.power == 0 - - -def test_calc_power_for_all_components_recovers_after_error_clears(data_): - # setup - # Modul war im Fehlerzustand (error_timer noch gesetzt), liefert jetzt aber wieder gültige Werte - # (fault_state == 0) - der alte error_timer darf nicht mehr dazu führen, dass weiterhin 0 genutzt wird. - data.data.pv_data = {"pv1": Pv(1)} - data.data.pv_data["pv1"].data.get.power = -1500 - data.data.pv_data["pv1"].data.get.fault_state = 0 - data.data.pv_data["pv1"].data.set.error_timer = timecheck.create_timestamp() - 61 - _setup_io_actions() - pv_all = PvAll() - - # execution - pv_all.calc_power_for_all_components() - - # evaluation - assert data.data.pv_data["pv1"].data.get.power == -1500 - assert data.data.pv_data["pv1"].data.set.error_timer is None - assert pv_all.data.get.power == -1500 diff --git a/packages/modules/common/configurable_consumer.py b/packages/modules/common/configurable_consumer.py index f1d4113ddf..317657578e 100644 --- a/packages/modules/common/configurable_consumer.py +++ b/packages/modules/common/configurable_consumer.py @@ -48,6 +48,7 @@ def __init__(self, self.sim_counter = SimCounterConsumer(self.config.id, ComponentType.CONSUMER) self.store = get_component_value_store(ComponentType.CONSUMER.value, self.config.id) self.fault_state = FaultState(ComponentInfo(self.config.id, self.config.name, ComponentType.CONSUMER.value)) + self.fault_state.on_sustained_error = self._zero_power_on_sustained_error self.peak_filter = PeakFilter(ComponentType.CONSUMER, self.config.id, self.fault_state) try: self.module_initializer() @@ -72,18 +73,28 @@ def error_handler(self) -> None: self.error_timestamp = None Pub().pub(error_timestamp_topic, self.error_timestamp) + def _zero_power_on_sustained_error(self) -> None: + try: + self.store.zero_power_on_sustained_error() + self.store.update() + except AttributeError: + pass + def update(self): - with SingleComponentUpdateContext(self.fault_state): - if self.module_updater is not None: - consumer_state = self.module_updater() - imported, exported = self.peak_filter.check_values( - consumer_state.power, - consumer_state.imported, - consumer_state.exported if consumer_state.exported is not None else 0) - if imported != consumer_state.imported or exported != consumer_state.exported: - consumer_state.imported = imported - consumer_state.exported = exported - self.store.set(consumer_state) + try: + with SingleComponentUpdateContext(self.fault_state, reraise=True): + if self.module_updater is not None: + consumer_state = self.module_updater() + imported, exported = self.peak_filter.check_values( + consumer_state.power, + consumer_state.imported, + consumer_state.exported if consumer_state.exported is not None else 0) + if imported != consumer_state.imported or exported != consumer_state.exported: + consumer_state.imported = imported + consumer_state.exported = exported + self.store.set(consumer_state) + except Exception: + self.error_handler() def set_power_limit(self, power_limit: float, data: SetLimitData) -> None: with SingleComponentUpdateContext(self.fault_state): diff --git a/packages/modules/common/configurable_device.py b/packages/modules/common/configurable_device.py index 97ceb6e70f..73da148541 100644 --- a/packages/modules/common/configurable_device.py +++ b/packages/modules/common/configurable_device.py @@ -113,6 +113,7 @@ def add_component(self, component_config: T_COMPONENT_CONFIG) -> None: component.initialized = False self.components["component" + str(component_config.id)] = component component.initialize() + _wire_sustained_error_handler(component) component.initialized = True def update(self): @@ -123,6 +124,7 @@ def update(self): else: try: component.initialize() + _wire_sustained_error_handler(component) component.initialized = True initialized_components.append(component) except Exception: @@ -130,6 +132,19 @@ def update(self): self.__component_updater(initialized_components, self.error_handler) +def _wire_sustained_error_handler(component: Any) -> None: + if hasattr(component, "fault_state") and hasattr(component, "store"): + store = component.store + + def handler() -> None: + try: + store.zero_power_on_sustained_error() + store.update() + except AttributeError: + pass + component.fault_state.on_sustained_error = handler + + def set_power_limit_wrapper(bat_component: AbstractBat, power_limit: Optional[int]): """set_power_limit innerhalb des SingleComponentUpdateContext aufrufen, damit Fehler im fault_state-Handler behandelt werden diff --git a/packages/modules/common/configurable_device_test.py b/packages/modules/common/configurable_device_test.py new file mode 100644 index 0000000000..c6aa71efd9 --- /dev/null +++ b/packages/modules/common/configurable_device_test.py @@ -0,0 +1,68 @@ +from unittest.mock import Mock + +from modules.common.component_setup import ComponentSetup +from modules.common.configurable_device import ConfigurableDevice + + +class FakeComponent: + store_spec = None # None = Mock erlaubt beliebige Attribute (zB zero_power_on_sustained_error) + + def __init__(self, component_config): + self.component_config = component_config + + def initialize(self): + self.fault_state = Mock(on_sustained_error=None) + self.store = Mock(spec=self.store_spec) if self.store_spec else Mock() + + +class FakeComponentWithoutZeroPowerHook(FakeComponent): + store_spec = ["update"] # zB Counter: keine zero_power_on_sustained_error() + + +def _device(component_class=FakeComponent) -> ConfigurableDevice: + device_config = Mock(id=1, name="Test") + return ConfigurableDevice( + device_config=device_config, + component_factory=lambda config: component_class(config), + component_updater=Mock(), + ) + + +def test_add_component_wires_sustained_error_handler(): + # setup + device = _device() + component_config = ComponentSetup("Test", "inverter", 1, None) + + # execution + device.add_component(component_config) + + # evaluation + component = device.components["component1"] + assert component.fault_state.on_sustained_error is not None + + +def test_wired_handler_zeroes_and_publishes(): + # setup + device = _device() + component_config = ComponentSetup("Test", "inverter", 1, None) + device.add_component(component_config) + component = device.components["component1"] + + # execution + component.fault_state.on_sustained_error() + + # evaluation + component.store.zero_power_on_sustained_error.assert_called_once() + component.store.update.assert_called_once() + + +def test_wired_handler_tolerates_store_without_zero_power_hook(): + # setup - zB Counter: Store ohne zero_power_on_sustained_error() + device = _device(FakeComponentWithoutZeroPowerHook) + component_config = ComponentSetup("Test", "counter", 1, None) + device.add_component(component_config) + component = device.components["component1"] + + # execution - darf nicht raisen + component.fault_state.on_sustained_error() + component.store.update.assert_not_called() diff --git a/packages/modules/common/fault_state.py b/packages/modules/common/fault_state.py index 499725ac52..365c1925db 100644 --- a/packages/modules/common/fault_state.py +++ b/packages/modules/common/fault_state.py @@ -3,9 +3,9 @@ from types import TracebackType from typing import Optional, Callable, Type, TypeVar -from helpermodules import exceptions +from helpermodules import exceptions, timecheck from helpermodules.pub import Pub -from helpermodules.constants import NO_ERROR +from helpermodules.constants import COMPONENT_ERROR_DURATION, NO_ERROR from modules.common import component_type from modules.common.component_setup import ComponentSetup from modules.common.fault_state_level import FaultStateLevel @@ -40,9 +40,15 @@ def __init__(self, component_info: ComponentInfo) -> None: self.component_info = component_info self.fault_str = NO_ERROR self.fault_state = FaultStateLevel.NO_ERROR + self.error_timestamp: Optional[float] = None + # vom Modul gesetzter Callback (zB Leistung nullen), feuert aus dem Lese-Zyklus heraus. + self.on_sustained_error: Optional[Callable[[], None]] = None def store_error(self) -> None: try: + self._track_error_timestamp() + if self.error_duration_exceeded() and self.on_sustained_error is not None: + self.on_sustained_error() if self.fault_state != FaultStateLevel.NO_ERROR: log.error(self.component_info.name + ": FaultState " + str(self.fault_state) + ", FaultStr " + @@ -87,6 +93,18 @@ def from_exception(self, exception: Optional[Exception] = None) -> None: self.fault_str, self.fault_state = exceptions.get_default_exception_registry().translate_exception( exception) + def _track_error_timestamp(self) -> None: + if self.fault_state == FaultStateLevel.ERROR: + if self.error_timestamp is None: + self.error_timestamp = timecheck.create_timestamp() + else: + self.error_timestamp = None + + def error_duration_exceeded(self, duration: float = COMPONENT_ERROR_DURATION) -> bool: + """ analog zu ErrorTimerContext.error_counter_exceeded() (helpermodules/utils/error_handling.py) """ + return (self.fault_state == FaultStateLevel.ERROR and self.error_timestamp is not None and + timecheck.check_timestamp(self.error_timestamp, duration) is False) + class FaultStateContext: def __init__(self, fault_state: FaultState, update_always: bool = True, reraise: bool = False) -> None: diff --git a/packages/modules/common/fault_state_test.py b/packages/modules/common/fault_state_test.py new file mode 100644 index 0000000000..58a3389694 --- /dev/null +++ b/packages/modules/common/fault_state_test.py @@ -0,0 +1,154 @@ +from unittest.mock import Mock + +from helpermodules import timecheck +from helpermodules.constants import COMPONENT_ERROR_DURATION +from modules.common.component_context import MultiComponentUpdateContext, SingleComponentUpdateContext +from modules.common.fault_state import ComponentInfo, FaultState + + +def _component() -> FaultState: + return FaultState(ComponentInfo(1, "Test", "pv")) + + +def test_error_timestamp_persists_across_repeated_failed_reads(monkeypatch): + # setup - no_error() läuft vor jedem Lesen (update_always=True), darf error_timestamp dabei nicht löschen. + fault_state = _component() + now = timecheck.create_timestamp() + monkeypatch.setattr(timecheck, "create_timestamp", lambda: now) + + def failing_read(): + raise Exception("Lesefehler") + + with SingleComponentUpdateContext(fault_state): + failing_read() + first_timestamp = fault_state.error_timestamp + assert first_timestamp is not None + + # execution - mehrere weitere Zyklen mit demselben andauernden Fehler + monkeypatch.setattr(timecheck, "create_timestamp", lambda: now + 30) + for _ in range(3): + with SingleComponentUpdateContext(fault_state): + failing_read() + + # evaluation - Zeitstempel bleibt der allererste, wird nicht pro Zyklus neu gesetzt + assert fault_state.error_timestamp == first_timestamp + + +def test_error_duration_exceeded_becomes_true_after_repeated_failed_reads(monkeypatch): + # setup + fault_state = _component() + now = timecheck.create_timestamp() + monkeypatch.setattr(timecheck, "create_timestamp", lambda: now) + + def failing_read(): + raise Exception("Lesefehler") + + with SingleComponentUpdateContext(fault_state): + failing_read() + assert fault_state.error_duration_exceeded() is False + + # execution - Zeit vergeht, weitere Zyklen scheitern weiterhin + monkeypatch.setattr(timecheck, "create_timestamp", lambda: now + COMPONENT_ERROR_DURATION + 1) + with SingleComponentUpdateContext(fault_state): + failing_read() + + # evaluation + assert fault_state.error_duration_exceeded() is True + + +def test_error_timestamp_persists_with_nested_single_component_context(monkeypatch): + # setup - Pattern wie bei Kostal Plenticore/Fronius/RCT: MultiComponentUpdateContext mit innerem + # SingleComponentUpdateContext pro Komponente. + fault_state = _component() + component = type("Component", (), {"fault_state": fault_state})() + now = timecheck.create_timestamp() + monkeypatch.setattr(timecheck, "create_timestamp", lambda: now) + + def failing_read(): + raise Exception("Lesefehler") + + with MultiComponentUpdateContext([component]): + with SingleComponentUpdateContext(component.fault_state): + failing_read() + first_timestamp = fault_state.error_timestamp + assert first_timestamp is not None + + # execution - mehrere weitere Zyklen + monkeypatch.setattr(timecheck, "create_timestamp", lambda: now + 30) + for _ in range(3): + with MultiComponentUpdateContext([component]): + with SingleComponentUpdateContext(component.fault_state): + failing_read() + + # evaluation + assert fault_state.error_timestamp == first_timestamp + + +def test_error_timestamp_clears_after_successful_read(monkeypatch): + # setup + fault_state = _component() + now = timecheck.create_timestamp() + monkeypatch.setattr(timecheck, "create_timestamp", lambda: now) + + def failing_read(): + raise Exception("Lesefehler") + + with SingleComponentUpdateContext(fault_state): + failing_read() + assert fault_state.error_timestamp is not None + + # execution - Lesen klappt wieder + with SingleComponentUpdateContext(fault_state): + pass + + # evaluation + assert fault_state.error_timestamp is None + assert fault_state.error_duration_exceeded() is False + + +def test_on_sustained_error_fires_from_read_phase_once_duration_exceeded(monkeypatch): + # setup - zentraler Regressionstest: on_sustained_error feuert aus dem Lese-Zyklus, unabhängig von + # update_values()/loadvars.py. + fault_state = _component() + on_sustained_error = Mock() + fault_state.on_sustained_error = on_sustained_error + now = timecheck.create_timestamp() + monkeypatch.setattr(timecheck, "create_timestamp", lambda: now) + + def failing_read(): + raise Exception("Lesefehler") + + with SingleComponentUpdateContext(fault_state): + failing_read() + on_sustained_error.assert_not_called() + + # execution + monkeypatch.setattr(timecheck, "create_timestamp", lambda: now + COMPONENT_ERROR_DURATION + 1) + with SingleComponentUpdateContext(fault_state): + failing_read() + + # evaluation + on_sustained_error.assert_called_once() + + +def test_on_sustained_error_does_not_fire_within_grace_period(monkeypatch): + # setup + fault_state = _component() + on_sustained_error = Mock() + fault_state.on_sustained_error = on_sustained_error + now = timecheck.create_timestamp() + monkeypatch.setattr(timecheck, "create_timestamp", lambda: now) + + def failing_read(): + raise Exception("Lesefehler") + + with SingleComponentUpdateContext(fault_state): + failing_read() + + # execution - noch innerhalb der Gnadenfrist + monkeypatch.setattr(timecheck, "create_timestamp", lambda: now + (COMPONENT_ERROR_DURATION - 1)) + with SingleComponentUpdateContext(fault_state): + failing_read() + + # evaluation + on_sustained_error.assert_not_called() diff --git a/packages/modules/common/store/_api.py b/packages/modules/common/store/_api.py index 8afca62477..d52f8d9a66 100644 --- a/packages/modules/common/store/_api.py +++ b/packages/modules/common/store/_api.py @@ -17,6 +17,9 @@ def set(self, state: T) -> None: def update(self) -> None: pass + def zero_power_on_sustained_error(self) -> None: + pass + class LoggingValueStore(Generic[T], ValueStore[T]): def __init__(self, delegate: ValueStore[T]): @@ -36,10 +39,22 @@ def update(self) -> None: except Exception: log.exception("Error while publishing module data") + def zero_power_on_sustained_error(self) -> None: + try: + self.delegate.zero_power_on_sustained_error() + except AttributeError: + pass + def update_values(component): with SingleComponentUpdateContext(component.fault_state, update_always=False): if hasattr(component, "store"): + if component.fault_state.error_duration_exceeded(): + try: + component.store.zero_power_on_sustained_error() + except AttributeError: + # darf update() nicht blockieren + pass try: component.store.update() except AttributeError: diff --git a/packages/modules/common/store/_api_test.py b/packages/modules/common/store/_api_test.py new file mode 100644 index 0000000000..ce8cae69ad --- /dev/null +++ b/packages/modules/common/store/_api_test.py @@ -0,0 +1,69 @@ +from unittest.mock import Mock + +from helpermodules import timecheck +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.fault_state_level import FaultStateLevel +from modules.common.store._api import update_values + + +def _faulted_component(store): + fault_state = FaultState(ComponentInfo(1, "Test", "counter")) + fault_state.error("Fehler") + fault_state.error_timestamp = timecheck.create_timestamp() - 61 + return Mock(fault_state=fault_state, store=store) + + +def test_update_values_still_publishes_when_store_has_no_zero_power_hook(): + # setup - zB Counter definiert absichtlich kein zero_power_on_sustained_error() + store = Mock(spec=["set", "update"]) + component = _faulted_component(store) + + # execution + update_values(component) + + # evaluation + store.update.assert_called_once() + + +def test_update_values_zeroes_power_on_sustained_error(): + # setup + store = Mock() + component = _faulted_component(store) + + # execution + update_values(component) + + # evaluation + store.zero_power_on_sustained_error.assert_called_once() + store.update.assert_called_once() + + +def test_update_values_does_not_zero_power_within_grace_period(): + # setup + store = Mock() + fault_state = FaultState(ComponentInfo(1, "Test", "pv")) + fault_state.error("Fehler") + fault_state.error_timestamp = timecheck.create_timestamp() - 30 + component = Mock(fault_state=fault_state, store=store) + + # execution + update_values(component) + + # evaluation + store.zero_power_on_sustained_error.assert_not_called() + store.update.assert_called_once() + + +def test_update_values_does_not_zero_power_without_error(): + # setup + store = Mock() + fault_state = FaultState(ComponentInfo(1, "Test", "pv")) + fault_state.fault_state = FaultStateLevel.NO_ERROR + component = Mock(fault_state=fault_state, store=store) + + # execution + update_values(component) + + # evaluation + store.zero_power_on_sustained_error.assert_not_called() + store.update.assert_called_once() diff --git a/packages/modules/common/store/_battery.py b/packages/modules/common/store/_battery.py index 7aaf5ff291..8b064d1345 100644 --- a/packages/modules/common/store/_battery.py +++ b/packages/modules/common/store/_battery.py @@ -11,6 +11,9 @@ def __init__(self, component_num: int) -> None: def set(self, bat_state: BatState): self.state = bat_state + def zero_power_on_sustained_error(self) -> None: + self.state.power = 0 + def update(self): pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/currents", self.state.currents, 2) pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/power", self.state.power, 2) @@ -29,6 +32,9 @@ def __init__(self, delegate: LoggingValueStore) -> None: def set(self, state: BatState) -> None: self.delegate.set(state) + def zero_power_on_sustained_error(self) -> None: + self.delegate.zero_power_on_sustained_error() + def update(self) -> None: self.delegate.update() diff --git a/packages/modules/common/store/_consumer.py b/packages/modules/common/store/_consumer.py index f04efa6ad5..be3cc14629 100644 --- a/packages/modules/common/store/_consumer.py +++ b/packages/modules/common/store/_consumer.py @@ -14,6 +14,9 @@ def __init__(self, component_num: int) -> None: def set(self, state: ConsumerState) -> None: self.state = state + def zero_power_on_sustained_error(self) -> None: + self.state.power = 0 + def update(self) -> None: if self.state.currents is not None: pub_to_broker(f"openWB/set/consumer/{self.num}/get/currents", self.state.currents, 2) @@ -40,19 +43,24 @@ def __init__(self, delegate: LoggingValueStore[ConsumerState]) -> None: def set(self, state: ConsumerState) -> None: self.delegate.set(state) + def zero_power_on_sustained_error(self) -> None: + self.delegate.zero_power_on_sustained_error() + def update(self) -> None: extra_meter_id = data.data.consumer_data[f"consumer{self.delegate.delegate.num}"].data.extra_meter if extra_meter_id is not None: try: - consumer = get_hierarchy_obj_by_id(extra_meter_id, ComponentType.COUNTER.value) - consumer_state = consumer.store.delegate.delegate.state + counter = get_hierarchy_obj_by_id(extra_meter_id, ComponentType.COUNTER.value) + counter_state = counter.store.delegate.delegate.state + # Leistung kommt vom Zähler, nicht vom Verbraucher - dessen Fehlerzustand zählt hier nicht. + power = 0 if counter.fault_state.error_duration_exceeded() else counter_state.power self.set(ConsumerState( - power=consumer_state.power, - imported=consumer_state.imported, - exported=consumer_state.exported, - voltages=consumer_state.voltages, - currents=consumer_state.currents, - powers=consumer_state.powers, + power=power, + imported=counter_state.imported, + exported=counter_state.exported, + voltages=counter_state.voltages, + currents=counter_state.currents, + powers=counter_state.powers, )) except Exception: raise Exception(f"Fehler beim Auslesen des Verbrauchszählers {extra_meter_id} " diff --git a/packages/modules/common/store/_consumer_test.py b/packages/modules/common/store/_consumer_test.py new file mode 100644 index 0000000000..95f9a8c2dc --- /dev/null +++ b/packages/modules/common/store/_consumer_test.py @@ -0,0 +1,85 @@ +from unittest.mock import Mock + +import pytest + +from control import data +from helpermodules import timecheck +from modules.common.abstract_device import AbstractDevice +from modules.common.component_state import ConsumerState, CounterState +from modules.common.fault_state import ComponentInfo, FaultState +from modules.common.store._consumer import ConsumerValueStoreBroker, PurgeConsumerState + + +@pytest.fixture(autouse=True) +def mock_data() -> None: + data.data_init(Mock()) + + +def _extra_meter_consumer(counter_fault_state: FaultState, counter_state: CounterState): + data.data.consumer_data["consumer1"] = Mock(data=Mock(extra_meter=9)) + counter_component = Mock( + fault_state=counter_fault_state, + store=Mock(delegate=Mock(delegate=Mock(state=counter_state)))) + counter_component.component_config.id = 9 + data.data.system_data["device0"] = Mock(spec=AbstractDevice, components={"component9": counter_component}) + return PurgeConsumerState(delegate=Mock(delegate=ConsumerValueStoreBroker(1))) + + +def test_extra_meter_shows_live_power_when_counter_healthy(): + # setup + fault_state = FaultState(ComponentInfo(9, "Zähler", "counter")) + counter_state = CounterState(power=1234, imported=100, exported=50, currents=[1, 2, 3], powers=[1, 2, 3]) + purge = _extra_meter_consumer(fault_state, counter_state) + + # execution + purge.update() + + # evaluation + assert purge.delegate.set.call_args.args[0].power == 1234 + + +def test_extra_meter_zeroes_power_after_counter_sustained_error(): + # setup - Verbraucher selbst fehlerfrei, nur der Zähler zählt hier + fault_state = FaultState(ComponentInfo(9, "Zähler", "counter")) + fault_state.error("Fehler") + fault_state.error_timestamp = timecheck.create_timestamp() - 61 + counter_state = CounterState(power=1234, imported=100, exported=50, currents=[1, 2, 3], powers=[1, 2, 3]) + purge = _extra_meter_consumer(fault_state, counter_state) + + # execution + purge.update() + + # evaluation + assert purge.delegate.set.call_args.args[0].power == 0 + # Zählerstände selbst bleiben unverändert - nur die Leistung wird genullt + assert purge.delegate.set.call_args.args[0].imported == 100 + assert purge.delegate.set.call_args.args[0].exported == 50 + + +def test_extra_meter_keeps_power_within_counter_grace_period(): + # setup + fault_state = FaultState(ComponentInfo(9, "Zähler", "counter")) + fault_state.error("Fehler") + fault_state.error_timestamp = timecheck.create_timestamp() - 30 + counter_state = CounterState(power=1234, imported=100, exported=50, currents=[1, 2, 3], powers=[1, 2, 3]) + purge = _extra_meter_consumer(fault_state, counter_state) + + # execution + purge.update() + + # evaluation + assert purge.delegate.set.call_args.args[0].power == 1234 + + +def test_no_extra_meter_just_delegates(): + # setup + data.data.consumer_data["consumer1"] = Mock(data=Mock(extra_meter=None)) + delegate = Mock(delegate=ConsumerValueStoreBroker(1)) + purge = PurgeConsumerState(delegate=delegate) + purge.set(ConsumerState(power=500)) + + # execution + purge.update() + + # evaluation + delegate.update.assert_called_once() diff --git a/packages/modules/common/store/_inverter.py b/packages/modules/common/store/_inverter.py index d7e28a996c..9615cc3cae 100644 --- a/packages/modules/common/store/_inverter.py +++ b/packages/modules/common/store/_inverter.py @@ -31,16 +31,27 @@ def update(self): class PurgeInverterState: def __init__(self, delegate: LoggingValueStore) -> None: self.delegate = delegate + self.zeroed_on_sustained_error = False def set(self, state: InverterState) -> None: + self.last_read_state = state self.delegate.set(state) + def zero_power_on_sustained_error(self) -> None: + self.zeroed_on_sustained_error = True + def update(self) -> None: - state = self.fix_hybrid_values(self.delegate.delegate.state) + # update() läuft auch ohne neues set() (Lesefehler) - fix_hybrid_values() darf daher nicht mutieren. + state = self.fix_hybrid_values(self.last_read_state) + if self.zeroed_on_sustained_error: + # Hybrid-Korrektur könnte die Nullung sonst durch Abzug der Speicherleistung aufheben. + state.power = 0 + self.zeroed_on_sustained_error = False self.delegate.set(state) self.delegate.update() def fix_hybrid_values(self, state: InverterState) -> InverterState: + """ mutiert state nicht - wird ggf. mehrfach auf denselben Rohwert angewendet. """ children = data.data.counter_all_data.get_entry_of_element(self.delegate.delegate.num)["children"] power = state.power exported = state.exported @@ -64,9 +75,14 @@ def fix_hybrid_values(self, state: InverterState) -> InverterState: # Manche Systeme werden auch aus dem Netz geladen, um einen Mindest-SoC zu halten. if state.dc_power == 0: power = 0 - state.power = power - state.exported = exported - return state + return InverterState( + power=power, + exported=exported, + imported=imported, + currents=state.currents, + dc_power=state.dc_power, + serial_number=state.serial_number, + ) def get_inverter_value_store(component_num: int) -> PurgeInverterState: diff --git a/packages/modules/common/store/_inverter_test.py b/packages/modules/common/store/_inverter_test.py index 682c9ca2d8..eea1468d7c 100644 --- a/packages/modules/common/store/_inverter_test.py +++ b/packages/modules/common/store/_inverter_test.py @@ -50,3 +50,61 @@ def test_fix_hybrid_values(params): # evaluation assert vars(state) == vars(params.expected_state) + + +def test_update_without_new_reading_does_not_apply_hybrid_fix_twice(): + # setup + # Lesefehler darf die Hybrid-Korrektur nicht doppelt anwenden. + data.data.counter_all_data.data.get.hierarchy = HYBRID_HIERARCHY + data.data.bat_data["bat2"] = Mock(spec=Bat, data=Mock( + spec=BatData, get=Mock(spec=Get, currents=[0]*3, power=223, exported=100, imported=200))) + delegate = Mock(delegate=Mock(num=1)) + purge = PurgeInverterState(delegate=delegate) + purge.set(InverterState(power=-5786, exported=200)) + + # execution + purge.update() + purge.update() # Lesefehler: kein erneutes set() + + # evaluation + state = delegate.set.call_args.args[0] + assert (state.power, state.exported) == (-6009, 300) + + +def test_zero_power_on_sustained_error_survives_hybrid_fix(): + # setup + # Nullung darf durch Hybrid-Korrektur nicht negativ werden. + data.data.counter_all_data.data.get.hierarchy = HYBRID_HIERARCHY + data.data.bat_data["bat2"] = Mock(spec=Bat, data=Mock( + spec=BatData, get=Mock(spec=Get, currents=[0]*3, power=500, exported=100, imported=200))) + delegate = Mock(delegate=Mock(num=1)) + purge = PurgeInverterState(delegate=delegate) + purge.set(InverterState(power=-5786, exported=200)) + + # execution + purge.zero_power_on_sustained_error() + purge.update() + + # evaluation + state = delegate.set.call_args.args[0] + assert state.power == 0 + + +def test_zero_power_on_sustained_error_flag_only_applies_once(): + # setup + data.data.counter_all_data.data.get.hierarchy = HYBRID_HIERARCHY + data.data.bat_data["bat2"] = Mock(spec=Bat, data=Mock( + spec=BatData, get=Mock(spec=Get, currents=[0]*3, power=500, exported=100, imported=200))) + delegate = Mock(delegate=Mock(num=1)) + purge = PurgeInverterState(delegate=delegate) + purge.set(InverterState(power=-5786, exported=200)) + purge.zero_power_on_sustained_error() + purge.update() + + # execution - Modul liest wieder gültige Werte, meldet also keinen neuen Fehler + purge.set(InverterState(power=-6000, exported=250)) + purge.update() + + # evaluation + state = delegate.set.call_args.args[0] + assert state.power == -6500 # -6000 - 500 (Speicherleistung), nicht mehr genullt From fb7b2c705e1e3855d71b3c12f3c3e399776f848a Mon Sep 17 00:00:00 2001 From: seaspotter Date: Sat, 3 Oct 2026 13:09:40 +0200 Subject: [PATCH 8/8] store: Nullung funktioniert auch ohne vorherigen erfolgreichen Read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zero_power_on_sustained_error() ging bisher von einem bereits gelesenen state aus - eine Komponente, die seit dem Programmstart nie erfolgreich gelesen wurde, hatte nichts zu nullen und blieb stumm. Ein davor über MQTT retained alter Wert wäre dadurch nie überschrieben worden. Publiziert jetzt in diesem Fall einen frischen Nullwert. --- packages/modules/common/store/_battery.py | 7 ++++- .../modules/common/store/_battery_test.py | 27 +++++++++++++++++++ packages/modules/common/store/_consumer.py | 5 +++- .../modules/common/store/_consumer_test.py | 12 +++++++++ packages/modules/common/store/_inverter.py | 20 ++++++++++---- .../modules/common/store/_inverter_test.py | 27 +++++++++++++++++++ 6 files changed, 91 insertions(+), 7 deletions(-) create mode 100644 packages/modules/common/store/_battery_test.py diff --git a/packages/modules/common/store/_battery.py b/packages/modules/common/store/_battery.py index 8b064d1345..f2a277d8e0 100644 --- a/packages/modules/common/store/_battery.py +++ b/packages/modules/common/store/_battery.py @@ -12,7 +12,12 @@ def set(self, bat_state: BatState): self.state = bat_state def zero_power_on_sustained_error(self) -> None: - self.state.power = 0 + if hasattr(self, "state"): + self.state.power = 0 + else: + # noch nie erfolgreich gelesen - trotzdem publizieren, sonst bliebe ein alter MQTT-Retained-Wert + # von vor einem Neustart für immer stehen. + self.state = BatState(power=0) def update(self): pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/currents", self.state.currents, 2) diff --git a/packages/modules/common/store/_battery_test.py b/packages/modules/common/store/_battery_test.py new file mode 100644 index 0000000000..bfe5732f88 --- /dev/null +++ b/packages/modules/common/store/_battery_test.py @@ -0,0 +1,27 @@ +from modules.common.component_state import BatState +from modules.common.store._battery import BatteryValueStoreBroker + + +def test_zero_power_on_sustained_error_without_prior_read_still_publishes(): + # setup - noch nie erfolgreich gelesen, aber andauernder Fehler soll trotzdem publizieren, sonst + # bliebe ein MQTT-Retained-Wert von vor einem Neustart für immer stehen. + broker = BatteryValueStoreBroker(1) + + # execution + broker.zero_power_on_sustained_error() + + # evaluation + assert broker.state.power == 0 + + +def test_zero_power_on_sustained_error_after_prior_read_keeps_other_fields(): + # setup + broker = BatteryValueStoreBroker(1) + broker.set(BatState(power=-500, soc=60, imported=100, exported=50)) + + # execution + broker.zero_power_on_sustained_error() + + # evaluation + assert broker.state.power == 0 + assert broker.state.soc == 60 diff --git a/packages/modules/common/store/_consumer.py b/packages/modules/common/store/_consumer.py index be3cc14629..fef253d574 100644 --- a/packages/modules/common/store/_consumer.py +++ b/packages/modules/common/store/_consumer.py @@ -15,7 +15,10 @@ def set(self, state: ConsumerState) -> None: self.state = state def zero_power_on_sustained_error(self) -> None: - self.state.power = 0 + if hasattr(self, "state"): + self.state.power = 0 + else: + self.state = ConsumerState(power=0) def update(self) -> None: if self.state.currents is not None: diff --git a/packages/modules/common/store/_consumer_test.py b/packages/modules/common/store/_consumer_test.py index 95f9a8c2dc..4ac05f63bc 100644 --- a/packages/modules/common/store/_consumer_test.py +++ b/packages/modules/common/store/_consumer_test.py @@ -83,3 +83,15 @@ def test_no_extra_meter_just_delegates(): # evaluation delegate.update.assert_called_once() + + +def test_zero_power_on_sustained_error_without_prior_read_still_publishes(): + # setup - noch nie erfolgreich gelesen, aber trotzdem publizieren, sonst bliebe ein MQTT-Retained-Wert + # von vor einem Neustart für immer stehen. + broker = ConsumerValueStoreBroker(1) + + # execution + broker.zero_power_on_sustained_error() + + # evaluation + assert broker.state.power == 0 diff --git a/packages/modules/common/store/_inverter.py b/packages/modules/common/store/_inverter.py index 9615cc3cae..49f0acd5ab 100644 --- a/packages/modules/common/store/_inverter.py +++ b/packages/modules/common/store/_inverter.py @@ -1,4 +1,5 @@ import logging +from typing import Optional from control import data from modules.common.component_state import InverterState @@ -32,6 +33,7 @@ class PurgeInverterState: def __init__(self, delegate: LoggingValueStore) -> None: self.delegate = delegate self.zeroed_on_sustained_error = False + self.last_read_state: Optional[InverterState] = None def set(self, state: InverterState) -> None: self.last_read_state = state @@ -41,12 +43,20 @@ def zero_power_on_sustained_error(self) -> None: self.zeroed_on_sustained_error = True def update(self) -> None: - # update() läuft auch ohne neues set() (Lesefehler) - fix_hybrid_values() darf daher nicht mutieren. - state = self.fix_hybrid_values(self.last_read_state) - if self.zeroed_on_sustained_error: - # Hybrid-Korrektur könnte die Nullung sonst durch Abzug der Speicherleistung aufheben. - state.power = 0 + if self.last_read_state is None: + if not self.zeroed_on_sustained_error: + raise AttributeError # noch nie erfolgreich gelesen, nichts zu publizieren + # nie erfolgreich gelesen, aber andauernder Fehler - trotzdem 0 publizieren, sonst bliebe ein + # alter MQTT-Retained-Wert von vor einem Neustart für immer stehen. + state = InverterState(power=0, exported=None) self.zeroed_on_sustained_error = False + else: + # update() läuft auch ohne neues set() (Lesefehler) - fix_hybrid_values() darf daher nicht mutieren. + state = self.fix_hybrid_values(self.last_read_state) + if self.zeroed_on_sustained_error: + # Hybrid-Korrektur könnte die Nullung sonst durch Abzug der Speicherleistung aufheben. + state.power = 0 + self.zeroed_on_sustained_error = False self.delegate.set(state) self.delegate.update() diff --git a/packages/modules/common/store/_inverter_test.py b/packages/modules/common/store/_inverter_test.py index eea1468d7c..016144fad5 100644 --- a/packages/modules/common/store/_inverter_test.py +++ b/packages/modules/common/store/_inverter_test.py @@ -52,6 +52,33 @@ def test_fix_hybrid_values(params): assert vars(state) == vars(params.expected_state) +def test_update_without_any_prior_read_is_a_noop(): + # setup - Komponente noch nie erfolgreich gelesen (zB direkt nach einem Neustart) + delegate = Mock(delegate=Mock(num=1)) + purge = PurgeInverterState(delegate=delegate) + + # execution / evaluation + with pytest.raises(AttributeError): + purge.update() + delegate.set.assert_not_called() + + +def test_zero_power_on_sustained_error_without_any_prior_read_still_publishes(): + # setup - zB Modul-Fehler besteht schon seit dem Start, nie erfolgreich gelesen, aber trotzdem nach + # 60s publizieren, sonst bliebe ein MQTT-Retained-Wert von vor dem Neustart für immer stehen. + data.data.counter_all_data.data.get.hierarchy = STANDARD_HIERARCHY + delegate = Mock(delegate=Mock(num=1)) + purge = PurgeInverterState(delegate=delegate) + + # execution + purge.zero_power_on_sustained_error() + purge.update() + + # evaluation + state = delegate.set.call_args.args[0] + assert state.power == 0 + + def test_update_without_new_reading_does_not_apply_hybrid_fix_twice(): # setup # Lesefehler darf die Hybrid-Korrektur nicht doppelt anwenden.