mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-09 07:56:40 +00:00
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.
216 lines
8.6 KiB
Python
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))
|