Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/modules/common/configurable_forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
21 changes: 21 additions & 0 deletions packages/modules/common/configurable_forecast_test.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion packages/modules/common/store/_forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions packages/modules/common/store/_forecast_test.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 2 additions & 1 deletion packages/modules/forecast/open_meteo/forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
33 changes: 33 additions & 0 deletions packages/modules/forecast/open_meteo/forecast_test.py
Original file line number Diff line number Diff line change
@@ -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
Loading