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
+24
-1
@@ -10,7 +10,7 @@ import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator, Optional, Type
|
||||
from typing import Any, AsyncIterator, Optional, Type, cast
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
@@ -876,3 +876,26 @@ class TestPerformance:
|
||||
assert loaded == n
|
||||
print(f"\nLoaded {n} records in {load_duration:.2f}s "
|
||||
f"({n / load_duration:.0f} rec/s)")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sqlite_bulk_operations_rollback_as_one_transaction(config_eos):
|
||||
import sqlite3
|
||||
|
||||
config_eos.database.provider = "SQLite"
|
||||
db = get_database()
|
||||
await db.open()
|
||||
try:
|
||||
await db.save_records([(b"first", b"original"), (b"second", b"other")], namespace="atomic")
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
await db.save_records(
|
||||
[(b"first", b"changed"), (b"bad", cast(Any, object()))], namespace="atomic"
|
||||
)
|
||||
assert dict([row async for row in db.iterate_records(namespace="atomic")]) == {
|
||||
b"first": b"original",
|
||||
b"second": b"other",
|
||||
}
|
||||
with pytest.raises(sqlite3.ProgrammingError):
|
||||
await db.delete_records([b"first", cast(Any, object())], namespace="atomic")
|
||||
assert await db.count_records(namespace="atomic") == 2
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
@@ -27,6 +27,9 @@ from akkudoktoreos.prediction.prediction import (
|
||||
Prediction,
|
||||
PredictionCommonSettings,
|
||||
)
|
||||
from akkudoktoreos.prediction.pvakkuadapt import (
|
||||
PVForecastAdaptiveAkkudoktor,
|
||||
)
|
||||
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
|
||||
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
|
||||
from akkudoktoreos.prediction.pvforecasthomeassistant import PVForecastHomeAssistant
|
||||
@@ -75,6 +78,7 @@ def forecast_providers():
|
||||
LoadImport(),
|
||||
LoadVrm(),
|
||||
PVForecastAkkudoktor(),
|
||||
PVForecastAdaptiveAkkudoktor(),
|
||||
PVForecastForecastSolar(),
|
||||
PVForecastHomeAssistant(),
|
||||
PVForecastImport(),
|
||||
@@ -138,19 +142,23 @@ def test_provider_sequence(prediction):
|
||||
assert isinstance(prediction.providers[21], LoadImport)
|
||||
assert isinstance(prediction.providers[22], LoadVrm)
|
||||
assert isinstance(prediction.providers[23], PVForecastAkkudoktor)
|
||||
assert isinstance(prediction.providers[24], PVForecastForecastSolar)
|
||||
assert isinstance(prediction.providers[25], PVForecastHomeAssistant)
|
||||
assert isinstance(prediction.providers[26], PVForecastImport)
|
||||
assert isinstance(prediction.providers[27], PVForecastPVLib)
|
||||
assert isinstance(prediction.providers[28], PVForecastPVNode)
|
||||
assert isinstance(prediction.providers[29], PVForecastSolcast)
|
||||
assert isinstance(prediction.providers[30], PVForecastVrm)
|
||||
assert isinstance(prediction.providers[24], PVForecastAdaptiveAkkudoktor)
|
||||
assert isinstance(prediction.providers[25], PVForecastForecastSolar)
|
||||
assert isinstance(prediction.providers[26], PVForecastHomeAssistant)
|
||||
assert isinstance(prediction.providers[27], PVForecastImport)
|
||||
assert isinstance(prediction.providers[28], PVForecastPVLib)
|
||||
assert isinstance(prediction.providers[29], PVForecastPVNode)
|
||||
assert isinstance(prediction.providers[30], PVForecastSolcast)
|
||||
assert isinstance(prediction.providers[31], PVForecastVrm)
|
||||
|
||||
|
||||
def test_provider_by_id(prediction, forecast_providers):
|
||||
"""Test that provider_by_id method returns the correct provider."""
|
||||
for provider in forecast_providers:
|
||||
assert prediction.provider_by_id(provider.provider_id()).provider_id() == provider.provider_id()
|
||||
assert (
|
||||
prediction.provider_by_id(provider.provider_id()).provider_id()
|
||||
== provider.provider_id()
|
||||
)
|
||||
|
||||
|
||||
def test_prediction_repr(prediction):
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
"""Issued forecast persistence, causal arrivals, RC provider wiring and data gaps."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.measurement.quality import MeasurementSample
|
||||
from akkudoktoreos.prediction.pvakkuadapt import (
|
||||
PVForecastAdaptiveAkkudoktor,
|
||||
PVForecastAdaptiveAkkudoktorCommonSettings,
|
||||
)
|
||||
from akkudoktoreos.prediction.pvakkuadaptstore import PVLearningStore
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
|
||||
def sample(key, time, value):
|
||||
return MeasurementSample(
|
||||
key=key, date_time=pd.Timestamp(time).to_pydatetime(), value=float(value)
|
||||
)
|
||||
|
||||
|
||||
def test_sample_arrival_is_preserved_and_revisions_get_later_availability(tmp_path):
|
||||
store = PVLearningStore(tmp_path / "learning.sqlite3")
|
||||
point = sample("micro", "2026-09-01T12:00Z", 1000)
|
||||
first, later = pd.Timestamp("2026-09-01T12:01Z"), pd.Timestamp("2026-09-01T13:00Z")
|
||||
store.import_samples([point], first)
|
||||
store.import_samples([point], later)
|
||||
assert store.samples("micro", pd.Timestamp("2026-09-01T11:00Z"), first)[0][
|
||||
"available_ms"
|
||||
] == store.milliseconds(first)
|
||||
changed = sample("micro", point.date_time, 2000)
|
||||
store.import_samples([changed], later)
|
||||
assert store.samples("micro", pd.Timestamp("2026-09-01T11:00Z"), first) == []
|
||||
|
||||
|
||||
def test_future_sample_rejected_before_storage(tmp_path):
|
||||
store = PVLearningStore(tmp_path / "learning.sqlite3")
|
||||
now = pd.Timestamp("2026-09-01T12:00Z")
|
||||
with pytest.raises(ValueError, match="Future"):
|
||||
store.import_samples([sample("micro", now + pd.Timedelta(minutes=1), 1)], now)
|
||||
|
||||
|
||||
def test_issued_forecast_immutable_and_day_ahead_baseline_freezes(tmp_path):
|
||||
store = PVLearningStore(tmp_path / "learning.sqlite3")
|
||||
target = pd.Timestamp("2026-09-02T08:00Z")
|
||||
before = target - pd.Timedelta(minutes=30)
|
||||
original = dict(plant_raw_w=1000, features=None)
|
||||
store.remember("a", before, [(target, original)], "Europe/Berlin")
|
||||
store.remember("a", before, [(target, dict(plant_raw_w=9999, features=None))], "Europe/Berlin")
|
||||
with store.connection() as db:
|
||||
row = db.execute("SELECT payload FROM forecasts").fetchone()
|
||||
assert json.loads(row[0])["plant_raw_w"] == 1000
|
||||
assert store.baselines("a", before, target + pd.Timedelta(hours=1))[0][1]["plant_raw_w"] == 1000
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(config_eos):
|
||||
config_eos.merge_settings_from_dict(
|
||||
dict(
|
||||
general=dict(latitude=50.8588, longitude=7.3747, timezone="Europe/Berlin"),
|
||||
pvforecast=dict(
|
||||
provider="PVForecastAdaptiveAkkudoktor",
|
||||
adaptive=dict(regional_weather_model=None, weather_models=["icon_seamless"]),
|
||||
planes=[
|
||||
dict(
|
||||
name="Hybrid",
|
||||
peakpower=3,
|
||||
surface_tilt=20,
|
||||
surface_azimuth=180,
|
||||
measurement_key="hybrid",
|
||||
curtailment_sensitive=True,
|
||||
),
|
||||
dict(
|
||||
name="Micro",
|
||||
peakpower=1,
|
||||
surface_tilt=20,
|
||||
surface_azimuth=180,
|
||||
measurement_key="micro",
|
||||
nowcast_reference=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
measurement=dict(
|
||||
keys=["hybrid", "micro"],
|
||||
channels={
|
||||
key: dict(
|
||||
quantity="power", unit="W", integration_method="hold", max_gap_seconds=600
|
||||
)
|
||||
for key in ("hybrid", "micro")
|
||||
},
|
||||
),
|
||||
)
|
||||
)
|
||||
return PVForecastAdaptiveAkkudoktor()
|
||||
|
||||
|
||||
def test_measurement_closing_samples_and_ingestion_arrivals_are_causal(provider):
|
||||
issue = pd.Timestamp("2026-09-18T12:20:00Z")
|
||||
values = [
|
||||
sample(key, time, 200)
|
||||
for key in ("hybrid", "micro")
|
||||
for time in pd.date_range(
|
||||
issue.floor("15min") - pd.Timedelta(minutes=30), issue, freq="5min"
|
||||
)
|
||||
]
|
||||
provider.store().import_samples(values, issue)
|
||||
result = provider._measurements(issue, 1)
|
||||
assert result.loc["2026-09-18T12:00Z", "reference_actual_w"] == pytest.approx(200)
|
||||
before_arrival = provider._measurements(issue - pd.Timedelta(minutes=1), 1)
|
||||
assert before_arrival.reference_actual_w.isna().all()
|
||||
|
||||
|
||||
def test_missing_reference_does_not_become_zero_or_borrow_hybrid_power(provider):
|
||||
issue = pd.Timestamp("2026-09-18T12:20:00Z")
|
||||
values = [
|
||||
sample("hybrid", time, 200)
|
||||
for time in pd.date_range(issue - pd.Timedelta(hours=1), issue, freq="5min")
|
||||
]
|
||||
provider.store().import_samples(values, issue)
|
||||
result = provider._measurements(issue, 1)
|
||||
assert result.reference_actual_w.isna().all()
|
||||
assert result.actual_w.isna().all()
|
||||
|
||||
|
||||
def test_hybrid_never_trains_weather_gain(provider):
|
||||
issue = pd.Timestamp("2026-09-18T12:20:00Z")
|
||||
state = provider._models(provider._signature(), issue)
|
||||
assert state["gains"]["hybrid"]["gain"] == 1
|
||||
assert state["gains"]["hybrid"]["days"] == 0
|
||||
assert state["coefficients"] == {}
|
||||
assert provider.store().metadata("daily_models") == state
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cold_provider_generates_standard_power_and_status(provider):
|
||||
from test_pvforecastakkudoktorlocal import synthetic_openmeteo
|
||||
|
||||
original = to_datetime
|
||||
issue = pd.Timestamp("2025-06-15T12:05:00+02:00")
|
||||
|
||||
def clock(*args, **kwargs):
|
||||
return (
|
||||
original(issue.to_pydatetime())
|
||||
if not args and kwargs.get("in_timezone")
|
||||
else original(*args, **kwargs)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("akkudoktoreos.prediction.pvakkuadapt.to_datetime", side_effect=clock),
|
||||
patch.object(type(provider), "_request_local_forecast", return_value=synthetic_openmeteo()),
|
||||
):
|
||||
await provider._update_data()
|
||||
status = provider.store().metadata("status")
|
||||
assert status["state"] == "reference_missing"
|
||||
assert status["training_days"] == 0
|
||||
assert status["trained_leads"] == 0
|
||||
assert status["references"] == ["micro"]
|
||||
assert provider.record_keys.count("pvforecast_ac_power") == 1
|
||||
future = await provider.key_to_series("pvforecast_ac_power")
|
||||
assert future.max() > 500
|
||||
assert np.isfinite(future.to_numpy()).all()
|
||||
for row in provider.store().latest(provider.store().milliseconds(issue)):
|
||||
assert row["plant_adaptive_w"] == pytest.approx(row["plant_calibrated_w"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"patch",
|
||||
[
|
||||
dict(backend="remote"),
|
||||
dict(resolution_minutes=60),
|
||||
dict(calibration_enabled=True),
|
||||
dict(min_training_days=10, learning_days=7),
|
||||
],
|
||||
)
|
||||
def test_adaptive_settings_reject_inconsistent_modes(patch):
|
||||
with pytest.raises(ValueError):
|
||||
PVForecastAdaptiveAkkudoktorCommonSettings(**patch)
|
||||
|
||||
|
||||
def test_pruning_is_scoped_to_private_archive(tmp_path):
|
||||
path = tmp_path / "learning.sqlite3"
|
||||
store = PVLearningStore(path)
|
||||
store.import_samples(
|
||||
[sample("micro", "2026-08-01T12:00Z", 100)], pd.Timestamp("2026-08-01T12:00Z")
|
||||
)
|
||||
store.metadata("profiles", dict(micro=dict(response=[1] * 46)))
|
||||
store.prune(pd.Timestamp("2026-10-01T12:00Z"), 31)
|
||||
assert (
|
||||
store.samples("micro", pd.Timestamp("2026-07-01T12:00Z"), pd.Timestamp("2026-10-01T12:00Z"))
|
||||
== []
|
||||
)
|
||||
assert store.metadata("profiles")
|
||||
|
||||
|
||||
def test_labels_are_unavailable_before_true_receipt_and_actual_can_arrive_later(tmp_path):
|
||||
store = PVLearningStore(tmp_path / "learning.sqlite3")
|
||||
target = pd.Timestamp("2026-09-18T12:15Z")
|
||||
issue = target - pd.Timedelta(minutes=15)
|
||||
payload = dict(
|
||||
features=dict(level=0.1), reference_sky_w=1000, reference_calibrated_w=500, plant_raw_w=1000
|
||||
)
|
||||
store.remember("a", issue, [(target, payload)], "Europe/Berlin")
|
||||
received = target + pd.Timedelta(minutes=25)
|
||||
observations = pd.DataFrame(
|
||||
dict(reference_actual_w=[600], actual_w=[np.nan], available_at=[received]), index=[target]
|
||||
)
|
||||
store.resolve("a", observations, received)
|
||||
assert store.training("a", received - pd.Timedelta(seconds=1)).empty
|
||||
assert store.training("a", received).iloc[0].target_residual == pytest.approx(0.1)
|
||||
observations["actual_w"] = 1500
|
||||
store.resolve("a", observations, received + pd.Timedelta(minutes=2))
|
||||
row = store.comparisons("a", store.milliseconds(issue), store.milliseconds(received))[0]
|
||||
assert row["actual_w"] == 1500
|
||||
assert row["reference_actual_w"] == 600
|
||||
assert row["plant_raw_w"] == 1000
|
||||
assert row["available_ms"] == store.milliseconds(received)
|
||||
|
||||
|
||||
def test_regional_missing_tail_falls_back_without_filling_radiation_gaps(provider):
|
||||
from test_pvforecastakkudoktorlocal import synthetic_openmeteo
|
||||
|
||||
data = synthetic_openmeteo()
|
||||
raw = data["minutely_15"]
|
||||
from akkudoktoreos.prediction.pvforecastakkudoktorlocal import OPENMETEO_VARIABLES
|
||||
|
||||
provider.config.pvforecast.adaptive.regional_weather_model = "icon_d2"
|
||||
for variable in OPENMETEO_VARIABLES:
|
||||
raw[variable + "_icon_d2"] = [None] * len(raw["time"])
|
||||
frame = provider._weather_frame(data)
|
||||
assert (frame.regional_weight == 0).all()
|
||||
for variable in OPENMETEO_VARIABLES[3:]:
|
||||
for member in list(raw):
|
||||
if member == variable or member.startswith(variable + "_"):
|
||||
raw[member][50] = None
|
||||
frame = provider._weather_frame(data)
|
||||
assert frame.iloc[50][list(OPENMETEO_VARIABLES[3:])].isna().all()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_measurements_keep_first_seen_availability(provider):
|
||||
from types import SimpleNamespace
|
||||
|
||||
issue = pd.Timestamp("2026-09-18T12:20Z")
|
||||
record = SimpleNamespace(
|
||||
date_time=to_datetime("2026-09-18T12:00Z"),
|
||||
configured_data=dict(micro=500),
|
||||
sample_quality={},
|
||||
)
|
||||
|
||||
async def records(*args):
|
||||
yield record
|
||||
|
||||
with patch.object(type(provider.measurement), "db_iterate_records", side_effect=records):
|
||||
await provider._sync_measurements(issue)
|
||||
await provider._sync_measurements(issue + pd.Timedelta(minutes=1))
|
||||
assert provider.store().samples("micro", issue - pd.Timedelta(hours=1), issue)[0][
|
||||
"available_ms"
|
||||
] == provider.store().milliseconds(issue)
|
||||
|
||||
|
||||
def test_persisted_live_features_train_after_five_complete_days(tmp_path):
|
||||
from akkudoktoreos.prediction.pvakkuadapttrend import FEATURES, LocalPVTrendModel
|
||||
|
||||
store = PVLearningStore(tmp_path / "learning.sqlite3")
|
||||
for day in pd.date_range("2026-09-12T08:00Z", periods=5, freq="D"):
|
||||
for offset in range(24):
|
||||
issue = day + pd.Timedelta(minutes=offset * 15)
|
||||
target = issue + pd.Timedelta(minutes=15)
|
||||
features: dict[str, Any] = {key: 0.1 for key in FEATURES}
|
||||
features["intercept"] = 1.0
|
||||
features["feature_available_at"] = issue
|
||||
store.remember(
|
||||
"a",
|
||||
issue,
|
||||
[
|
||||
(
|
||||
target,
|
||||
dict(features=features, reference_sky_w=1000, reference_calibrated_w=500),
|
||||
)
|
||||
],
|
||||
"Europe/Berlin",
|
||||
)
|
||||
received = target + pd.Timedelta(minutes=20)
|
||||
observed = pd.DataFrame(
|
||||
dict(reference_actual_w=[650], actual_w=[1000], available_at=[received]),
|
||||
index=[target],
|
||||
)
|
||||
store.resolve("a", observed, received)
|
||||
cutoff = pd.Timestamp("2026-09-18T12:00Z")
|
||||
trained = LocalPVTrendModel(scale_features=True).fit(store.training("a", cutoff), cutoff)
|
||||
assert trained.training_counts[15] == 120
|
||||
assert 15 in trained.coefficients
|
||||
assert np.isfinite(trained.coefficients[15]).all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adaptive_client(provider, monkeypatch):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from akkudoktoreos.server.rest import pvakkuadaptrest
|
||||
|
||||
monkeypatch.setattr(pvakkuadaptrest, "provider", lambda: provider)
|
||||
app = FastAPI()
|
||||
app.include_router(pvakkuadaptrest.router)
|
||||
with TestClient(app) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def test_api_exposes_honest_cold_start_and_persists_samples(adaptive_client, provider):
|
||||
provider.config.database.provider = "SQLite"
|
||||
prefix = "/v1/prediction/pv-adaptive"
|
||||
status = adaptive_client.get(prefix + "/status")
|
||||
assert status.status_code == 200
|
||||
assert status.json()["state"] == "initializing"
|
||||
assert status.json()["training_days"] == 0
|
||||
assert adaptive_client.get(prefix + "/latest").json() == []
|
||||
assert adaptive_client.get(prefix + "/comparisons").json() == []
|
||||
now = pd.Timestamp.now(tz="UTC")
|
||||
reading = sample("micro", now - pd.Timedelta(minutes=5), 250)
|
||||
response = adaptive_client.put(prefix + "/samples", json=[reading.model_dump(mode="json")])
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"updated": 1}
|
||||
stored = PVLearningStore(provider.store().path)
|
||||
assert (
|
||||
stored.samples("micro", now - pd.Timedelta(hours=1), pd.Timestamp.now(tz="UTC"))[0]["value"]
|
||||
== 250
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", ["unknown", "negative", "future", "timezone_missing"])
|
||||
def test_api_rejects_invalid_sample_batches_atomically(adaptive_client, provider, case):
|
||||
now = pd.Timestamp.now(tz="UTC")
|
||||
valid = sample("micro", now - pd.Timedelta(minutes=10), 250).model_dump(mode="json")
|
||||
invalid = dict(valid)
|
||||
if case == "unknown":
|
||||
invalid["key"] = "not_a_pv_plane"
|
||||
elif case == "negative":
|
||||
invalid["value"] = -1
|
||||
elif case == "future":
|
||||
invalid["date_time"] = (now + pd.Timedelta(hours=1)).isoformat()
|
||||
else:
|
||||
invalid["date_time"] = "2026-01-01T12:00:00"
|
||||
response = adaptive_client.put("/v1/prediction/pv-adaptive/samples", json=[valid, invalid])
|
||||
assert response.status_code == 422
|
||||
assert (
|
||||
provider.store().samples("micro", now - pd.Timedelta(hours=1), pd.Timestamp.now(tz="UTC"))
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_curtailment_flag_requires_complete_fresh_zero_support(provider):
|
||||
plane = provider.config.pvforecast.planes[0]
|
||||
plane.curtailment_key = "unrestricted_flag"
|
||||
issue = pd.Timestamp("2026-09-18T12:20Z")
|
||||
times = pd.date_range("2026-09-18T11:30Z", issue, freq="5min")
|
||||
provider.store().import_samples(
|
||||
[
|
||||
sample(key, t, 200 if key != "unrestricted_flag" else 0)
|
||||
for key in ("hybrid", "micro", "unrestricted_flag")
|
||||
for t in times
|
||||
],
|
||||
issue,
|
||||
)
|
||||
measured = provider._measurements(issue, 1)
|
||||
assert bool(measured.loc["2026-09-18T12:00Z", "hybrid_eligible"])
|
||||
provider.store().import_samples([sample("unrestricted_flag", "2026-09-18T12:05Z", 1)], issue)
|
||||
measured = provider._measurements(issue, 1)
|
||||
assert not bool(measured.loc["2026-09-18T12:00Z", "hybrid_eligible"])
|
||||
assert measured.loc["2026-09-18T12:00Z", "hybrid"] == 200
|
||||
|
||||
|
||||
def test_curtailment_flag_missing_does_not_authorize_learning(provider):
|
||||
provider.config.pvforecast.planes[0].curtailment_key = "missing_flag"
|
||||
issue = pd.Timestamp("2026-09-18T12:20Z")
|
||||
provider.store().import_samples(
|
||||
[sample("hybrid", t, 200) for t in pd.date_range("2026-09-18T11:30Z", issue, freq="5min")],
|
||||
issue,
|
||||
)
|
||||
measured = provider._measurements(issue, 1)
|
||||
assert not measured.hybrid_eligible.any()
|
||||
|
||||
|
||||
def test_explicit_site_guard_excludes_high_values_and_requires_support(provider):
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastPlaneSetting
|
||||
|
||||
provider.config.pvforecast.planes[0] = PVForecastPlaneSetting(
|
||||
**{
|
||||
**provider.config.pvforecast.planes[0].model_dump(),
|
||||
"learning_guard_key": "battery_soc_fraction",
|
||||
"learning_guard_max": 0.85,
|
||||
"learning_guard_max_gap_seconds": 1800,
|
||||
}
|
||||
)
|
||||
issue = pd.Timestamp("2026-09-18T12:20Z")
|
||||
times = pd.date_range("2026-09-18T11:30Z", issue, freq="5min")
|
||||
provider.store().import_samples(
|
||||
[sample("hybrid", t, 200) for t in times]
|
||||
+ [sample("battery_soc_fraction", t, 0.6) for t in times],
|
||||
issue,
|
||||
)
|
||||
assert provider._measurements(issue, 1).loc["2026-09-18T12:00Z", "hybrid_eligible"]
|
||||
provider.store().import_samples(
|
||||
[sample("battery_soc_fraction", "2026-09-18T12:05Z", 0.9)], issue
|
||||
)
|
||||
assert not provider._measurements(issue, 1).loc["2026-09-18T12:00Z", "hybrid_eligible"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fields",
|
||||
[
|
||||
{"learning_guard_key": "soc"},
|
||||
{"learning_guard_max": 0.9},
|
||||
{"learning_guard_key": "soc", "learning_guard_max": 0.9, "curtailment_key": "flag"},
|
||||
{"measurement_key": "power", "curtailment_key": "power"},
|
||||
],
|
||||
)
|
||||
def test_invalid_learning_guard_config_rejected(fields):
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastPlaneSetting
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
PVForecastPlaneSetting(**fields)
|
||||
|
||||
|
||||
def test_new_quality_options_preserve_legacy_physical_signature(provider):
|
||||
original = provider._signature()
|
||||
provider.config.pvforecast.planes[0].correction_group = "new_inverter"
|
||||
provider.config.pvforecast.planes[0].curtailment_key = "new_status_flag"
|
||||
provider.config.pvforecast.adaptive.correction_mode = "calibrated"
|
||||
assert provider._signature() == original
|
||||
|
||||
|
||||
def test_total_channel_and_no_independent_reference_are_supported(provider):
|
||||
from akkudoktoreos.measurement.measurement import MeasurementChannelSettings
|
||||
|
||||
provider.config.pvforecast.adaptive.total_measurement_key = "whole_plant"
|
||||
provider.config.pvforecast.adaptive.total_curtailment_sensitive = True
|
||||
for plane in provider.config.pvforecast.planes:
|
||||
plane.measurement_key = None
|
||||
provider.config.measurement.channels["whole_plant"] = MeasurementChannelSettings(
|
||||
quantity="power", unit="W", integration_method="hold", max_gap_seconds=600
|
||||
)
|
||||
issue = pd.Timestamp("2026-09-18T12:20Z")
|
||||
provider.store().import_samples(
|
||||
[
|
||||
sample("whole_plant", t, 500)
|
||||
for t in pd.date_range("2026-09-18T11:30Z", issue, freq="5min")
|
||||
],
|
||||
issue,
|
||||
)
|
||||
measured = provider._measurements(issue, 1)
|
||||
assert measured.loc["2026-09-18T12:00Z", "actual_w"] == 500
|
||||
assert measured.reference_actual_w.isna().all()
|
||||
assert provider._groups() == {"plant": ["whole_plant"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calibrated_ac_is_limited_per_plane_and_dc_is_not_reconstructed(provider):
|
||||
from test_pvforecastakkudoktorlocal import synthetic_openmeteo
|
||||
|
||||
issue = pd.Timestamp("2026-09-18T12:05Z")
|
||||
provider.config.pvforecast.planes[1].inverter_paco = 800
|
||||
index = pd.date_range(issue.floor("15min") - pd.Timedelta(hours=2), periods=20, freq="15min")
|
||||
raw = pd.DataFrame(
|
||||
dict(
|
||||
hybrid=3000.0,
|
||||
micro=800.0,
|
||||
hybrid_dc=5000.0,
|
||||
micro_dc=1000.0,
|
||||
weather_weight=0.0,
|
||||
solar_elevation=30.0,
|
||||
),
|
||||
index=index,
|
||||
)
|
||||
models = dict(
|
||||
gains={
|
||||
k: dict(
|
||||
gain=1.5 if k == "micro" else 1.0, shape=[1.0] * 12, days=5, curtailed=k == "hybrid"
|
||||
)
|
||||
for k in ("hybrid", "micro")
|
||||
},
|
||||
coefficients={},
|
||||
training_counts={},
|
||||
fitted_at=issue.isoformat(),
|
||||
selection={},
|
||||
)
|
||||
original = to_datetime
|
||||
|
||||
def clock(*args, **kwargs):
|
||||
return (
|
||||
original(issue.to_pydatetime())
|
||||
if not args and kwargs.get("in_timezone")
|
||||
else original(*args, **kwargs)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("akkudoktoreos.prediction.pvakkuadapt.to_datetime", side_effect=clock),
|
||||
patch.object(type(provider), "_physical_frames", return_value=[raw, raw.copy()]),
|
||||
patch.object(type(provider), "_request_local_forecast", return_value=synthetic_openmeteo()),
|
||||
patch.object(type(provider), "_models", return_value=models),
|
||||
):
|
||||
await provider._update_data()
|
||||
forecast = await provider.key_to_series("pvforecast_ac_power")
|
||||
dc = await provider.key_to_series("pvforecast_dc_power")
|
||||
assert forecast.max() <= 3800
|
||||
assert dc.max() == pytest.approx(6500)
|
||||
|
||||
|
||||
def test_missing_reference_still_resolves_whole_plant_metrics(tmp_path):
|
||||
store = PVLearningStore(tmp_path / "learning.sqlite3")
|
||||
issue = pd.Timestamp("2026-09-01T10:00Z")
|
||||
target = issue + pd.Timedelta(minutes=15)
|
||||
store.remember(
|
||||
"a",
|
||||
issue,
|
||||
[
|
||||
(
|
||||
target,
|
||||
dict(
|
||||
plant_raw_w=1000,
|
||||
plant_calibrated_w=900,
|
||||
plant_local_w=900,
|
||||
plant_adaptive_w=900,
|
||||
),
|
||||
)
|
||||
],
|
||||
"UTC",
|
||||
)
|
||||
available = target + pd.Timedelta(minutes=20)
|
||||
store.resolve(
|
||||
"a",
|
||||
pd.DataFrame(
|
||||
dict(actual_w=[800], reference_actual_w=[np.nan], available_at=[available]),
|
||||
index=[target],
|
||||
),
|
||||
available,
|
||||
)
|
||||
rows = store.metric_rows("a", issue, available)
|
||||
assert rows[0]["actual_w"] == 800
|
||||
assert (
|
||||
store.comparisons("a", store.milliseconds(issue), store.milliseconds(available))[0][
|
||||
"actual_w"
|
||||
]
|
||||
== 800
|
||||
)
|
||||
|
||||
|
||||
def test_metrics_use_full_window_independently_of_public_export_limit(tmp_path):
|
||||
store = PVLearningStore(tmp_path / "learning.sqlite3")
|
||||
target = pd.Timestamp("2026-09-02T12:00Z")
|
||||
stamp = store.milliseconds(target)
|
||||
payload = store.dump(dict(plant_raw_w=1000, reference_raw_w=100))
|
||||
with store.connection() as db:
|
||||
db.executemany(
|
||||
"INSERT INTO forecasts(signature,issue_ms,target_ms,lead_minutes,payload,actual_w,updated_ms) VALUES (?,?,?,?,?,?,?)",
|
||||
[
|
||||
("a", stamp - 100000 + i, stamp, 1, payload, 500, stamp + 900000)
|
||||
for i in range(20005)
|
||||
],
|
||||
)
|
||||
start, end = target - pd.Timedelta(hours=1), target + pd.Timedelta(hours=1)
|
||||
assert (
|
||||
len(store.comparisons("a", store.milliseconds(start), store.milliseconds(end), limit=20000))
|
||||
== 20000
|
||||
)
|
||||
assert len(store.metric_rows("a", start, end)) == 20005
|
||||
|
||||
|
||||
def test_daily_fit_waits_for_previous_day_closing_samples(provider):
|
||||
first = provider._models(provider._signature(), pd.Timestamp("2026-09-18T12:00Z"))
|
||||
before = provider._models(provider._signature(), pd.Timestamp("2026-09-19T00:02+02:00"))
|
||||
assert before["fitted_at"] == first["fitted_at"]
|
||||
after = provider._models(provider._signature(), pd.Timestamp("2026-09-19T00:16+02:00"))
|
||||
assert after["day"] == "2026-09-19"
|
||||
assert after["after_delay"] is True
|
||||
|
||||
|
||||
def test_day_ahead_metrics_count_dst_intervals_and_never_zero_fill(tmp_path):
|
||||
store = PVLearningStore(tmp_path / "learning.sqlite3")
|
||||
start = pd.Timestamp("2026-10-25", tz="Europe/Berlin")
|
||||
end = start + pd.DateOffset(days=1)
|
||||
targets = pd.date_range(start, end, freq="15min", inclusive="left")
|
||||
payload = store.dump({f"plant_{m}_w": 2000 for m in ("raw", "calibrated", "local", "adaptive")})
|
||||
with store.connection() as db:
|
||||
db.executemany(
|
||||
"INSERT INTO baselines VALUES (?,?,?,?)",
|
||||
[
|
||||
(
|
||||
"a",
|
||||
store.milliseconds(t),
|
||||
store.milliseconds(start - pd.Timedelta(hours=1)),
|
||||
payload,
|
||||
)
|
||||
for t in targets
|
||||
],
|
||||
)
|
||||
db.executemany(
|
||||
"INSERT INTO forecasts(signature,issue_ms,target_ms,lead_minutes,payload,actual_w,updated_ms) VALUES (?,?,?,?,?,?,?)",
|
||||
[
|
||||
(
|
||||
"a",
|
||||
store.milliseconds(t - pd.Timedelta(minutes=15)),
|
||||
store.milliseconds(t),
|
||||
15,
|
||||
payload,
|
||||
1000,
|
||||
store.milliseconds(end),
|
||||
)
|
||||
for t in targets
|
||||
],
|
||||
)
|
||||
rows = store.daily_metrics("a", start, end, "Europe/Berlin")
|
||||
assert rows[0]["complete"] is True
|
||||
assert rows[0]["expected_slots"] == 100
|
||||
assert rows[0]["actual_kwh"] == 25
|
||||
assert rows[0]["adaptive_kwh"] == 50
|
||||
with store.connection() as db:
|
||||
db.execute(
|
||||
"UPDATE forecasts SET actual_w=NULL WHERE target_ms=?",
|
||||
(store.milliseconds(targets[3]),),
|
||||
)
|
||||
assert store.daily_metrics("a", start, end, "Europe/Berlin")[0]["actual_kwh"] is None
|
||||
|
||||
|
||||
def test_censored_calibration_uses_only_authorized_partial_days():
|
||||
from akkudoktoreos.prediction.pvakkuadaptmodel import AdaptivePVModel
|
||||
|
||||
index = pd.DatetimeIndex(
|
||||
[
|
||||
t
|
||||
for d in pd.date_range("2026-09-01T10:00Z", periods=5, freq="D")
|
||||
for t in pd.date_range(d, periods=8, freq="15min")
|
||||
]
|
||||
)
|
||||
history = pd.DataFrame(
|
||||
dict(raw_power=1000.0, measured_power=600.0, available_at=index + pd.Timedelta(minutes=20)),
|
||||
index=index,
|
||||
)
|
||||
cutoff = pd.Timestamp("2026-09-07T00:00Z")
|
||||
model = AdaptivePVModel(min_days=5).fit(history, cutoff)
|
||||
assert model.trained_days == 0
|
||||
partial = AdaptivePVModel(min_days=5, allow_partial_days=True).fit(history, cutoff)
|
||||
assert partial.trained_days == 5
|
||||
assert partial.gain == pytest.approx(0.6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("backend", ["SQLite", "LMDB"])
|
||||
def test_eos_backend_migrates_archive_and_preserves_identity(config_eos, tmp_path, backend):
|
||||
import hashlib
|
||||
import sqlite3
|
||||
|
||||
from akkudoktoreos.prediction.pvakkuadaptstore import _DatabaseWorker
|
||||
|
||||
path = tmp_path / "legacy.sqlite3"
|
||||
store = PVLearningStore(path)
|
||||
target = pd.Timestamp("2026-09-02T08:00Z")
|
||||
issue = target - pd.Timedelta(minutes=30)
|
||||
store.import_samples([sample("micro", issue, 123)], issue)
|
||||
store.remember("stable", issue, [(target, dict(plant_raw_w=1000))], "Europe/Berlin")
|
||||
with sqlite3.connect(path) as legacy:
|
||||
store._db.backup(legacy)
|
||||
before = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
config_eos.database.provider = backend
|
||||
migrated = PVLearningStore(path)
|
||||
_DatabaseWorker.run(
|
||||
migrated.database.save_records([(b"other", b"untouched")], namespace="another-provider")
|
||||
)
|
||||
reopened = PVLearningStore(path)
|
||||
assert reopened.counts() == migrated.counts()
|
||||
assert reopened.storage_status()["backend"] == backend
|
||||
assert reopened.samples("micro", issue, target)[0]["available_ms"] == store.milliseconds(issue)
|
||||
with reopened.connection() as db:
|
||||
assert dict(db.execute("SELECT * FROM forecasts").fetchone()) == dict(
|
||||
store._db.execute("SELECT * FROM forecasts").fetchone()
|
||||
)
|
||||
reopened.remember("stable", issue, [(target, dict(plant_raw_w=9999))], "Europe/Berlin")
|
||||
assert (
|
||||
json.loads(reopened._db.execute("SELECT payload FROM forecasts").fetchone()[0])[
|
||||
"plant_raw_w"
|
||||
]
|
||||
== 1000
|
||||
)
|
||||
reopened.prune(target + pd.Timedelta(days=61))
|
||||
assert reopened.counts()["forecasts"] == 0
|
||||
after_prune = PVLearningStore(path)
|
||||
new_target = target + pd.Timedelta(days=62)
|
||||
after_prune.remember(
|
||||
"stable",
|
||||
new_target - pd.Timedelta(minutes=30),
|
||||
[(new_target, dict(plant_raw_w=1000))],
|
||||
"Europe/Berlin",
|
||||
)
|
||||
assert after_prune._db.execute("SELECT id FROM forecasts").fetchone()[0] > 1
|
||||
assert _DatabaseWorker.run(reopened.database.count_records(namespace="another-provider")) == 1
|
||||
assert hashlib.sha256(path.read_bytes()).hexdigest() == before
|
||||
_DatabaseWorker.run(reopened.database.close())
|
||||
|
||||
|
||||
def test_nodb_is_explicitly_volatile_and_creates_no_private_file(config_eos, tmp_path):
|
||||
path = tmp_path / "absent" / "learning.sqlite3"
|
||||
store = PVLearningStore(path)
|
||||
store.metadata("test", {"value": 1})
|
||||
assert store.storage_status()["persistent"] is False
|
||||
assert not path.exists()
|
||||
assert PVLearningStore(path).metadata("test") is None
|
||||
|
||||
|
||||
def test_long_snapshots_and_received_weather_are_immutable(tmp_path):
|
||||
store = PVLearningStore(tmp_path / "unused.sqlite3")
|
||||
issued = pd.Timestamp("2026-09-01T12:01Z")
|
||||
target = issued.ceil("h") + pd.Timedelta(hours=3)
|
||||
data = dict(
|
||||
minutely_15=dict(
|
||||
time=["2026-09-01T12:00Z", "2026-09-01T13:15Z"], shortwave_radiation=[999, 600]
|
||||
)
|
||||
)
|
||||
digest = store.archive_weather(data, issued)
|
||||
assert store.archive_weather(data, issued + pd.Timedelta(minutes=20)) == digest
|
||||
assert store.counts()["weather"] == 1
|
||||
payload = json.loads(store._db.execute("SELECT payload FROM weather").fetchone()[0])
|
||||
assert payload["source_issue_at"] is None
|
||||
assert payload["received_at"] == issued.isoformat()
|
||||
assert payload["minutely_15"]["shortwave_radiation"] == [600]
|
||||
store.remember_long("a", issued, [(target, {"planes": {"micro": {"calibrated": 100}}})])
|
||||
store.remember_long(
|
||||
"a",
|
||||
issued + pd.Timedelta(minutes=5),
|
||||
[(target, {"planes": {"micro": {"calibrated": 999}}})],
|
||||
)
|
||||
rows = store.long_rows("a", issued, target + pd.Timedelta(hours=1))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["planes"]["micro"]["calibrated"] == 100
|
||||
assert rows[0]["issued_ms"] == store.milliseconds(issued)
|
||||
|
||||
|
||||
def test_backend_write_failure_is_reported_and_pending_changes_retry(config_eos, tmp_path):
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from akkudoktoreos.prediction.pvakkuadaptstore import _DatabaseWorker
|
||||
|
||||
config_eos.database.provider = "SQLite"
|
||||
store = PVLearningStore(tmp_path / "no-legacy.sqlite3")
|
||||
with patch.object(type(store.database), "save_records", new=AsyncMock(return_value=0)):
|
||||
with pytest.raises(RuntimeError, match="Incomplete"):
|
||||
store.metadata("pending", {"value": 123})
|
||||
assert store.metadata("pending") == {"value": 123}
|
||||
assert PVLearningStore(store.path).metadata("pending") == {"value": 123}
|
||||
_DatabaseWorker.run(store.database.close())
|
||||
|
||||
|
||||
def test_long_solar_candidate_limits_ac_and_keeps_physical_dc(provider):
|
||||
provider.config.pvforecast.adaptive.weather_response_enabled = False
|
||||
provider.config.pvforecast.planes[0].inverter_paco = 3000
|
||||
provider.config.pvforecast.planes[1].inverter_paco = 800
|
||||
issue = pd.Timestamp("2026-09-18T06:00Z")
|
||||
index = pd.date_range("2026-09-18T12:00Z", periods=4, freq="15min")
|
||||
ac = pd.DataFrame(dict(hybrid=3000., micro=800.), index=index)
|
||||
dc = pd.DataFrame(dict(hybrid=5000., micro=1200.), index=index)
|
||||
response = dict(solar={key:dict(coefficients=[.5]*45) for key in ("hybrid", "micro")}, weather={})
|
||||
candidates, members, dc_candidates = provider._long_candidates({}, ac, ac, dc, {}, response, issue)
|
||||
assert not members
|
||||
assert candidates["solar"].hybrid.max() == 3000
|
||||
assert candidates["solar"].micro.max() == 800
|
||||
np.testing.assert_allclose(dc_candidates["solar"].hybrid, 7500)
|
||||
np.testing.assert_allclose(dc_candidates["solar"].micro, 1800)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -1,6 +1,7 @@
|
||||
|
||||
"""Tests for PVForecastPVLib prediction provider."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -487,7 +488,8 @@ class TestCECDatabase:
|
||||
#
|
||||
# Force new modification time.
|
||||
#
|
||||
path.touch()
|
||||
previous = path.stat()
|
||||
os.utime(path, ns=(previous.st_atime_ns, previous.st_mtime_ns + 1_000_000_000))
|
||||
|
||||
second = _load_cec_database(path)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user