fix(pvforecast): normalize Forecast.Solar power before resampling (#1487)

Forecast.Solar supplies instantaneous power with irregular sunrise/sunset timestamps. GENETIC interprets stored values as interval averages and infers cadence from the shortest gap, so these samples create artificial coverage gaps. The prediction list API also averages away the sunset zero before interpolating, producing positive nighttime PV.

Integrate each roof plane's piecewise-linear power curve into UTC-contiguous 15-minute interval-average W before summing planes. Both hourly and quarter-hour GENETIC slots then consume the same normalized data as the prediction API, with exactly one downstream W-to-Wh conversion. Explicit sunrise/sunset zero boundaries establish the adjacent night within each forecast day. Daylight gaps longer than an hour, invalid power, absent planes and unavailable forecast days remain missing; the strict GENETIC coverage check stays in place.

Refreshes replace the current forecast and remove stale tails. Because cadence detection also sees historical records, the first refresh clears legacy forecasts containing non-aligned timestamps, including their history. Later refreshes retain normalized history. This upgrade behavior is documented.

Forecast validation errors now identify the first invalid interval and the number of invalid slots.
This commit is contained in:
Normann
2026-11-07 19:46:58 +01:00
committed by GitHub
parent 651301f9e6
commit b33953d445
5 changed files with 359 additions and 26 deletions
+79 -2
View File
@@ -1,7 +1,7 @@
"""Configuration-owned requests and the real automatic GENETIC path."""
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from unittest.mock import AsyncMock, Mock, patch
import httpx
import numpy as np
@@ -21,6 +21,7 @@ from akkudoktoreos.optimization.optimization import (
OptimizationAlgorithm,
OptimizationCommonSettings,
)
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
from akkudoktoreos.utils.datetimeutil import to_datetime
@@ -90,6 +91,80 @@ def configured_request(config_eos, monkeypatch):
return config_eos, ems, measurement, data
@pytest.mark.asyncio
@pytest.mark.parametrize("interval", [900, 3600])
async def test_forecastsolar_resolves_without_runtime_override(
configured_request, monkeypatch, interval
):
config, ems, _, data = configured_request
config.merge_settings_from_dict(
{
"general": {"latitude": 52.5, "longitude": 13.4, "timezone": "Europe/Berlin"},
"prediction": {"hours": 48},
"optimization": {"genetic": {"interval_sec": interval, "horizon_hours": 24}},
"pvforecast": {
"provider": "PVForecastForecastSolar",
"planes": [
{"surface_tilt": 45, "surface_azimuth": 217, "peakpower": 8.05},
],
},
}
)
ems.start_datetime = to_datetime("2026-11-02T10:00:00+01:00", in_timezone="Europe/Berlin")
ems.observation_datetime = ems.start_datetime
data["forecasts"] = {}
pv = PVForecastForecastSolar(config=config.load)
watts = {
f"2026-11-{day} {time}": power
for day in ("02", "03")
for time, power in {
"07:16:18": 0,
"08:00:00": 1000,
"09:00:00": 1000,
"10:00:00": 1000,
"11:00:00": 1000,
"12:00:00": 1000,
"13:00:00": 1000,
"14:00:00": 1000,
"15:00:00": 1000,
"16:00:00": 1000,
"16:55:54": 0,
}.items()
}
async def read(key, **kwargs):
if key == "pvforecast_ac_power":
return await pv.key_to_raw_series(key, **kwargs)
value = {
"loadforecast_power_w": 2000,
"elecprice_marketprice_wh": 0.0003,
"feed_in_tariff_wh": 0.00008,
}[key]
return pd.Series(
[value] * 72, index=pd.date_range("2026-11-02T00:00:00+01:00", periods=72, freq="h")
)
async def update():
# Isolate this synthetic response from the provider's on-disk cache.
await pv._update_data(force_update=True)
prediction = SimpleNamespace(update_data=AsyncMock(side_effect=update), key_to_raw_series=read)
monkeypatch.setattr(ConfigOptimizationRequest, "prediction", prediction)
response = Mock()
response.json.return_value = {
"result": {"watts": watts},
"message": {"info": {"timezone": "Europe/Berlin"}},
}
with patch("requests.get", return_value=response):
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
prediction.update_data.assert_awaited_once()
energy = parameters.ems.pv_forecast_wh
assert energy[10 * 3600 // interval] == pytest.approx(1000 * interval / 3600)
assert energy[17 * 3600 // interval : 24 * 3600 // interval] == [0] * (7 * 3600 // interval)
assert np.isfinite(energy[10 * 3600 // interval : 34 * 3600 // interval]).all()
await pv.delete_by_datetime()
@pytest.mark.asyncio
async def test_hardware_costs_and_state_are_resolved_without_config_changes(configured_request):
config, _, _, data = configured_request
@@ -208,8 +283,10 @@ async def test_control_gaps_fail_but_shorter_tail_is_accepted(configured_request
parameters = await ConfigOptimizationRequest.model_validate(data).resolve()
assert len(parameters.ems.total_load) == 50
data["forecasts"]["electricity_price_per_wh"][42] = np.nan
with pytest.raises(ValueError, match="control horizon"):
with pytest.raises(ValueError, match="control horizon") as error:
await ConfigOptimizationRequest.model_validate(data).resolve()
assert "2026-09-12T10:30:00+00:00, 2026-09-12T10:45:00+00:00" in str(error.value)
assert "1 invalid interval(s)" in str(error.value)
@pytest.mark.asyncio
+170 -4
View File
@@ -1,10 +1,14 @@
import math
from unittest.mock import call, patch
import numpy as np
import pendulum
import pytest
import requests
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
def _config(config_eos, planes=None, api_key=None):
@@ -55,10 +59,14 @@ async def test_update_data_resolves_tz_and_sets_power(pvforecast_instance):
"watts": {"2025-01-01 12:00:00": 1200.0, "2025-01-01 13:00:00": 1500.0},
}
with patch.object(pvforecast_instance, "_request_forecast", return_value=body), \
patch.object(PVForecastForecastSolar, "update_value") as mock_update:
patch.object(PVForecastForecastSolar, "update_value") as mock_update, \
patch.object(PVForecastForecastSolar, "delete_by_datetime") as mock_delete:
await pvforecast_instance._update_data()
assert mock_update.call_count == 2
mock_delete.assert_awaited_once_with(
start_datetime=to_datetime("2025-01-01T12:00:00+01:00")
)
expected = [
call(
pendulum.datetime(2025, 1, 1, 12, 0, tz="Europe/Berlin"),
@@ -105,13 +113,13 @@ def test_request_forecast_sums_planes(config_eos):
config=config_eos.load, start_datetime=pendulum.datetime(2025, 1, 1, tz="UTC")
)
responses = [
_http({"2025-01-01 12:00:00": 1000.0}),
_http({"2025-01-01 12:00:00": 800.0}),
_http({"2025-01-01 12:00:00": 1000.0, "2025-01-01 13:00:00": 1000.0}),
_http({"2025-01-01 12:00:00": 800.0, "2025-01-01 13:00:00": 800.0}),
]
with patch("requests.get", side_effect=responses) as mock_get:
body = pv._request_forecast(force_update=True) # type: ignore
assert mock_get.call_count == 2
assert body["watts"]["2025-01-01 12:00:00"] == 1800.0
assert body["watts"]["2025-01-01T11:00:00+00:00"] == 1800.0
@pytest.mark.asyncio
@@ -129,3 +137,161 @@ def test_request_forecast_raises_on_http_error(pvforecast_instance):
with pytest.raises(RuntimeError) as exc_info:
pvforecast_instance._request_forecast(force_update=True)
assert "Failed to fetch pvforecast from Forecast.Solar" in str(exc_info.value)
def _day_watts(day="2026-11-02"):
"""The irregular sunrise/sunset samples reported in #1485."""
return {
f"{day} {time}": power
for time, power in {
"07:16:18": 0.0,
"08:00:00": 84.0,
"09:00:00": 292.0,
"10:00:00": 709.0,
"11:00:00": 1225.0,
"12:00:00": 1296.0,
"13:00:00": 1289.0,
"14:00:00": 1894.0,
"15:00:00": 2509.0,
"16:00:00": 1777.0,
"16:55:54": 0.0,
}.items()
}
def test_instantaneous_power_is_integrated_and_nights_are_zero(pvforecast_instance):
averages = pvforecast_instance._interval_power(_day_watts(), "Europe/Berlin")
assert len(averages) == 96
assert all(math.isfinite(value) for value in averages.values())
at = lambda time: int(to_datetime(f"2026-11-02T{time}+01:00").timestamp())
# A linear ramp 84 -> 292 W over an hour has 110 W average in its first quarter.
assert averages[at("08:00:00")] == pytest.approx(110.0)
# Integrating into Wh must conserve the entire daylight curve's energy.
points = [
(int(to_datetime(ts, in_timezone="Europe/Berlin").timestamp()), p)
for ts, p in _day_watts().items()
]
expected_wh = sum((b - a) * (pa + pb) / 7200 for (a, pa), (b, pb) in zip(points, points[1:]))
assert sum(averages.values()) / 4 == pytest.approx(expected_wh)
assert all(
value == 0
for stamp, value in averages.items()
if stamp < at("07:00:00") or stamp >= at("17:00:00")
)
@pytest.mark.parametrize("change", ["missing", "invalid", "negative", "infinite"])
def test_bad_daylight_coverage_remains_missing(pvforecast_instance, change):
watts = _day_watts()
key = "2026-11-02 12:00:00"
if change == "missing":
del watts[key]
else:
watts[key] = {"invalid": "bad", "negative": -1, "infinite": math.inf}[change]
averages = pvforecast_instance._interval_power(watts, "Europe/Berlin")
at = int(to_datetime("2026-11-02T11:00:00+01:00").timestamp())
assert all(math.isnan(averages[at + i * 900]) for i in range(8))
assert averages[at + 8 * 900] == pytest.approx((1289 * 7 + 1894) / 8)
def test_partial_days_and_missing_days_are_not_invented(pvforecast_instance):
watts = {"2026-11-02 12:00:00": 1000, "2026-11-02 13:00:00": 1000, "2026-11-04 12:00:00": 1000}
averages = pvforecast_instance._interval_power(watts, "Europe/Berlin")
assert len(averages) == 192
assert sum(math.isfinite(value) for value in averages.values()) == 4
assert max(averages) < to_datetime("2026-11-05T00:00:00+01:00").timestamp()
@pytest.mark.parametrize("day,hours", [("2026-03-29", 23), ("2026-10-25", 25)])
def test_dst_days_have_elapsed_time_slots(pvforecast_instance, day, hours):
averages = pvforecast_instance._interval_power(_day_watts(day), "Europe/Berlin")
assert len(averages) == hours * 4
assert all(math.isfinite(value) for value in averages.values())
assert np.diff(sorted(averages)).tolist() == [900] * (hours * 4 - 1)
@pytest.mark.asyncio
@pytest.mark.parametrize("interval", [900, 3600])
async def test_provider_to_genetic_and_prediction_list(pvforecast_instance, interval):
"""Exercise real persistence/resampling, including cleanup of pre-fix samples."""
pv = pvforecast_instance
start = to_datetime("2026-11-02T00:00:00+01:00", in_timezone="Europe/Berlin")
# Upgrade/refresh must remove the irregular sunset and an old forecast tail.
await pv.update_value(
start.subtract(days=1).add(hours=16, minutes=55, seconds=54), "pvforecast_ac_power", 0.0
)
await pv.update_value(start.add(hours=16, minutes=55, seconds=54), "pvforecast_ac_power", 0.0)
await pv.update_value(start.add(days=3), "pvforecast_ac_power", 123.0)
watts = _day_watts() | _day_watts("2026-11-03")
with patch("requests.get", return_value=_http(watts)):
await pv._update_data(force_update=True)
raw = await pv.key_to_raw_series("pvforecast_ac_power", dropna=False)
assert len(raw) == 192
assert np.diff(raw.index.as_unit("ns").asi8 / 1e9).tolist() == [900] * 191
values = await bounded_forecast_array(
pv,
key="pvforecast_ac_power",
start_datetime=start,
end_datetime=start.add(days=2),
interval=to_duration(interval),
)
assert np.isfinite(values).all()
listed = await pv.key_to_array(
"pvforecast_ac_power",
start_datetime=start,
end_datetime=start.add(days=2),
interval=to_duration(interval),
)
assert listed == pytest.approx(values)
assert values[17 * 3600 // interval : 24 * 3600 // interval].tolist() == [0.0] * (
7 * 3600 // interval
)
# Preserve the missing tail instead of extrapolating zeros into a third day.
tail = await bounded_forecast_array(
pv,
key="pvforecast_ac_power",
start_datetime=start.add(days=2),
end_datetime=start.add(days=2, hours=1),
interval=to_duration(interval),
)
assert np.isnan(tail).all()
# Once migrated, a normal refresh retains already normalized history.
with patch("requests.get", return_value=_http(_day_watts("2026-11-03"))):
await pv._update_data(force_update=True)
raw = await pv.key_to_raw_series("pvforecast_ac_power", dropna=False)
assert len(raw) == 192
await pv.delete_by_datetime()
def test_planes_are_integrated_before_summing(config_eos):
_config(
config_eos,
planes=[
{"surface_tilt": 30, "surface_azimuth": 90, "peakpower": 3},
{"surface_tilt": 30, "surface_azimuth": 270, "peakpower": 3},
],
)
pv = PVForecastForecastSolar(config=config_eos.load)
first = {
"2026-11-02 07:10:00": 0,
"2026-11-02 08:00:00": 1000,
"2026-11-02 09:00:00": 1000,
"2026-11-02 09:45:00": 0,
}
second = {
"2026-11-02 07:25:00": 0,
"2026-11-02 08:00:00": 500,
"2026-11-02 09:00:00": 500,
"2026-11-02 09:55:00": 0,
}
expected = pv._interval_power(first, "Europe/Berlin")
other = pv._interval_power(second, "Europe/Berlin")
with patch("requests.get", side_effect=[_http(first), _http(second)]):
body = pv._request_forecast(force_update=True) # type: ignore[call-arg]
for ts, value in body["watts"].items():
stamp = int(to_datetime(ts).timestamp())
assert value == pytest.approx(expected[stamp] + other[stamp])
# An absent plane may not silently produce the other plane's forecast.
with patch("requests.get", side_effect=[_http(first), _http({})]):
body = pv._request_forecast(force_update=True) # type: ignore[call-arg]
assert all(math.isnan(value) for value in body["watts"].values())