Files
EOS/scripts/pvforecast_backtest.py
T
Andreas 6dc58c33e2 feat(pvforecast): keep outages out of the local provider's calibration
A battery or inverter failure limits PV to local demand for days. The
calibration read that as the plant's true output and learned it as a permanent
model loss, so one outage degraded the forecast long after the hardware was
fixed.

Calibration now estimates the healthy plant ratio over
`calibration_reference_days`, excludes days below `calibration_outage_threshold`
of it, and falls back to the most recent `calibration_min_healthy_days` when the
normal window is contaminated. `calibration_outage_filter_enabled` turns this
off for plants where measured curtailment, not available potential, is the
prediction target.

The fit also uses native 15-minute meter readings when every configured PV meter
supplies them - never interpolating hourly counters into an invented
quarter-hour profile - interpolates azimuth factors smoothly between bin centres
instead of stepping the EMS input curve, and normalizes the shape per forecast
day so it redistributes energy without changing that day's kWh correction. The
default azimuth bin widens from 15 to 45 degrees, which is what a typical
window actually supports.

Fixes the calibration window itself: it was derived from the measurement store
as a whole rather than from the configured PV production meters. A load meter
reaching further than the PV meter placed the window where no PV reading exists,
so calibration silently fell back to hourly fitting or skipped itself.

Also fixes `Measurement.load()`, which discarded every stored record. It
validated the file into a "temporary" Measurement, but Measurement is a
singleton, so that instance was the live one and the parsed records were
dropped.
2026-09-09 07:56:55 +02:00

216 lines
8.6 KiB
Python

