From ec28f47e933f4163c3cde4251a5e511d8443f2f0 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Mon, 12 Oct 2026 22:18:59 +0200 Subject: [PATCH 1/7] Tarifaktualisierung: fehlender Backoff bei erfolglosem Abruf beheben (#3703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit next_query_time wurde bisher nur neu gesetzt, wenn ein Abruf tatsächlich neuere Preise lieferte. Hatte ein Anbieter die Preise für den Folgetag noch nicht veröffentlicht (der normale Fall für mehrere Stunden am Tag), blieb next_query_time auf dem alten, bereits abgelaufenen Wert stehen - jeder folgende Regelzyklus (alle ~10s) hat dadurch sofort erneut abgefragt statt zu warten. Betrifft alle Tarif-Anbieter gleichermaßen, da sie dieselbe ConfigurableFlexibleTariff/ConfigurableGridFee-Basis nutzen. Zweiter, unabhängiger Bug beim Testen gefunden: SingleComponentUpdateContext ohne reraise=True hat die Exception im Fehlerfall schon in __exit__ verschluckt, bevor der except-Block in update() next_query_time neu setzen konnte - der Exception-Pfad war faktisch toter Code. - next_query_time wird jetzt auch bei "keine neuen Preise" und bei einer Exception auf einen 20-Minuten-Backoff gesetzt statt unverändert zu bleiben - reraise=True ergänzt, damit der except-Block tatsächlich erreicht wird (fault_state wurde unabhängig davon schon vorher korrekt aus der Exception gesetzt) - echte Warnung, wenn 2h nach der ersten konfigurierten Update-Stunde immer noch keine Preise für den Folgetag vorliegen, statt weiterhin "Kein Fehler" anzuzeigen --- .../modules/common/configurable_tariff.py | 38 ++++++- .../common/configurable_tariff_test.py | 101 +++++++++++++++++- 2 files changed, 136 insertions(+), 3 deletions(-) diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index 9093badf26..202ca151ae 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -16,6 +16,13 @@ # Stunden für tägliche Tarifaktualisierung, manche Anbieter aktualisieren mehrfach täglich T_TARIFF_CONFIG = TypeVar("T_TARIFF_CONFIG") ONE_HOUR_SECONDS: int = 3600 +# Wartezeit, bevor nach einem erfolglosen Abruf (keine neuen Preise oder Fehler) erneut versucht wird - +# ohne das würde bei einem Anbieter ohne aktuelle Daten jeden Regelzyklus neu abgefragt (siehe #3703) +RETRY_BACKOFF_SECONDS: int = 20 * 60 +# So viele Stunden nach der ersten konfigurierten Tarifaktualisierungsstunde löst ein immer noch +# fehlender Preis für den Folgetag eine Warnung aus statt stillschweigend weiter zu versuchen - +# vorher ist das noch der normale, erwartete Fall +LATE_WARNING_OFFSET_HOURS: int = 2 log = logging.getLogger(__name__) @@ -48,13 +55,17 @@ def update(self) -> None: if self.get.next_query_time is None or self.get.next_query_time <= timecheck.create_timestamp(): log.debug(f"Tarifaktualisierung für {self.tariff_type}") try: - with SingleComponentUpdateContext(self.fault_state): + # reraise=True: ohne das verschluckt der Context-Manager die Exception bereits in + # __exit__, bevor der except-Block unten next_query_time neu setzen kann (siehe #3703) - + # fault_state wird trotzdem unabhängig davon schon in __exit__ aus der Exception gesetzt + with SingleComponentUpdateContext(self.fault_state, reraise=True): tariff_state, timeslot_length_seconds = self.__update_et_provider_data() self.__store_and_publish_updated_data(tariff_state) self.__log_and_publish_progress(timeslot_length_seconds, tariff_state) + self.__warn_if_tomorrow_missing(tariff_state) except Exception as e: log.exception(f"Fehler beim Aktualisieren der Tarifdaten {e}") - self.fault_state.warning("Error updating tariff data, retry in 5 minutes") + self._delay_next_query() else: log.info(f"nächste Tarifaktualisierung für {self.tariff_type}" + f" um {datetime.fromtimestamp(self.get.next_query_time)}") @@ -102,8 +113,31 @@ def __call_component_updater(self) -> TariffState: latest_price_timestamp = datetime.fromtimestamp(max((int(ts) for ts in tariff_state.prices.keys()), default=0)) if last_known_timestamp < latest_price_timestamp: self._calculate_next_query_time(latest_price_timestamp) + else: + # Anbieter hat (noch) keine neueren Preise geliefert, z.B. weil die Preise für den + # Folgetag noch nicht veröffentlicht sind - ohne Backoff würde sonst jeden Regelzyklus + # neu abgefragt, bis es welche gibt (siehe #3703) + self._delay_next_query() return tariff_state + def _delay_next_query(self) -> None: + self.get.next_query_time = int(timecheck.create_timestamp()) + RETRY_BACKOFF_SECONDS + Pub().pub(f"openWB/set/optional/ep/{self.tariff_type}/get/next_query_time", self.get.next_query_time) + + def __warn_if_tomorrow_missing(self, tariff_state: TariffState) -> None: + # __log_and_publish_progress() setzt den Fehlerstatus kurz zuvor auf NO_ERROR zurück - diese + # Prüfung läuft deshalb bewusst als letzter Schritt, damit eine Warnung hier nicht sofort + # wieder überschrieben wird. + now = datetime.now() + warning_hour = min(self.tariff_update_hours) + LATE_WARNING_OFFSET_HOURS + if now.hour < warning_hour: + return + tomorrow_start = int(now.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()) + 24*ONE_HOUR_SECONDS + if not any(int(ts) >= tomorrow_start for ts in tariff_state.prices.keys()): + self.fault_state.warning( + f"Noch keine aktuellen Preise für {self.tariff_type} erhalten - " + "der Anbieter liefert bisher keine Daten für den Folgetag.") + def __log_and_publish_progress(self, timeslot_length_seconds, tariff_state): def publish_info(message_extension: str) -> None: self.fault_state.no_error( diff --git a/packages/modules/common/configurable_tariff_test.py b/packages/modules/common/configurable_tariff_test.py index 6f06829450..27811b26e5 100644 --- a/packages/modules/common/configurable_tariff_test.py +++ b/packages/modules/common/configurable_tariff_test.py @@ -6,10 +6,21 @@ from modules.common.component_state import TariffState from modules.common import configurable_tariff -from modules.common.configurable_tariff import ConfigurableFlexibleTariff +from modules.common.configurable_tariff import ConfigurableFlexibleTariff, RETRY_BACKOFF_SECONDS +from modules.common.fault_state_level import FaultStateLevel from modules.electricity_pricing.flexible_tariffs.awattar.config import AwattarTariff +def _patch_now(monkeypatch: pytest.MonkeyPatch, fixed_now: datetime) -> None: + class FixedDateTime(datetime): + @classmethod + def now(cls, tz: Optional[tzinfo] = None): + return fixed_now if tz is None else fixed_now.replace(tzinfo=tz) + + monkeypatch.setattr(configurable_tariff, "datetime", FixedDateTime) + monkeypatch.setattr(timecheck, "create_timestamp", Mock(return_value=fixed_now.timestamp())) + + @pytest.mark.parametrize( "now, tariff_state, expected", [ @@ -170,3 +181,91 @@ def now(cls, tz: Optional[tzinfo] = None): # evaluation expected_timestamp = int(latest_price_timestamp.timestamp()) assert tariff.get.next_query_time == expected_timestamp + + +def test_no_new_prices_schedules_short_backoff_instead_of_stale_next_query_time(monkeypatch: pytest.MonkeyPatch): + # Regression-Test für #3703: Liefert der Anbieter keine neueren Preise (z.B. weil die Preise + # für den Folgetag noch nicht veröffentlicht sind), darf next_query_time nicht auf dem alten, + # bereits abgelaufenen Wert stehen bleiben - sonst wird jeden Regelzyklus neu abgefragt. + fixed_now = datetime(2022, 5, 16, 13, 40, 0) + _patch_now(monkeypatch, fixed_now) + + known_prices = {"1652700000": 10.72e-05} # irgendein "heute"-Preis + tariff = ConfigurableFlexibleTariff( + AwattarTariff(), Mock(return_value=Mock(return_value=TariffState(prices=known_prices)))) + tariff.get.prices = known_prices + tariff.get.next_query_time = int(fixed_now.timestamp()) - 10 # bereits abgelaufen, löst update() aus + + tariff.update() + + assert tariff.get.next_query_time == int(fixed_now.timestamp()) + RETRY_BACKOFF_SECONDS + + +def test_exception_schedules_short_backoff(monkeypatch: pytest.MonkeyPatch): + # Regression-Test für #3703: SingleComponentUpdateContext ohne reraise=True hat die Exception + # bisher verschluckt, bevor der except-Block next_query_time neu setzen konnte - ohne Fix blieb + # next_query_time nach einem Fehler dauerhaft auf dem alten, bereits abgelaufenen Wert stehen. + fixed_now = datetime(2022, 5, 16, 13, 40, 0) + _patch_now(monkeypatch, fixed_now) + + tariff = ConfigurableFlexibleTariff(AwattarTariff(), Mock(return_value=Mock(side_effect=Exception("boom")))) + tariff.get.next_query_time = int(fixed_now.timestamp()) - 10 + + tariff.update() + + assert tariff.get.next_query_time == int(fixed_now.timestamp()) + RETRY_BACKOFF_SECONDS + # fault_state wird unabhängig vom Fix schon von SingleComponentUpdateContext aus der Exception gesetzt + assert tariff.fault_state.fault_state == FaultStateLevel.ERROR + assert "boom" in tariff.fault_state.fault_str + + +def test_no_tomorrow_prices_before_late_hour_is_not_a_warning(monkeypatch: pytest.MonkeyPatch): + # Default tariff_update_hours=[14] + LATE_WARNING_OFFSET_HOURS=2 -> Warnschwelle 16:00. + # 15:59 ist noch davor - "noch keine Preise für morgen" ist hier der normale, erwartete Fall. + fixed_now = datetime(2022, 5, 16, 15, 59, 0) + _patch_now(monkeypatch, fixed_now) + + known_prices = {"1652700000": 10.72e-05} + tariff = ConfigurableFlexibleTariff( + AwattarTariff(), Mock(return_value=Mock(return_value=TariffState(prices=known_prices)))) + tariff.get.prices = known_prices + tariff.get.next_query_time = int(fixed_now.timestamp()) - 10 + + tariff.update() + + assert tariff.fault_state.fault_state == FaultStateLevel.NO_ERROR + + +def test_no_tomorrow_prices_after_late_hour_warns(monkeypatch: pytest.MonkeyPatch): + # Warnschwelle 16:00 (siehe oben), 16:05 liegt knapp danach. + fixed_now = datetime(2022, 5, 16, 16, 5, 0) + _patch_now(monkeypatch, fixed_now) + + known_prices = {"1652700000": 10.72e-05} # kein Eintrag für morgen + tariff = ConfigurableFlexibleTariff( + AwattarTariff(), Mock(return_value=Mock(return_value=TariffState(prices=known_prices)))) + tariff.get.prices = known_prices + tariff.get.next_query_time = int(fixed_now.timestamp()) - 10 + + tariff.update() + + assert tariff.fault_state.fault_state == FaultStateLevel.WARNING + assert "Folgetag" in tariff.fault_state.fault_str + + +def test_tomorrow_prices_present_after_late_hour_does_not_warn(monkeypatch: pytest.MonkeyPatch): + # Kein Fehlalarm, wenn die Preise für morgen bereits bekannt sind (z.B. aus einem früheren + # erfolgreichen Abruf am selben Tag). + fixed_now = datetime(2022, 5, 16, 16, 5, 0) + _patch_now(monkeypatch, fixed_now) + + tomorrow_timestamp = str(int(datetime(2022, 5, 17, 0, 0, 0).timestamp())) + known_prices = {"1652700000": 10.72e-05, tomorrow_timestamp: 5.467e-05} + tariff = ConfigurableFlexibleTariff( + AwattarTariff(), Mock(return_value=Mock(return_value=TariffState(prices=known_prices)))) + tariff.get.prices = known_prices + tariff.get.next_query_time = int(fixed_now.timestamp()) - 10 + + tariff.update() + + assert tariff.fault_state.fault_state == FaultStateLevel.NO_ERROR From a44ee8b37c45a9bd9c23347ee950c523443472ad Mon Sep 17 00:00:00 2001 From: seaspotter Date: Mon, 12 Oct 2026 22:45:23 +0200 Subject: [PATCH 2/7] Tarifaktualisierung: weitere Fehlerbehandlung korrigieren MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fault_state wurde nach einer gesetzten Warnung/Fehler im selben Durchlauf durch ein abschließendes no_error() sofort wieder überschrieben - jetzt einziger Ort (__finalize_update_status), der den Status setzt - Exception im component_initializer (Konstruktor) wurde bisher stillschweigend verschluckt, _component_updater blieb dauerhaft ungesetzt, ohne jede Fehlermeldung - Backoff nach Exception vereinheitlicht auf die ursprünglich vorgesehenen 5 Minuten (vorher totes "retry in 5 minutes", siehe Historie zu #3703) --- .../modules/common/configurable_tariff.py | 60 +++++++++---------- .../common/configurable_tariff_test.py | 34 ++++++++--- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index 202ca151ae..9b9ce38825 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -16,9 +16,8 @@ # Stunden für tägliche Tarifaktualisierung, manche Anbieter aktualisieren mehrfach täglich T_TARIFF_CONFIG = TypeVar("T_TARIFF_CONFIG") ONE_HOUR_SECONDS: int = 3600 -# Wartezeit, bevor nach einem erfolglosen Abruf (keine neuen Preise oder Fehler) erneut versucht wird - -# ohne das würde bei einem Anbieter ohne aktuelle Daten jeden Regelzyklus neu abgefragt (siehe #3703) -RETRY_BACKOFF_SECONDS: int = 20 * 60 +# Wartezeit vor dem nächsten Versuch nach einem Abruf ohne neue Preise oder einer Exception (siehe #3703) +RETRY_BACKOFF_SECONDS: int = 5 * 60 # So viele Stunden nach der ersten konfigurierten Tarifaktualisierungsstunde löst ein immer noch # fehlender Preis für den Folgetag eine Warnung aus statt stillschweigend weiter zu versuchen - # vorher ist das noch der normale, erwartete Fall @@ -47,7 +46,8 @@ def __init__(self, # nach Init auf NO_ERROR setzen, damit der Fehlerstatus beim Modulwechsel gelöscht wird self.fault_state.no_error() self.fault_state.store_error() - with SingleComponentUpdateContext(self.fault_state): + # reraise=True, sonst bleibt _component_updater bei einer Exception dauerhaft ungesetzt + with SingleComponentUpdateContext(self.fault_state, reraise=True): self._component_updater = component_initializer(config) def update(self) -> None: @@ -55,14 +55,11 @@ def update(self) -> None: if self.get.next_query_time is None or self.get.next_query_time <= timecheck.create_timestamp(): log.debug(f"Tarifaktualisierung für {self.tariff_type}") try: - # reraise=True: ohne das verschluckt der Context-Manager die Exception bereits in - # __exit__, bevor der except-Block unten next_query_time neu setzen kann (siehe #3703) - - # fault_state wird trotzdem unabhängig davon schon in __exit__ aus der Exception gesetzt + # reraise=True, sonst verschluckt __exit__ die Exception vor dem except-Block (siehe #3703) with SingleComponentUpdateContext(self.fault_state, reraise=True): tariff_state, timeslot_length_seconds = self.__update_et_provider_data() self.__store_and_publish_updated_data(tariff_state) - self.__log_and_publish_progress(timeslot_length_seconds, tariff_state) - self.__warn_if_tomorrow_missing(tariff_state) + self.__finalize_update_status(timeslot_length_seconds, tariff_state) except Exception as e: log.exception(f"Fehler beim Aktualisieren der Tarifdaten {e}") self._delay_next_query() @@ -124,48 +121,49 @@ def _delay_next_query(self) -> None: self.get.next_query_time = int(timecheck.create_timestamp()) + RETRY_BACKOFF_SECONDS Pub().pub(f"openWB/set/optional/ep/{self.tariff_type}/get/next_query_time", self.get.next_query_time) - def __warn_if_tomorrow_missing(self, tariff_state: TariffState) -> None: - # __log_and_publish_progress() setzt den Fehlerstatus kurz zuvor auf NO_ERROR zurück - diese - # Prüfung läuft deshalb bewusst als letzter Schritt, damit eine Warnung hier nicht sofort - # wieder überschrieben wird. - now = datetime.now() - warning_hour = min(self.tariff_update_hours) + LATE_WARNING_OFFSET_HOURS - if now.hour < warning_hour: + def __finalize_update_status(self, timeslot_length_seconds: int, tariff_state: TariffState) -> None: + # einziger Ort, der den fault_state setzt - sonst überschreibt ein abschließendes no_error() + # jede zuvor gesetzte Warnung/Fehler sofort wieder (siehe #3703) + if tariff_state.prices is None or len(tariff_state.prices) < 2: + self.fault_state.error( + f"Nicht genug Preiseinträge für {self.tariff_type}, um die Zeitschrittlänge zu berechnen.") return - tomorrow_start = int(now.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()) + 24*ONE_HOUR_SECONDS - if not any(int(ts) >= tomorrow_start for ts in tariff_state.prices.keys()): + if self.__is_tomorrow_price_missing_after_late_hour(tariff_state): self.fault_state.warning( f"Noch keine aktuellen Preise für {self.tariff_type} erhalten - " "der Anbieter liefert bisher keine Daten für den Folgetag.") - - def __log_and_publish_progress(self, timeslot_length_seconds, tariff_state): - def publish_info(message_extension: str) -> None: - self.fault_state.no_error( - f'Die {self.tariff_type} Preisliste hat {message_extension}{len(tariff_state.prices)} Einträge. ') + return expected_time_slots = int(24 * ONE_HOUR_SECONDS / timeslot_length_seconds) - publish_info(f'nicht {expected_time_slots}, sondern ' - if len(tariff_state.prices) < expected_time_slots - else '' - ) + message_extension = (f'nicht {expected_time_slots}, sondern ' if len(tariff_state.prices) < expected_time_slots + else '') + self.fault_state.no_error( + f'Die {self.tariff_type} Preisliste hat {message_extension}{len(tariff_state.prices)} Einträge. ') + + def __is_tomorrow_price_missing_after_late_hour(self, tariff_state: TariffState) -> bool: + now = datetime.now() + warning_hour = min(self.tariff_update_hours) + LATE_WARNING_OFFSET_HOURS + if now.hour < warning_hour: + return False + tomorrow_start = int(now.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()) + 24*ONE_HOUR_SECONDS + return not any(int(ts) >= tomorrow_start for ts in tariff_state.prices.keys()) def __store_and_publish_updated_data(self, tariff_state: TariffState) -> None: self.store.set(tariff_state) self.store.update() def __calculate_price_timeslot_length(self, tariff_state: TariffState) -> int: + # <2 Einträge werden in __finalize_update_status() gemeldet, hier nur Platzhalter gegen Division durch 0 if (tariff_state is None or tariff_state.prices is None or len(tariff_state.prices) < 2): - self.fault_state.error("not enough price entries to calculate timeslot length") return 1 else: first_timestamps = list(tariff_state.prices.keys())[:2] return int(first_timestamps[1]) - int(first_timestamps[0]) def _remove_outdated_prices(self, tariff_state: TariffState, timeslot_length_seconds: int) -> TariffState: - if tariff_state.prices is None: - self.fault_state.error("no prices to show") - else: + # prices is None wird in __finalize_update_status() gemeldet + if tariff_state.prices is not None: now = timecheck.create_timestamp() first_timestamp = int(max([int(timestamp) for timestamp in tariff_state.prices.keys() if int(timestamp) <= now], default=0)) diff --git a/packages/modules/common/configurable_tariff_test.py b/packages/modules/common/configurable_tariff_test.py index 27811b26e5..d55ec9e38e 100644 --- a/packages/modules/common/configurable_tariff_test.py +++ b/packages/modules/common/configurable_tariff_test.py @@ -184,9 +184,8 @@ def now(cls, tz: Optional[tzinfo] = None): def test_no_new_prices_schedules_short_backoff_instead_of_stale_next_query_time(monkeypatch: pytest.MonkeyPatch): - # Regression-Test für #3703: Liefert der Anbieter keine neueren Preise (z.B. weil die Preise - # für den Folgetag noch nicht veröffentlicht sind), darf next_query_time nicht auf dem alten, - # bereits abgelaufenen Wert stehen bleiben - sonst wird jeden Regelzyklus neu abgefragt. + # Regression-Test für #3703: ohne neue Preise darf next_query_time nicht auf dem alten, + # abgelaufenen Wert stehen bleiben - sonst wird jeden Regelzyklus neu abgefragt. fixed_now = datetime(2022, 5, 16, 13, 40, 0) _patch_now(monkeypatch, fixed_now) @@ -202,9 +201,7 @@ def test_no_new_prices_schedules_short_backoff_instead_of_stale_next_query_time( def test_exception_schedules_short_backoff(monkeypatch: pytest.MonkeyPatch): - # Regression-Test für #3703: SingleComponentUpdateContext ohne reraise=True hat die Exception - # bisher verschluckt, bevor der except-Block next_query_time neu setzen konnte - ohne Fix blieb - # next_query_time nach einem Fehler dauerhaft auf dem alten, bereits abgelaufenen Wert stehen. + # Regression-Test für #3703: next_query_time muss auch nach einer Exception weitergesetzt werden fixed_now = datetime(2022, 5, 16, 13, 40, 0) _patch_now(monkeypatch, fixed_now) @@ -219,13 +216,34 @@ def test_exception_schedules_short_backoff(monkeypatch: pytest.MonkeyPatch): assert "boom" in tariff.fault_state.fault_str +def test_constructor_exception_is_not_swallowed(monkeypatch: pytest.MonkeyPatch): + # Regression-Test: ein Fehler in component_initializer darf nicht verschluckt werden + with pytest.raises(Exception, match="init failed"): + ConfigurableFlexibleTariff(AwattarTariff(), Mock(side_effect=Exception("init failed"))) + + +def test_too_few_price_entries_error_is_not_overwritten_by_no_error(monkeypatch: pytest.MonkeyPatch): + # Regression-Test für #3703: ein abschließendes no_error() darf den Fehler nicht überschreiben + fixed_now = datetime(2022, 5, 16, 13, 40, 0) + _patch_now(monkeypatch, fixed_now) + + known_prices = {"1652700000": 10.72e-05} # nur ein Eintrag - zu wenig für eine Zeitschrittlänge + tariff = ConfigurableFlexibleTariff( + AwattarTariff(), Mock(return_value=Mock(return_value=TariffState(prices=known_prices)))) + tariff.get.next_query_time = int(fixed_now.timestamp()) - 10 + + tariff.update() + + assert tariff.fault_state.fault_state == FaultStateLevel.ERROR + + def test_no_tomorrow_prices_before_late_hour_is_not_a_warning(monkeypatch: pytest.MonkeyPatch): # Default tariff_update_hours=[14] + LATE_WARNING_OFFSET_HOURS=2 -> Warnschwelle 16:00. # 15:59 ist noch davor - "noch keine Preise für morgen" ist hier der normale, erwartete Fall. fixed_now = datetime(2022, 5, 16, 15, 59, 0) _patch_now(monkeypatch, fixed_now) - known_prices = {"1652700000": 10.72e-05} + known_prices = {"1652709600": 10.72e-05, "1652713200": 5.467e-05} # 16:00 und 17:00, noch nicht veraltet tariff = ConfigurableFlexibleTariff( AwattarTariff(), Mock(return_value=Mock(return_value=TariffState(prices=known_prices)))) tariff.get.prices = known_prices @@ -241,7 +259,7 @@ def test_no_tomorrow_prices_after_late_hour_warns(monkeypatch: pytest.MonkeyPatc fixed_now = datetime(2022, 5, 16, 16, 5, 0) _patch_now(monkeypatch, fixed_now) - known_prices = {"1652700000": 10.72e-05} # kein Eintrag für morgen + known_prices = {"1652710200": 10.72e-05, "1652713800": 5.467e-05} # 16:10 und 17:10, kein Eintrag für morgen tariff = ConfigurableFlexibleTariff( AwattarTariff(), Mock(return_value=Mock(return_value=TariffState(prices=known_prices)))) tariff.get.prices = known_prices From 01593f38e2b33919a0f8c4f62dadbf5b9ff211e1 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Mon, 12 Oct 2026 22:59:14 +0200 Subject: [PATCH 3/7] =?UTF-8?q?Tarifaktualisierung:=20Tests=20TZ-unabh?= =?UTF-8?q?=C3=A4ngig=20machen=20und=20flakiges=20Assert=20entfernen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/modules/common/configurable_tariff_test.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/modules/common/configurable_tariff_test.py b/packages/modules/common/configurable_tariff_test.py index d55ec9e38e..61b947a32d 100644 --- a/packages/modules/common/configurable_tariff_test.py +++ b/packages/modules/common/configurable_tariff_test.py @@ -213,7 +213,6 @@ def test_exception_schedules_short_backoff(monkeypatch: pytest.MonkeyPatch): assert tariff.get.next_query_time == int(fixed_now.timestamp()) + RETRY_BACKOFF_SECONDS # fault_state wird unabhängig vom Fix schon von SingleComponentUpdateContext aus der Exception gesetzt assert tariff.fault_state.fault_state == FaultStateLevel.ERROR - assert "boom" in tariff.fault_state.fault_str def test_constructor_exception_is_not_swallowed(monkeypatch: pytest.MonkeyPatch): @@ -243,7 +242,8 @@ def test_no_tomorrow_prices_before_late_hour_is_not_a_warning(monkeypatch: pytes fixed_now = datetime(2022, 5, 16, 15, 59, 0) _patch_now(monkeypatch, fixed_now) - known_prices = {"1652709600": 10.72e-05, "1652713200": 5.467e-05} # 16:00 und 17:00, noch nicht veraltet + now_ts = int(fixed_now.timestamp()) + known_prices = {str(now_ts + 3600): 10.72e-05, str(now_ts + 7200): 5.467e-05} # +1h/+2h, noch nicht veraltet tariff = ConfigurableFlexibleTariff( AwattarTariff(), Mock(return_value=Mock(return_value=TariffState(prices=known_prices)))) tariff.get.prices = known_prices @@ -259,7 +259,8 @@ def test_no_tomorrow_prices_after_late_hour_warns(monkeypatch: pytest.MonkeyPatc fixed_now = datetime(2022, 5, 16, 16, 5, 0) _patch_now(monkeypatch, fixed_now) - known_prices = {"1652710200": 10.72e-05, "1652713800": 5.467e-05} # 16:10 und 17:10, kein Eintrag für morgen + now_ts = int(fixed_now.timestamp()) + known_prices = {str(now_ts + 3600): 10.72e-05, str(now_ts + 7200): 5.467e-05} # +1h/+2h, kein Eintrag für morgen tariff = ConfigurableFlexibleTariff( AwattarTariff(), Mock(return_value=Mock(return_value=TariffState(prices=known_prices)))) tariff.get.prices = known_prices @@ -277,8 +278,9 @@ def test_tomorrow_prices_present_after_late_hour_does_not_warn(monkeypatch: pyte fixed_now = datetime(2022, 5, 16, 16, 5, 0) _patch_now(monkeypatch, fixed_now) + now_ts = int(fixed_now.timestamp()) tomorrow_timestamp = str(int(datetime(2022, 5, 17, 0, 0, 0).timestamp())) - known_prices = {"1652700000": 10.72e-05, tomorrow_timestamp: 5.467e-05} + known_prices = {str(now_ts - 3600): 10.72e-05, tomorrow_timestamp: 5.467e-05} tariff = ConfigurableFlexibleTariff( AwattarTariff(), Mock(return_value=Mock(return_value=TariffState(prices=known_prices)))) tariff.get.prices = known_prices From 8a0c26bdc4bdb61274dcce8931c19c5507325751 Mon Sep 17 00:00:00 2001 From: SeaSpotter Date: Tue, 13 Oct 2026 08:59:34 +0200 Subject: [PATCH 4/7] Update packages/modules/common/configurable_tariff.py Co-authored-by: Thomas Papendieck <14850347+tpd-opitz@users.noreply.github.com> --- packages/modules/common/configurable_tariff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index 9b9ce38825..9035b68457 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -145,7 +145,7 @@ def __is_tomorrow_price_missing_after_late_hour(self, tariff_state: TariffState) if now.hour < warning_hour: return False tomorrow_start = int(now.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()) + 24*ONE_HOUR_SECONDS - return not any(int(ts) >= tomorrow_start for ts in tariff_state.prices.keys()) + return int(max(tariff_state.prices.keys())) < tomorrow_start def __store_and_publish_updated_data(self, tariff_state: TariffState) -> None: self.store.set(tariff_state) From a0940f93c9bd3c263703e1e6cd6998a211460c15 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Tue, 13 Oct 2026 11:20:44 +0200 Subject: [PATCH 5/7] Tarifaktualisierung: Vergleich auf Zahlen statt Strings umstellen --- packages/modules/common/configurable_tariff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index 9035b68457..8a909793fd 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -145,7 +145,7 @@ def __is_tomorrow_price_missing_after_late_hour(self, tariff_state: TariffState) if now.hour < warning_hour: return False tomorrow_start = int(now.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()) + 24*ONE_HOUR_SECONDS - return int(max(tariff_state.prices.keys())) < tomorrow_start + return max(int(ts) for ts in tariff_state.prices.keys()) < tomorrow_start def __store_and_publish_updated_data(self, tariff_state: TariffState) -> None: self.store.set(tariff_state) From 3ffc2284803cd63acc6b867d3b07a7c5ed39b049 Mon Sep 17 00:00:00 2001 From: seaspotter Date: Tue, 13 Oct 2026 11:44:12 +0200 Subject: [PATCH 6/7] Tarifaktualisierung: Copilot-Findings beheben (DST, verschluckter Init-Fehler) --- .../modules/common/configurable_tariff.py | 13 +++++-- .../common/configurable_tariff_test.py | 38 +++++++++++++++++-- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index 8a909793fd..bf36c57cca 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -46,9 +46,13 @@ def __init__(self, # nach Init auf NO_ERROR setzen, damit der Fehlerstatus beim Modulwechsel gelöscht wird self.fault_state.no_error() self.fault_state.store_error() - # reraise=True, sonst bleibt _component_updater bei einer Exception dauerhaft ungesetzt - with SingleComponentUpdateContext(self.fault_state, reraise=True): + try: self._component_updater = component_initializer(config) + except Exception as e: + # nicht reraisen, sonst bliebe bei subdata.py's Zuweisung das alte Modul aktiv + log.exception(f"Fehler beim Initialisieren von {tariff_type}: {e}") + self.fault_state.from_exception(e) + self.fault_state.store_error() def update(self) -> None: if hasattr(self, "_component_updater"): @@ -144,7 +148,10 @@ def __is_tomorrow_price_missing_after_late_hour(self, tariff_state: TariffState) warning_hour = min(self.tariff_update_hours) + LATE_WARNING_OFFSET_HOURS if now.hour < warning_hour: return False - tomorrow_start = int(now.replace(hour=0, minute=0, second=0, microsecond=0).timestamp()) + 24*ONE_HOUR_SECONDS + # Datum vor dem Nullsetzen der Uhrzeit erhöhen - +24h in Sekunden träfe an Tagen mit Zeitumstellung + # nicht auf Mitternacht + tomorrow_midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + tomorrow_start = int(tomorrow_midnight.timestamp()) return max(int(ts) for ts in tariff_state.prices.keys()) < tomorrow_start def __store_and_publish_updated_data(self, tariff_state: TariffState) -> None: diff --git a/packages/modules/common/configurable_tariff_test.py b/packages/modules/common/configurable_tariff_test.py index 61b947a32d..9344bdd878 100644 --- a/packages/modules/common/configurable_tariff_test.py +++ b/packages/modules/common/configurable_tariff_test.py @@ -1,4 +1,5 @@ from datetime import datetime, tzinfo +import time from typing import Optional from unittest.mock import Mock from helpermodules import timecheck @@ -215,10 +216,16 @@ def test_exception_schedules_short_backoff(monkeypatch: pytest.MonkeyPatch): assert tariff.fault_state.fault_state == FaultStateLevel.ERROR -def test_constructor_exception_is_not_swallowed(monkeypatch: pytest.MonkeyPatch): - # Regression-Test: ein Fehler in component_initializer darf nicht verschluckt werden - with pytest.raises(Exception, match="init failed"): - ConfigurableFlexibleTariff(AwattarTariff(), Mock(side_effect=Exception("init failed"))) +def test_constructor_exception_is_logged_and_does_not_propagate(monkeypatch: pytest.MonkeyPatch): + # Regression-Test: darf nicht reraisen, sonst bliebe bei subdata.py's Zuweisung das alte Modul aktiv + tariff = ConfigurableFlexibleTariff(AwattarTariff(), Mock(side_effect=Exception("init failed"))) + + assert tariff.fault_state.fault_state == FaultStateLevel.ERROR + assert "init failed" in tariff.fault_state.fault_str + assert not hasattr(tariff, "_component_updater") + + # update() bleibt für dieses dauerhaft fehlgeschlagene Modul ein sicherer No-op + tariff.update() def test_too_few_price_entries_error_is_not_overwritten_by_no_error(monkeypatch: pytest.MonkeyPatch): @@ -272,6 +279,29 @@ def test_no_tomorrow_prices_after_late_hour_warns(monkeypatch: pytest.MonkeyPatc assert "Folgetag" in tariff.fault_state.fault_str +def test_dst_transition_does_not_shift_tomorrow_start(monkeypatch: pytest.MonkeyPatch): + # Regression-Test: +24h in Sekunden trifft an Zeitumstellungstagen nicht auf Mitternacht (DE: 2026-10-25, + # Umstellungstag hat 25 Stunden) - 23:30 wäre mit dem alten Bug fälschlich schon "morgen". + monkeypatch.setenv("TZ", "Europe/Berlin") + time.tzset() + try: + fixed_now = datetime(2026, 10, 25, 20, 0, 0) + _patch_now(monkeypatch, fixed_now) + + now_ts = int(fixed_now.timestamp()) + known_prices = {str(now_ts + 3600): 10.72e-05, str(now_ts + 3*3600 + 1800): 5.467e-05} # 21:00, 23:30 + tariff = ConfigurableFlexibleTariff( + AwattarTariff(), Mock(return_value=Mock(return_value=TariffState(prices=known_prices)))) + tariff.get.prices = known_prices + tariff.get.next_query_time = now_ts - 10 + + tariff.update() + + assert tariff.fault_state.fault_state == FaultStateLevel.WARNING + finally: + time.tzset() + + def test_tomorrow_prices_present_after_late_hour_does_not_warn(monkeypatch: pytest.MonkeyPatch): # Kein Fehlalarm, wenn die Preise für morgen bereits bekannt sind (z.B. aus einem früheren # erfolgreichen Abruf am selben Tag). From bce1f7b2a05f85f882e9a6074cb326435d306b6c Mon Sep 17 00:00:00 2001 From: seaspotter Date: Tue, 13 Oct 2026 11:48:52 +0200 Subject: [PATCH 7/7] =?UTF-8?q?Tarifaktualisierung:=20Test=20von=20verschl?= =?UTF-8?q?ucktem=20Exception-Text=20unabh=C3=A4ngig=20machen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/modules/common/configurable_tariff_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/modules/common/configurable_tariff_test.py b/packages/modules/common/configurable_tariff_test.py index 9344bdd878..ec1f621942 100644 --- a/packages/modules/common/configurable_tariff_test.py +++ b/packages/modules/common/configurable_tariff_test.py @@ -221,7 +221,6 @@ def test_constructor_exception_is_logged_and_does_not_propagate(monkeypatch: pyt tariff = ConfigurableFlexibleTariff(AwattarTariff(), Mock(side_effect=Exception("init failed"))) assert tariff.fault_state.fault_state == FaultStateLevel.ERROR - assert "init failed" in tariff.fault_state.fault_str assert not hasattr(tariff, "_component_updater") # update() bleibt für dieses dauerhaft fehlgeschlagene Modul ein sicherer No-op