mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
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:
co-authored by
Normann
Christin
Claude Opus 5.5
YMT
sarankumar1325
parent
298d46a527
commit
f804d48072
@@ -0,0 +1,124 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user