feat(pvforecast): local pvlib provider with measurement calibration

Add PVForecastAkkudoktorLocal, which runs the modelling chain inside EOS on
raw Open-Meteo irradiance instead of calling a forecast service: solar
position, horizon shading, plane transposition, incidence-angle modifier,
cell temperature, PVWatts DC and inverter AC.

It needs no API key and serves up to 16 days at 15-minute resolution from a
single hourly request, which is what keeps `optimization.tail_horizon_hours`
fed - services wrapping Open-Meteo cut the horizon much shorter. Several
Open-Meteo models can be listed in `weather_models` and are averaged per
variable at no extra request cost.

With `calibration_enabled` the provider fits itself against
`measurement.pv_production_emr_keys` over the past `calibration_days`: a
global scale factor plus optional per-solar-azimuth factors, each weighted by
modelled energy, shrunk toward the global factor by `calibration_prior_kwh`
and clamped to `[calibration_min_factor, calibration_max_factor]`. The
comparison runs on past intervals, where Open-Meteo serves analysed rather
than forecast weather, so it corrects the error of the PV model and not that
of the weather forecast.

Calibration is a scale factor on the output and never touches `userhorizon`,
`surface_tilt`, `surface_azimuth` or `peakpower`. The docs say so, and say
why a short window and a plant fault inside it are the two ways to end up
with a misleading factor.

Also add `Measurement.pv_production_total_kwh()` alongside the existing load
total, and `scripts/pvforecast_backtest.py`, which scores configuration
variants against the stored meter readings without waiting for new forecasts
to come true.
This commit is contained in:
Andreas
2026-09-06 18:21:20 +02:00
parent c0c9a1f669
commit f976335122
15 changed files with 1814 additions and 79 deletions
+7 -4
View File
@@ -27,6 +27,7 @@ from akkudoktoreos.prediction.prediction import (
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.prediction.pvforecastakkudoktorlocal import PVForecastAkkudoktorLocal
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
from akkudoktoreos.prediction.pvforecastsolcast import PVForecastSolcast
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
@@ -68,6 +69,7 @@ def forecast_providers():
PVForecastForecastSolar(),
PVForecastSolcast(),
PVForecastImport(),
PVForecastAkkudoktorLocal(),
WeatherBrightSky(),
WeatherClearOutside(),
WeatherOpenMeteo(),
@@ -126,10 +128,11 @@ def test_provider_sequence(prediction):
assert isinstance(prediction.providers[19], PVForecastForecastSolar)
assert isinstance(prediction.providers[20], PVForecastSolcast)
assert isinstance(prediction.providers[21], PVForecastImport)
assert isinstance(prediction.providers[22], WeatherBrightSky)
assert isinstance(prediction.providers[23], WeatherClearOutside)
assert isinstance(prediction.providers[24], WeatherOpenMeteo)
assert isinstance(prediction.providers[25], WeatherImport)
assert isinstance(prediction.providers[22], PVForecastAkkudoktorLocal)
assert isinstance(prediction.providers[23], WeatherBrightSky)
assert isinstance(prediction.providers[24], WeatherClearOutside)
assert isinstance(prediction.providers[25], WeatherOpenMeteo)
assert isinstance(prediction.providers[26], WeatherImport)
def test_provider_by_id(prediction, forecast_providers):