diff --git a/packages/control/bat.py b/packages/control/bat.py index 8e6458c8b0..f82e2c39dd 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"}) evu_power: Optional[int] = field(default=None, metadata={"topic": "set/evu_power"}) diff --git a/packages/control/bat_all.py b/packages/control/bat_all.py index 255858a658..fbb5898bb2 100644 --- a/packages/control/bat_all.py +++ b/packages/control/bat_all.py @@ -23,9 +23,11 @@ from typing import List, Optional, Tuple from control import data +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 +from modules.common.utils.component_parser import get_component_name_by_id log = logging.getLogger(__name__) @@ -150,6 +152,10 @@ def calc_power_for_all_components(self): fault_state = 0 for battery in data.data.bat_data.values(): try: + # 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}") @@ -225,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 @@ -241,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 0985f3e6c5..e8c2c17e77 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,104 @@ 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_trusts_already_zeroed_battery_but_ticks_error_timer_for_active_control(data_): + # setup + # 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 = 0 + data.data.bat_data["bat2"].data.get.fault_state = FaultStateLevel.ERROR + 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 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 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 + 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")) + get_evu_counter_mock = Mock(return_value=Mock(data=Mock(get=Mock(power=0)))) + monkeypatch.setattr(data.data.counter_all_data, "get_evu_counter", get_evu_counter_mock) + + # 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( "hybrid_bat_ids, non_hybrid_bat_ids, hybrid_inverter_ids, expected_power", [ diff --git a/packages/control/consumer/consumer.py b/packages/control/consumer/consumer.py index baff6e5e61..9bc2a91913 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, WaitForStartStates +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 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__) @@ -60,6 +62,9 @@ def update(self): self.data.get.charge_state = max(self.data.get.currents) > self.STANDBY_THRESHOLD self.process_on_time() + # 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: @@ -121,6 +126,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 (max(self.data.get.currents), diff --git a/packages/control/consumer/consumer_all.py b/packages/control/consumer/consumer_all.py index c33d036a2b..f09ab1fd4a 100644 --- a/packages/control/consumer/consumer_all.py +++ b/packages/control/consumer/consumer_all.py @@ -41,6 +41,7 @@ def get_consumer_sum(self): try: for consumer in data.data.consumer_data.values(): try: + # 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 new file mode 100644 index 0000000000..96e64e17e8 --- /dev/null +++ b/packages/control/consumer/consumer_all_test.py @@ -0,0 +1,17 @@ +from control import data +from control.consumer.consumer import Consumer +from control.consumer.consumer_all import AllConsumers + + +def test_get_consumer_sum_sums_power(data_): + # 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 + all_consumers = AllConsumers() + + # execution + all_consumers.get_consumer_sum() + + # evaluation + assert all_consumers.data.get.power == 2500 diff --git a/packages/control/consumer/consumer_data.py b/packages/control/consumer/consumer_data.py index 9bd5493a8c..9e7110f9eb 100644 --- a/packages/control/consumer/consumer_data.py +++ b/packages/control/consumer/consumer_data.py @@ -72,7 +72,7 @@ class Get: imported: float = 0 phases_in_use: int = 0 plug_state: bool = field(default=False, metadata={"topic": "get/plug_state"}) - 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 @@ -83,6 +83,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/consumer/consumer_test.py b/packages/control/consumer/consumer_test.py index b08cfa0f8a..a53601fc66 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) @@ -102,6 +104,75 @@ 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 + required_current, message, mode, submode = consumer.get_parameter() + + # evaluation + 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_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 + 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 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 + 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 6446cda804..0147a65ef8 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 @@ -133,8 +133,6 @@ class SwitchOnTexts: class Counter: - MAX_EVU_ERROR_DURATION = 60 - def __init__(self, index): try: self.data = CounterData() @@ -162,7 +160,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( @@ -277,6 +275,8 @@ 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: + # 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 " diff --git a/packages/control/counter_test.py b/packages/control/counter_test.py index b8550a815a..c931f59c47 100644 --- a/packages/control/counter_test.py +++ b/packages/control/counter_test.py @@ -135,6 +135,27 @@ 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_reads_consumer_power_directly(monkeypatch, data_): + # setup + # 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) + 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 = 0 + data.data.consumer_data["consumer1"].data.set.switch_interval_elapsed = True + + # execution + counter._set_power_left(True) + + # evaluation + # power_raw = 1000 - 0 -> 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..92489fdb85 --- /dev/null +++ b/packages/control/error_state.py @@ -0,0 +1,20 @@ +""" 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 +from helpermodules.constants import COMPONENT_ERROR_DURATION +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) diff --git a/packages/control/error_state_test.py b/packages/control/error_state_test.py new file mode 100644 index 0000000000..f46b16bc5c --- /dev/null +++ b/packages/control/error_state_test.py @@ -0,0 +1,35 @@ +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_tick_error_timer_no_error_resets_timer(): + assert tick_error_timer(FaultStateLevel.NO_ERROR, 123.0) is None + + +def test_tick_error_timer_first_error_starts_timer(): + assert tick_error_timer(FaultStateLevel.ERROR, None) is not None + + +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(): + 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.py b/packages/control/pv.py index 6dec696046..d10d246cd7 100644 --- a/packages/control/pv.py +++ b/packages/control/pv.py @@ -43,10 +43,20 @@ def get_factory() -> Get: return Get() +@dataclass +class Set: + pass + + +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..5f3f66bfec 100644 --- a/packages/control/pv_all.py +++ b/packages/control/pv_all.py @@ -59,6 +59,7 @@ def calc_power_for_all_components(self) -> None: fault_state = 0 for module in data.data.pv_data.values(): try: + # 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 new file mode 100644 index 0000000000..c4d5d7841c --- /dev/null +++ b/packages/control/pv_all_test.py @@ -0,0 +1,42 @@ +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 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_trusts_already_zeroed_module(data_): + # setup + # 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 = 0 + data.data.pv_data["pv2"].data.get.fault_state = FaultStateLevel.ERROR + _setup_io_actions() + pv_all = PvAll() + + # execution + pv_all.calc_power_for_all_components() + + # evaluation + assert pv_all.data.get.power == -500 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/setdata.py b/packages/helpermodules/setdata.py index c0789e6887..b0de2eddbb 100644 --- a/packages/helpermodules/setdata.py +++ b/packages/helpermodules/setdata.py @@ -651,6 +651,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) @@ -740,6 +742,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) @@ -1286,8 +1290,6 @@ def process_consumer_topic(self, msg): "openWB/set/consumer/get/daily_imported" in msg.topic or "openWB/set/consumer/get/daily_exported" in msg.topic): self._validate_value(msg, float) - elif re.search("openWB/set/consumer/[0-9]+/config$", msg.topic) is not None: - self._validate_value(msg, "json") elif subdata.SubData.consumer_data.get(f"consumer{get_index(msg.topic)}"): if (re.search("openWB/set/consumer/[0-9]+/module$", msg.topic) is not None or re.search("openWB/set/consumer/[0-9]+/module/simulation$", msg.topic) is not None or @@ -1305,6 +1307,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 30c7dc0471..e83c9bcd12 100644 --- a/packages/helpermodules/subdata.py +++ b/packages/helpermodules/subdata.py @@ -592,6 +592,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) 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 diff --git a/packages/modules/common/configurable_consumer.py b/packages/modules/common/configurable_consumer.py index bc056e40fd..55fef90352 100644 --- a/packages/modules/common/configurable_consumer.py +++ b/packages/modules/common/configurable_consumer.py @@ -47,6 +47,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() @@ -71,23 +72,33 @@ 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.imported is None or consumer_state.exported is None: - simulated_imported, simulated_exported = self.sim_counter.sim_count(consumer_state.power) - if consumer_state.imported is None: - imported = simulated_imported - if consumer_state.exported is None: - exported = simulated_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.imported is None or consumer_state.exported is None: + simulated_imported, simulated_exported = self.sim_counter.sim_count(consumer_state.power) + if consumer_state.imported is None: + imported = simulated_imported + if consumer_state.exported is None: + exported = simulated_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 e31366f77c..418d269339 100644 --- a/packages/modules/common/configurable_device.py +++ b/packages/modules/common/configurable_device.py @@ -114,6 +114,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): @@ -124,6 +125,7 @@ def update(self): else: try: component.initialize() + _wire_sustained_error_handler(component) component.initialized = True initialized_components.append(component) except Exception: @@ -131,6 +133,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, setpoint: SetPoint): """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..f2a277d8e0 100644 --- a/packages/modules/common/store/_battery.py +++ b/packages/modules/common/store/_battery.py @@ -11,6 +11,14 @@ 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: + 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) pub_to_broker("openWB/set/bat/"+str(self.num)+"/get/power", self.state.power, 2) @@ -29,6 +37,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/_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 006eb09392..d39ffa75df 100644 --- a/packages/modules/common/store/_consumer.py +++ b/packages/modules/common/store/_consumer.py @@ -14,6 +14,12 @@ def __init__(self, component_num: int) -> None: def set(self, state: ConsumerState) -> None: self.state = state + def zero_power_on_sustained_error(self) -> None: + 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: pub_to_broker(f"openWB/set/consumer/{self.num}/get/currents", self.state.currents, 2) @@ -40,19 +46,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 ValueError: raise Exception( diff --git a/packages/modules/common/store/_consumer_test.py b/packages/modules/common/store/_consumer_test.py new file mode 100644 index 0000000000..4ac05f63bc --- /dev/null +++ b/packages/modules/common/store/_consumer_test.py @@ -0,0 +1,97 @@ +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() + + +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 d7e28a996c..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 @@ -31,16 +32,36 @@ def update(self): 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 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) + 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() 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 +85,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..016144fad5 100644 --- a/packages/modules/common/store/_inverter_test.py +++ b/packages/modules/common/store/_inverter_test.py @@ -50,3 +50,88 @@ def test_fix_hybrid_values(params): # evaluation 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. + 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 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