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,137 @@
|
||||
"""Causal trend features, online label availability and trend generalization."""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.prediction.pvakkuadaptmodel import apply_local_nowcast
|
||||
from akkudoktoreos.prediction.pvakkuadapttrend import (
|
||||
FEATURES,
|
||||
LocalPVTrendModel,
|
||||
local_trend_features,
|
||||
)
|
||||
|
||||
|
||||
def case():
|
||||
issue = pd.Timestamp("2026-09-18T12:05:00+02:00")
|
||||
past = pd.date_range(issue.floor("15min") - pd.Timedelta(hours=1), periods=4, freq="15min")
|
||||
hist = pd.DataFrame(
|
||||
dict(
|
||||
measured_power=[800, 900, 1100, 1500],
|
||||
calibrated_power=1200.0,
|
||||
clear_sky_power=2000.0,
|
||||
eligible=True,
|
||||
available_at=past + pd.Timedelta(minutes=20),
|
||||
),
|
||||
index=past,
|
||||
)
|
||||
future = pd.date_range(issue.ceil("15min"), periods=10, freq="15min")
|
||||
return issue, hist, pd.Series(1200.0, index=future), pd.Series(2000.0, index=future)
|
||||
|
||||
|
||||
def test_features_ignore_future_actuals_and_describe_a_rising_weather_residual():
|
||||
issue, hist, pred, sky = case()
|
||||
expected = local_trend_features(hist, pred, sky, issue)
|
||||
assert expected.ramp.iloc[0] > 0
|
||||
assert expected.slope.iloc[0] > 0
|
||||
assert expected.feature_available_at.iloc[0] <= issue
|
||||
hist.loc[issue.ceil("15min")] = [999999, 1200, 2000, True, issue + pd.Timedelta(hours=1)]
|
||||
pd.testing.assert_frame_equal(local_trend_features(hist, pred, sky, issue), expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["gap", "quality", "nan", "stale", "dim"])
|
||||
def test_invalid_latest_reference_never_produces_a_trend(bad):
|
||||
issue, hist, pred, sky = case()
|
||||
if bad == "gap":
|
||||
hist = hist.drop(hist.index[1])
|
||||
elif bad == "quality":
|
||||
hist.loc[hist.index[-1], "eligible"] = False
|
||||
elif bad == "nan":
|
||||
hist.loc[hist.index[-1], "measured_power"] = np.nan
|
||||
elif bad == "stale":
|
||||
hist.index -= pd.Timedelta(hours=1)
|
||||
hist.available_at -= pd.Timedelta(hours=1)
|
||||
else:
|
||||
hist.loc[hist.index[-1], "clear_sky_power"] = 50
|
||||
assert local_trend_features(hist, pred, sky, issue)[list(FEATURES)].isna().all().all()
|
||||
|
||||
|
||||
def training():
|
||||
rng = np.random.default_rng(42)
|
||||
issue = pd.date_range("2026-09-01T08:05:00Z", periods=200, freq="1h")
|
||||
frame = pd.DataFrame(rng.uniform(-0.2, 0.2, (len(issue), len(FEATURES))), columns=FEATURES)
|
||||
frame["intercept"] = 1.0
|
||||
frame["issue"] = issue
|
||||
frame["time"] = issue + pd.Timedelta(minutes=10)
|
||||
frame["lead_minutes"] = 10.0
|
||||
frame["target_available_at"] = issue + pd.Timedelta(minutes=30)
|
||||
frame["feature_available_at"] = issue
|
||||
frame["reference_sky_w"] = 2000.0
|
||||
frame["target_residual"] = 0.7 * frame.level + 0.8 * frame.ramp
|
||||
return frame
|
||||
|
||||
|
||||
def test_model_learns_slope_on_unseen_samples_while_level_only_cannot():
|
||||
samples = training()
|
||||
cutoff = pd.Timestamp("2026-09-15T00:00:00Z")
|
||||
trend = LocalPVTrendModel(ridge=0.01).fit(samples.iloc[:160], cutoff)
|
||||
level = LocalPVTrendModel(use_trend=False, ridge=0.01).fit(samples.iloc[:160], cutoff)
|
||||
test = samples.iloc[160:]
|
||||
trend_error = np.abs(
|
||||
test[list(trend.feature_names)] @ trend.coefficients[15] - test.target_residual
|
||||
)
|
||||
level_error = np.abs(
|
||||
test[list(level.feature_names)] @ level.coefficients[15] - test.target_residual
|
||||
)
|
||||
assert trend_error.mean() < 0.1 * level_error.mean()
|
||||
|
||||
|
||||
def test_scaling_allows_learning_small_normalized_features_with_regularization():
|
||||
samples = training()
|
||||
cutoff = pd.Timestamp("2026-09-15T00:00:00Z")
|
||||
model = LocalPVTrendModel(ridge=20, scale_features=True).fit(samples.iloc[:160], cutoff)
|
||||
test = samples.iloc[160:]
|
||||
error = np.abs(test[list(model.feature_names)] @ model.coefficients[15] - test.target_residual)
|
||||
assert error.mean() < 0.02
|
||||
|
||||
|
||||
def test_later_labels_and_later_feature_arrivals_cannot_change_coefficients():
|
||||
samples = training()
|
||||
cutoff = pd.Timestamp("2026-09-15T00:00:00Z")
|
||||
expected = LocalPVTrendModel().fit(samples, cutoff).coefficients[15]
|
||||
poison = samples.copy()
|
||||
poison.target_residual = 1.5
|
||||
poison.target_available_at = cutoff + pd.Timedelta(minutes=1)
|
||||
combined = pd.concat([samples, poison])
|
||||
np.testing.assert_allclose(LocalPVTrendModel().fit(combined, cutoff).coefficients[15], expected)
|
||||
poison.target_available_at = poison.time + pd.Timedelta(minutes=20)
|
||||
poison.feature_available_at = poison.issue + pd.Timedelta(minutes=1)
|
||||
combined = pd.concat([samples, poison])
|
||||
np.testing.assert_allclose(LocalPVTrendModel().fit(combined, cutoff).coefficients[15], expected)
|
||||
|
||||
|
||||
def test_today_targets_do_not_train_and_cold_start_preserves_old_correction():
|
||||
issue, hist, pred, sky = case()
|
||||
model = LocalPVTrendModel().fit(training().iloc[:50], issue)
|
||||
assert not model.coefficients
|
||||
features = local_trend_features(hist, pred, sky, issue)
|
||||
fallback = apply_local_nowcast(pred, sky, hist, issue)
|
||||
pd.testing.assert_series_equal(model.predict(pred, sky, features, issue, fallback), fallback)
|
||||
samples = training()
|
||||
cutoff = pd.Timestamp("2026-09-09T12:00:00Z")
|
||||
earlier = samples[pd.to_datetime(samples.time, utc=True).dt.date < cutoff.date()]
|
||||
expected = LocalPVTrendModel().fit(earlier, cutoff).coefficients[15]
|
||||
samples.loc[samples.index[-8:], "target_residual"] = 1.5
|
||||
np.testing.assert_allclose(LocalPVTrendModel().fit(samples, cutoff).coefficients[15], expected)
|
||||
|
||||
|
||||
def test_predict_respects_capacity_feature_arrival_and_expiry():
|
||||
issue, hist, pred, sky = case()
|
||||
features = local_trend_features(hist, pred, sky, issue)
|
||||
model = LocalPVTrendModel()
|
||||
model.coefficients = {lead: np.array([0, 0, 0, 10, 0, 0, 0, 0]) for lead in range(15, 121, 15)}
|
||||
result = model.predict(pred, sky, features, issue, pred, capacity_w=1500)
|
||||
assert result.iloc[0] == 1500
|
||||
assert result.iloc[-1] == pred.iloc[-1]
|
||||
features.loc[features.index[0], "feature_available_at"] = issue + pd.Timedelta(minutes=1)
|
||||
assert model.predict(pred, sky, features, issue, pred).iloc[0] == pred.iloc[0]
|
||||
Reference in New Issue
Block a user