Files
EOS/tests/test_pvakkuadaptresponse.py
T
f804d48072 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>
2026-11-03 15:31:32 +01:00

125 lines
4.6 KiB
Python

"""Causal solar response, model weather weighting and frozen-candidate selection."""
import numpy as np
import pandas as pd
from akkudoktoreos.prediction.pvakkuadaptresponse import (
fit_solar_response,
fit_weather_response,
paired_rows,
select_responses,
solar_basis,
solar_factors,
)
def test_solar_response_learns_repeatable_shape_without_learning_common_cloud_error():
index = pd.date_range("2026-09-01T06:00Z", "2026-09-10T16:00Z", freq="15min")
index = index[(index.hour >= 6) & (index.hour < 17)]
basis = solar_basis(index, 51.0, 7.4)
shade = 1 - 0.35 * basis[:, 5:20].sum(axis=1)
clouds = 0.55 + 0.4 * (np.sin(np.arange(len(index)) * 0.4) + 1) / 2
history = pd.DataFrame(
dict(
base=1000,
actual=1000 * clouds * shade,
common=clouds,
available_at=index + pd.Timedelta(minutes=20),
),
index=index,
)
cutoff = pd.Timestamp("2026-09-11T00:00Z")
fit = fit_solar_response(history, cutoff, "Europe/Berlin", 51.0, 7.4, 5)
assert fit["fitted"] and fit["days"] >= 5
future = pd.date_range("2026-09-12T06:00Z", periods=40, freq="15min")
future_basis = solar_basis(future, 51.0, 7.4)
truth = 1 - 0.35 * future_basis[:, 5:20].sum(axis=1)
predicted = solar_factors(future_basis, fit)
assert np.mean(abs(predicted - truth)) < np.mean(abs(1 - truth)) * 0.6
history["actual"] = 1000 * clouds
neutral = fit_solar_response(history, cutoff, "Europe/Berlin", 51.0, 7.4, 5)
assert np.allclose(solar_factors(future_basis, neutral), 1)
history["available_at"] = cutoff + pd.Timedelta(days=1)
assert not fit_solar_response(history, cutoff, "Europe/Berlin", 51.0, 7.4, 5)["fitted"]
def test_response_validation_needs_independent_days_and_does_not_reward_frequency():
pairs = [
dict(
bucket="6-24h",
day=f"2026-09-0{day}",
actual=1000.0,
calibrated=1300.0,
solar=1100.0,
weather=1050.0,
)
for day in range(1, 5)
for _ in range(10)
]
choice = select_responses(pairs, 3, 32, 0.02)["6-24h"]
assert choice["method"] == "weather"
assert choice["validated"] and choice["days"] == 4
assert select_responses(pairs[:10] * 50, 3, 32, 0.02)["6-24h"]["method"] == "calibrated"
assert select_responses(pairs, 3, 32, 0.02, ("calibrated",))["6-24h"]["method"] == "calibrated"
# A better MAE alone must not worsen energy bias.
pairs = [
dict(
bucket="6-24h",
day=f"2026-09-0{day}",
actual=1000.0,
calibrated=1000.0 + sign * 300,
solar=1100.0,
weather=1100.0,
)
for day in range(1, 5)
for sign in (-1, 1)
for _ in range(5)
]
assert select_responses(pairs, 3, 32, 0.02)["6-24h"]["method"] == "calibrated"
def test_paired_rows_reject_late_missing_and_curtailment_labels_and_deduplicate():
time = pd.Timestamp("2026-09-01T12:00Z")
truth = pd.DataFrame(
dict(
micro=[1000.0], micro_eligible=[True], micro_available=[time + pd.Timedelta(minutes=20)]
),
index=[time],
)
row = dict(
target_ms=int(time.timestamp() * 1000),
issued_ms=int((time - pd.Timedelta(hours=4)).timestamp() * 1000),
planes=dict(micro=dict(calibrated=1200.0, solar=1100.0, weather=1000.0, sky=1500.0)),
members=dict(icon_d2=dict(micro=None)),
response_version=1,
)
cutoff = pd.Timestamp("2026-09-02T00:00Z")
paired = paired_rows([row] * 20, truth, ["micro"], cutoff, "Europe/Berlin")
assert len(paired) == 1
assert np.isnan(paired[0]["members"]["icon_d2"])
assert not fit_weather_response(paired, 3, 32)["2-6h"]["fitted"]
truth["micro_eligible"] = False
assert paired_rows([row], truth, ["micro"], cutoff, "Europe/Berlin") == []
truth["micro_eligible"] = True
truth["micro_available"] = cutoff + pd.Timedelta(days=1)
assert paired_rows([row], truth, ["micro"], cutoff, "Europe/Berlin") == []
def test_weather_weights_are_trained_by_horizon_on_matching_members():
pairs = [
dict(
bucket="6-24h",
day=f"2026-09-0{day}",
actual=500.0,
sky=1500.0,
members=dict(icon_d2=600.0, icon_seamless=1000.0),
)
for day in range(1, 6)
for _ in range(10)
]
fitted = fit_weather_response(pairs, 5, 32)
assert fitted["6-24h"]["fitted"]
assert fitted["6-24h"]["weights"]["icon_d2"] > fitted["6-24h"]["weights"]["icon_seamless"]
assert not fitted["24-48h"]["fitted"]
assert abs(sum(fitted["6-24h"]["weights"].values()) - 1) < 1e-9