mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 01:03:45 +01:00
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>
125 lines
4.6 KiB
Python
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
|