Skip to content
71 changes: 55 additions & 16 deletions packages/modules/common/configurable_tariff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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)}")
Expand Down Expand Up @@ -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))
Expand Down
150 changes: 149 additions & 1 deletion packages/modules/common/configurable_tariff_test.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,27 @@
from datetime import datetime, tzinfo
import time
from typing import Optional
from unittest.mock import Mock
from helpermodules import timecheck
import pytest

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",
[
Expand Down Expand Up @@ -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
Loading