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