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:
Andreas
2026-11-03 15:31:32 +01:00
committed by GitHub
co-authored by Normann Christin Claude Opus 5.5 YMT sarankumar1325
parent 298d46a527
commit f804d48072
24 changed files with 6063 additions and 13 deletions
+24 -1
View File
@@ -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()
+16 -8
View File
@@ -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):
+769
View File
@@ -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)
+137
View File
@@ -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"
+124
View File
@@ -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
+137
View File
@@ -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]
+3 -1
View File
@@ -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)