#!.venv/bin/python
"""Backtest the local PV forecast model against measured PV production.
Answers the question "is my PV forecast actually any good, and does a different
configuration help?" without waiting for new forecasts to come true. Open-Meteo serves
past weather in the same request as the forecast, so every variant can be scored right
now against the meter readings EOS already holds.
The comparison runs on past intervals, where the Open-Meteo rows are analysed rather
than forecast weather. That isolates the error of the *PV model* (wrong peakpower,
soiling, shading the horizon profile misses) from the error of the *weather forecast*.
Only the former is systematic enough to fix by configuration.
Requires:
- ``general.latitude`` / ``general.longitude`` and ``pvforecast.planes`` configured
- ``measurement.pv_production_emr_keys`` configured and fed with cumulative PV
production meter readings [kWh]
Usage:
python scripts/pvforecast_backtest.py --days 30
python scripts/pvforecast_backtest.py --days 30 --tilt 88 --azimuth 175
"""
import argparse
import sys
from pathlib import Path
from typing import Any, Optional
import numpy as np
import pandas as pd
# Add the src directory to sys.path so import akkudoktoreos works in all cases
PROJECT_ROOT = Path(__file__).parent.parent
SRC_DIR = PROJECT_ROOT / "src"
sys.path.insert(0, str(SRC_DIR))
from akkudoktoreos.core.coreabc import get_config, get_measurement, singletons_init
from akkudoktoreos.prediction.pvforecastakkudoktorlocal import (
PVForecastAkkudoktorLocal,
PVForecastAkkudoktorLocalCommonSettings,
)
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
ENSEMBLE = ["icon_seamless", "ecmwf_ifs025", "gfs_seamless"]
# Variants scored against the meter. Each entry is a label plus the settings overrides
# applied on top of the configured provider settings.
VARIANTS: list[tuple[str, dict[str, Any]]] = [
(
"best_match",
{"weather_models": ["best_match"], "calibration_enabled": False},
),
("ensemble", {"weather_models": ENSEMBLE, "calibration_enabled": False}),
("ensemble + calibration", {"weather_models": ENSEMBLE, "calibration_enabled": True}),
(
"ensemble + calibration (global only)",
{
"weather_models": ENSEMBLE,
"calibration_enabled": True,
"calibration_azimuth_bin_degrees": 0,
},
),
(
"ensemble, isotropic sky",
{
"weather_models": ENSEMBLE,
"transposition_model": "isotropic",
"calibration_enabled": False,
},
),
(
"ensemble, no IAM",
{"weather_models": ENSEMBLE, "apply_iam": False, "calibration_enabled": False},
),
]
def score(modelled_kwh: np.ndarray, measured_kwh: np.ndarray) -> dict[str, float]:
"""Mean absolute error, bias and correlation over the daylight hours."""
error = modelled_kwh - measured_kwh
measured_total = measured_kwh.sum()
correlation = 0.0
if modelled_kwh.std() > 0 and measured_kwh.std() > 0:
correlation = float(np.corrcoef(modelled_kwh, measured_kwh)[0, 1])
return {
"mae": float(np.abs(error).mean()),
"rmse": float(np.sqrt((error**2).mean())),
"bias": float(error.mean()),
"bias_pct": float(100.0 * error.sum() / measured_total) if measured_total > 0 else 0.0,
"r": correlation,
"model_kwh": float(modelled_kwh.sum()),
"measured_kwh": float(measured_total),
}
def hourly_model(provider: PVForecastAkkudoktorLocal, data: Any, index: pd.DatetimeIndex) -> np.ndarray:
"""Run the chain and resample the AC power onto the measurement's hourly grid."""
frame = provider._forecast_frame(data)
if frame.empty:
return np.full(len(index), np.nan)
# `ac_power` is a mean power per interval, so an hourly mean in W is Wh per hour.
hourly = frame["ac_power"].resample("1h").mean().reindex(index)
return hourly.to_numpy(dtype=float) / 1000.0
def main(days: int, tilt: Optional[float], azimuth: Optional[float]) -> int:
singletons_init()
config = get_config()
measurement = get_measurement()
if measurement.max_datetime is None:
measurement.load()
if not config.measurement.pv_production_emr_keys:
print(
"measurement.pv_production_emr_keys is not configured - nothing to compare "
"against. Configure it and feed cumulative PV production readings [kWh]."
)
return 1
if not config.pvforecast.planes:
print("pvforecast.planes is not configured.")
return 1
if measurement.max_datetime is None:
print("No measurements stored yet.")
return 1
if tilt is not None:
config.pvforecast.planes[0].surface_tilt = tilt
if azimuth is not None:
config.pvforecast.planes[0].surface_azimuth = azimuth
end = measurement.max_datetime.start_of("hour")
start = end.subtract(days=days)
if start < measurement.min_datetime:
start = measurement.min_datetime.start_of("hour").add(hours=1)
measured_kwh = np.asarray(
measurement.pv_production_total_kwh(
start_datetime=start, end_datetime=end, interval=to_duration("1 hour")
),
dtype=float,
)
grid = pd.date_range(
start=pd.Timestamp(start.in_timezone("UTC").isoformat()),
periods=len(measured_kwh),
freq="1h",
)
print(f"Window: {start} .. {end} ({len(measured_kwh)} h)")
print(f"Measured PV production: {measured_kwh.sum():.1f} kWh")
provider = PVForecastAkkudoktorLocal(config=config, start_datetime=to_datetime())
baseline_settings = config.pvforecast.provider_settings.PVForecastAkkudoktorLocal
if baseline_settings is None:
baseline_settings = PVForecastAkkudoktorLocalCommonSettings()
common = {"past_days": min(days + 1, 92), "calibration_days": days}
rows = []
for label, overrides in VARIANTS:
settings = baseline_settings.model_copy(update={**common, **overrides})
config.pvforecast.provider_settings.PVForecastAkkudoktorLocal = settings
try:
data = provider._request_forecast(force_update=True)
modelled_kwh = hourly_model(provider, data, grid)
except Exception as exc: # noqa: BLE001 - one bad variant must not stop the rest
print(f" {label}: failed ({exc})")
continue
# Only score hours where both sides exist and something was actually produced.
usable = np.isfinite(modelled_kwh) & np.isfinite(measured_kwh)
usable &= (modelled_kwh > 0.05) | (measured_kwh > 0.05)
if usable.sum() < 12:
print(f" {label}: too few usable hours ({int(usable.sum())})")
continue
rows.append((label, score(modelled_kwh[usable], measured_kwh[usable]), int(usable.sum())))
if not rows:
print("No variant could be scored.")
return 1
print(f"\n{'variant':38s} {'MAE':>7s} {'RMSE':>7s} {'bias':>8s} {'bias%':>7s} {'r':>6s}")
print("-" * 78)
for label, result, _ in sorted(rows, key=lambda row: row[1]["mae"]):
print(
f"{label:38s} {result['mae']:7.3f} {result['rmse']:7.3f} "
f"{result['bias']:+8.3f} {result['bias_pct']:+6.1f}% {result['r']:6.3f}"
)
print("\nMAE/RMSE/bias in kWh per hour. Lower MAE is better; bias% is the total")
print("over- (+) or under-estimate (-) relative to the measured energy.")
print(
"\nNote: the calibrated variants are fitted on the same window they are scored\n"
"on, so their advantage here is optimistic. Re-run with a longer --days to see\n"
"how much of it survives."
)
print(
"Outage or curtailment periods are excluded from calibration but remain in these\n"
"scores, because the script cannot prove the plant's availability without an\n"
"explicit availability measurement."
)
best_label, best, hours = min(rows, key=lambda row: row[1]["mae"])
print(
f"\nBest: {best_label} - {best['model_kwh']:.1f} kWh modelled vs. "
f"{best['measured_kwh']:.1f} kWh measured over {hours} scored hours."
)
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Backtest the local PV forecast model.")
parser.add_argument("--days", type=int, default=30, help="Length of the window (default 30).")
parser.add_argument("--tilt", type=float, default=None, help="Override plane 0 surface_tilt.")
parser.add_argument(
"--azimuth", type=float, default=None, help="Override plane 0 surface_azimuth."
)
args = parser.parse_args()
sys.exit(main(args.days, args.tilt, args.azimuth))