diff --git a/packages/modules/common/configurable_tariff.py b/packages/modules/common/configurable_tariff.py index 9093badf26..bf36c57cca 100644 --- a/packages/modules/common/configurable_tariff.py +++ b/packages/modules/common/configurable_tariff.py @@ -16,6 +16,12 @@ # 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 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 +LATE_WARNING_OFFSET_HOURS: int = 2 log = logging.getLogger(__name__) @@ -40,21 +46,27 @@ 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): + 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"): 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, 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.__finalize_update_status(timeslot_length_seconds, 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,36 +114,63 @@ 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 __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. ') + 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 __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 + 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.") + 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 + # 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: 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 6f06829450..ec1f621942 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 @@ -6,10 +7,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 +182,139 @@ 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: 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) + + 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: next_query_time muss auch nach einer Exception weitergesetzt werden + 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 + + +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 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): + # 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) + + 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 + 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) + + 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 + 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_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). + 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 = {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 + tariff.get.next_query_time = int(fixed_now.timestamp()) - 10 + + tariff.update() + + assert tariff.fault_state.fault_state == FaultStateLevel.NO_ERROR