From 5f0c7a079cc44b2a7dc8de8a50bb36212df2bd1e Mon Sep 17 00:00:00 2001 From: seaspotter Date: Tue, 20 Oct 2026 21:40:27 +0200 Subject: [PATCH] Prognose: Heute/Morgen-Split nach Mitternacht korrekt halten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Split wurde bisher nur beim tatsächlichen Abruf (geplante Zeiten) neu berechnet und blieb danach bis zum nächsten Abruf exakt einen Tag falsch zugeordnet. Der Split läuft jetzt jeden Zyklus unabhängig vom Abruf. Zusätzlich Open-Meteo-Horizont 48h -> 60h, damit "morgen" direkt nach Mitternacht schon vollständig vorliegt (inkl. Marge für Zeitumstellung). --- .../modules/common/configurable_forecast.py | 2 + .../common/configurable_forecast_test.py | 21 ++++ packages/modules/common/store/_forecast.py | 3 +- .../modules/common/store/_forecast_test.py | 98 +++++++++++++++++++ .../modules/forecast/open_meteo/forecast.py | 3 +- .../forecast/open_meteo/forecast_test.py | 33 +++++++ 6 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 packages/modules/common/configurable_forecast_test.py create mode 100644 packages/modules/common/store/_forecast_test.py create mode 100644 packages/modules/forecast/open_meteo/forecast_test.py diff --git a/packages/modules/common/configurable_forecast.py b/packages/modules/common/configurable_forecast.py index 985d3378c0..33b31e535c 100644 --- a/packages/modules/common/configurable_forecast.py +++ b/packages/modules/common/configurable_forecast.py @@ -115,6 +115,8 @@ def _get_forecast_data(self): def update(self) -> None: force_update = self._is_force_update_requested() if not force_update and not self._is_update_due(): + # Heute/Morgen-Split hängt vom Kalendertag ab, daher jeden Zyklus neu berechnen + self.store.update() return try: trigger_mode = "manuell" if force_update else "geplant" diff --git a/packages/modules/common/configurable_forecast_test.py b/packages/modules/common/configurable_forecast_test.py new file mode 100644 index 0000000000..ca6fc57c1c --- /dev/null +++ b/packages/modules/common/configurable_forecast_test.py @@ -0,0 +1,21 @@ +from unittest.mock import Mock + +from modules.common.component_state import ForecastState +from modules.common.configurable_forecast import ConfigurableForecast +from modules.forecast.open_meteo.config import OpenMeteoForecast + + +def _make_forecast() -> ConfigurableForecast: + # strings=[] -> Konfiguration unvollständig, _is_update_due() bleibt False + return ConfigurableForecast(OpenMeteoForecast(), Mock(return_value=Mock(return_value=ForecastState()))) + + +def test_store_update_runs_even_when_no_fetch_is_due(): + forecast = _make_forecast() + forecast.store = Mock() + + forecast.update() + + forecast.store.update.assert_called_once() + forecast.store.set.assert_not_called() + forecast._component_updater.assert_not_called() diff --git a/packages/modules/common/store/_forecast.py b/packages/modules/common/store/_forecast.py index 8955c0f4db..35c9fb4d1b 100644 --- a/packages/modules/common/store/_forecast.py +++ b/packages/modules/common/store/_forecast.py @@ -34,7 +34,8 @@ def _filter_values_for_date(values: Dict[str, float], target_date) -> Dict[str, class ForecastValueStore(ValueStore[ForecastState]): def __init__(self): - pass + # erlaubt update() bereits vor dem ersten Abruf + self.state = ForecastState() def set(self, state: ForecastState) -> None: self.state = state diff --git a/packages/modules/common/store/_forecast_test.py b/packages/modules/common/store/_forecast_test.py new file mode 100644 index 0000000000..52ff788ccc --- /dev/null +++ b/packages/modules/common/store/_forecast_test.py @@ -0,0 +1,98 @@ +import time +from datetime import datetime +from typing import Optional as TypingOptional +from unittest.mock import Mock + +import pytest + +from control import data +from control.optional import Optional +from modules.common.component_state import ForecastState +from modules.common.store import _forecast +from modules.common.store._forecast import ForecastValueStore + + +@pytest.fixture(autouse=True) +def mock_data() -> None: + data.data_init(Mock()) + data.data.optional_data = Optional() + + +def _patch_now(monkeypatch: pytest.MonkeyPatch, fixed_now: datetime) -> None: + class FixedDateTime(datetime): + @classmethod + def now(cls, tz: TypingOptional[object] = None): + return fixed_now + + monkeypatch.setattr(_forecast, "datetime", FixedDateTime) + + +def _get() -> _forecast.ForecastValueStore: + return data.data.optional_data.data.forecast.get + + +def test_update_splits_by_current_calendar_day(monkeypatch: pytest.MonkeyPatch): + _patch_now(monkeypatch, datetime(2026, 10, 20, 8, 0, 0)) + values = { + str(int(datetime(2026, 10, 20, 10, 0, 0).timestamp())): 500.0, + str(int(datetime(2026, 10, 21, 10, 0, 0).timestamp())): 700.0, + } + store = ForecastValueStore() + store.set(ForecastState(forecast_values=values, daily_kwh={"2026-10-20": 3.5, "2026-10-21": 4.2})) + + store.update() + + assert data.data.optional_data.data.forecast.get.today_kwh == 3.5 + assert data.data.optional_data.data.forecast.get.tomorrow_kwh == 4.2 + assert list(data.data.optional_data.data.forecast.get.today_values.values()) == [500.0] + assert list(data.data.optional_data.data.forecast.get.tomorrow_values.values()) == [700.0] + + +def test_update_reruns_split_after_midnight_without_new_fetch(monkeypatch: pytest.MonkeyPatch): + # Regression-Test: derselbe (unveränderte) Datensatz muss nach Mitternacht korrekt neu + # zugeordnet werden, auch ohne dass zwischenzeitlich ein neuer Abruf stattgefunden hat. + values = { + str(int(datetime(2026, 10, 20, 10, 0, 0).timestamp())): 500.0, + str(int(datetime(2026, 10, 21, 10, 0, 0).timestamp())): 700.0, + } + daily_kwh = {"2026-10-20": 3.5, "2026-10-21": 4.2} + store = ForecastValueStore() + store.set(ForecastState(forecast_values=values, daily_kwh=daily_kwh)) + + _patch_now(monkeypatch, datetime(2026, 10, 20, 23, 50, 0)) + store.update() + assert data.data.optional_data.data.forecast.get.today_kwh == 3.5 + assert data.data.optional_data.data.forecast.get.tomorrow_kwh == 4.2 + + _patch_now(monkeypatch, datetime(2026, 10, 21, 0, 5, 0)) + store.update() + assert data.data.optional_data.data.forecast.get.today_kwh == 4.2 + assert data.data.optional_data.data.forecast.get.tomorrow_kwh == 0.0 + + +def test_update_safe_before_first_fetch(): + # ForecastValueStore() muss schon vor dem ersten set() aufrufbar sein (siehe + # ConfigurableForecast.update(), das update() auch ohne fälligen Abruf jeden Zyklus ruft) + ForecastValueStore().update() + + +def test_dst_transition_does_not_shift_day_boundary(monkeypatch: pytest.MonkeyPatch): + # Regression-Test: an Zeitumstellungstagen hat der lokale Tag 23 oder 25 Stunden - die + # Kalendertag-Zuordnung darf trotzdem nicht verrutschen. + monkeypatch.setenv("TZ", "Europe/Berlin") + time.tzset() + try: + values = { + str(int(datetime(2026, 10, 25, 23, 30, 0).timestamp())): 100.0, + str(int(datetime(2026, 10, 26, 0, 30, 0).timestamp())): 200.0, + } + store = ForecastValueStore() + store.set(ForecastState(forecast_values=values, daily_kwh={})) + + _patch_now(monkeypatch, datetime(2026, 10, 25, 20, 0, 0)) + store.update() + + assert list(data.data.optional_data.data.forecast.get.today_values.values()) == [100.0] + assert list(data.data.optional_data.data.forecast.get.tomorrow_values.values()) == [200.0] + finally: + time.tzset() diff --git a/packages/modules/forecast/open_meteo/forecast.py b/packages/modules/forecast/open_meteo/forecast.py index b83cb5d0b8..995883165b 100644 --- a/packages/modules/forecast/open_meteo/forecast.py +++ b/packages/modules/forecast/open_meteo/forecast.py @@ -9,7 +9,8 @@ from modules.forecast.open_meteo.config import OpenMeteoForecast, OpenMeteoForecastConfiguration -OPEN_METEO_FORECAST_HOURS = 48 +# > 48h, damit "morgen" nach Mitternacht schon vor dem ersten Abruf des neuen Tages vollständig vorliegt +OPEN_METEO_FORECAST_HOURS = 60 log = logging.getLogger("forecast") diff --git a/packages/modules/forecast/open_meteo/forecast_test.py b/packages/modules/forecast/open_meteo/forecast_test.py new file mode 100644 index 0000000000..279f5edaf0 --- /dev/null +++ b/packages/modules/forecast/open_meteo/forecast_test.py @@ -0,0 +1,33 @@ +from datetime import datetime, timedelta +from unittest.mock import Mock + +from modules.common import req +from modules.forecast.open_meteo import forecast +from modules.forecast.open_meteo.config import OpenMeteoForecastConfiguration + + +def test_horizon_covers_full_day_after_tomorrow_from_last_daily_fetch(): + # bei Abruf um 20 Uhr muss "übermorgen" (= "morgen" direkt nach Mitternacht, vor dem ersten + # Abruf des neuen Tages um 5 Uhr) noch vollständig enthalten sein + assert forecast.OPEN_METEO_FORECAST_HOURS >= 52 + + +def test_fetch_forecast_respects_horizon(monkeypatch): + start = datetime(2026, 10, 20, 0, 0, 0) + hours = forecast.OPEN_METEO_FORECAST_HOURS + 12 + times = [(start + timedelta(hours=i)).isoformat() for i in range(hours)] + radiation = [100.0] * hours + response = Mock() + response.json.return_value = {"hourly": {"time": times, "global_tilted_irradiance": radiation}} + session = Mock() + session.get.return_value = response + monkeypatch.setattr(req, "get_http_session", Mock(return_value=session)) + + config = OpenMeteoForecastConfiguration( + latitude=50.0, longitude=10.0, timezone="Europe/Berlin", + strings=[{"name": None, "peak_power_kw": 5.0, "tilt": 30.0, "azimuth": 180.0}], + ) + + values, _ = forecast.fetch_forecast(config) + + assert len(values) == forecast.OPEN_METEO_FORECAST_HOURS