feat(pvforecast): add adaptive per-plane PV learning and diagnostics (#1422)

PV arrays with different orientations and curtailment need separate plant calibration and weather correction. This opt-in provider publishes normal EOS AC/DC forecast keys at 15-minute resolution.

- Preserve daily per-plane energy/shape calibration and validated short-term local/trend selection. Curtailment-sensitive arrays require a fresh release flag or an explicitly configured learning guard.
-  Add switchable empirical solar-angle response relative to separate eligible reference arrays, removing their common weather error. This learns angular residuals, not physical angles or a measured geometric horizon.
-  Archive actually received weather model inputs, first local receipt and frozen per-member PV candidates. Returned past weather is excluded; unknown source issuance remains explicit. Learn model weights and forecast-regime bias separately at 2–6, 6–24 and 24–48 hours.
- New long-horizon candidates remain in shadow until their genuinely issued outputs improve equal-day MAE by the configured margin, win on at least 60% of validation days and pass a matched-support energy-bias guard. Insufficient evidence retains calibration. Daily yields still require complete local days, including DST.
- Expose fitted parameters, independent training/validation day counts, errors, selected methods and persistence in the existing status API, with additional normal prediction keys for Grafana. Retain physical DC through angular/weather corrections an  enforce individual inverter AC limits.

Co-authored-by: Normann <github@koldrack.com>
Co-authored-by: Christin <chrissi@von-perbandt.de>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: YMT <yn276364@dal.ca>
Co-authored-by: sarankumar1325 <120080287+sarankumar1325@users.noreply.github.com>
This commit is contained in:
Andreas
2026-11-03 15:31:32 +01:00
committed by GitHub
co-authored by Normann Christin Claude Opus 5.5 YMT sarankumar1325
parent 298d46a527
commit f804d48072
24 changed files with 6063 additions and 13 deletions
+137
View File
@@ -0,0 +1,137 @@
"""Group/horizon selection on genuinely issued, independent-day predictions."""
import numpy as np
import pandas as pd
import pytest
from akkudoktoreos.prediction.pvakkuadaptquality import (
choose_correction,
select_corrections,
)
def archive():
rows, index = [], []
for day in pd.date_range("2026-09-01T08:00Z", periods=4, freq="D"):
for slot in range(12):
time = day + pd.Timedelta(minutes=slot * 15)
index.append(time)
for lead in (7, 52):
rows.append(
dict(
target_ms=int(time.timestamp() * 1000),
issue_ms=int((time - pd.Timedelta(minutes=lead)).timestamp() * 1000),
lead_minutes=lead,
groups={
"roof": {
"calibrated": 1000,
"local": 800 if lead == 7 else 1100,
"trend": 700 if lead == 7 else 1200,
}
},
)
)
observed = pd.DataFrame(
dict(
renamed_sensor=700,
renamed_sensor_eligible=True,
renamed_sensor_available=pd.DatetimeIndex(index) + pd.Timedelta(minutes=20),
),
index=pd.DatetimeIndex(index),
)
return rows, observed
def test_selects_per_group_and_horizon_with_no_site_names():
rows, observed = archive()
result = select_corrections(
rows, observed, {"roof": ["renamed_sensor"]}, pd.Timestamp("2026-09-06T00:00Z"), "UTC"
)
assert result["roof"]["15"]["method"] == "trend"
assert result["roof"]["60"]["method"] == "calibrated"
assert result["roof"]["15"]["days"] == 4
@pytest.mark.parametrize("problem", ["future_labels", "curtailed", "missing", "today"])
def test_missing_censored_or_unavailable_truth_cannot_select_complex_model(problem):
rows, observed = archive()
cutoff = pd.Timestamp("2026-09-06T00:00Z")
if problem == "future_labels":
observed.renamed_sensor_available = cutoff + pd.Timedelta(minutes=1)
if problem == "curtailed":
observed.renamed_sensor_eligible = False
if problem == "missing":
observed.renamed_sensor = np.nan
if problem == "today":
cutoff = pd.Timestamp("2026-09-01T12:00Z")
assert (
select_corrections(rows, observed, {"roof": ["renamed_sensor"]}, cutoff, "UTC")["roof"]
== {}
)
def test_repeated_polling_cannot_manufacture_sample_size():
rows, observed = archive()
rows = [r for r in rows if r["target_ms"] == rows[0]["target_ms"]] * 1000
result = select_corrections(
rows, observed, {"roof": ["renamed_sensor"]}, pd.Timestamp("2026-09-06T00:00Z"), "UTC"
)
assert result["roof"]["15"]["samples"] == 1
assert result["roof"]["15"]["method"] == "calibrated"
def test_unknown_group_and_insufficient_history_preserve_calibration():
rows, observed = archive()
result = select_corrections(
rows,
observed,
{"different_roof": ["renamed_sensor"]},
pd.Timestamp("2026-09-06T00:00Z"),
"UTC",
)
assert result["different_roof"] == {}
def test_choice_expires_after_two_hours_and_cold_start_is_simple():
issue = pd.Timestamp("2026-09-06T08:00Z")
idx = pd.date_range(issue + pd.Timedelta(minutes=15), periods=12, freq="15min")
base = pd.Series(1000.0, index=idx)
local = base * 0.8
trend = base * 0.7
selected = choose_correction(
base, local, trend, issue, {"15": {"method": "trend"}, "60": {"method": "local"}}
)
assert selected.iloc[0] == 700
assert selected.iloc[3] == 800
assert selected.iloc[-1] == 1000
pd.testing.assert_series_equal(choose_correction(base, local, trend, issue, {}), base)
def test_legacy_archive_can_only_validate_explicit_reference_sum():
rows, observed = archive()
for r in rows:
r.pop("groups")
r.update(reference_calibrated_w=1000, reference_local_w=800, reference_adaptive_w=700)
result = select_corrections(
rows,
observed,
{"roof": ["renamed_sensor"], "other": ["renamed_sensor"]},
pd.Timestamp("2026-09-06T00:00Z"),
"UTC",
reference_group="roof",
)
assert result["roof"]["15"]["method"] == "trend"
assert result["other"] == {}
def test_disabled_trend_candidate_cannot_be_selected():
rows, observed = archive()
result = select_corrections(
rows,
observed,
{"roof": ["renamed_sensor"]},
pd.Timestamp("2026-09-06T00:00Z"),
"UTC",
enabled_methods=("calibrated", "local"),
)
assert result["roof"]["15"]["method"] == "local"