mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-08 15:26:38 +00:00
Add PVForecastAkkudoktorLocal, which runs the modelling chain inside EOS on raw Open-Meteo irradiance instead of calling a forecast service: solar position, horizon shading, plane transposition, incidence-angle modifier, cell temperature, PVWatts DC and inverter AC. It needs no API key and serves up to 16 days at 15-minute resolution from a single hourly request, which is what keeps `optimization.tail_horizon_hours` fed - services wrapping Open-Meteo cut the horizon much shorter. Several Open-Meteo models can be listed in `weather_models` and are averaged per variable at no extra request cost. With `calibration_enabled` the provider fits itself against `measurement.pv_production_emr_keys` over the past `calibration_days`: a global scale factor plus optional per-solar-azimuth factors, each weighted by modelled energy, shrunk toward the global factor by `calibration_prior_kwh` and clamped to `[calibration_min_factor, calibration_max_factor]`. The comparison runs on past intervals, where Open-Meteo serves analysed rather than forecast weather, so it corrects the error of the PV model and not that of the weather forecast. Calibration is a scale factor on the output and never touches `userhorizon`, `surface_tilt`, `surface_azimuth` or `peakpower`. The docs say so, and say why a short window and a plant fault inside it are the two ways to end up with a misleading factor. Also add `Measurement.pv_production_total_kwh()` alongside the existing load total, and `scripts/pvforecast_backtest.py`, which scores configuration variants against the stored meter readings without waiting for new forecasts to come true.
292 lines
12 KiB
Python
292 lines
12 KiB
Python
"""Tests for the native (pvlib) PV forecast provider."""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import pendulum
|
|
import pvlib
|
|
import pytest
|
|
|
|
from akkudoktoreos.core.coreabc import get_measurement
|
|
from akkudoktoreos.prediction.pvforecastakkudoktorlocal import (
|
|
PVForecastAkkudoktorLocal,
|
|
PVForecastAkkudoktorLocalCommonSettings,
|
|
)
|
|
|
|
LATITUDE = 52.52
|
|
LONGITUDE = 13.405
|
|
|
|
# A window that starts well before `START` so the calibration fit has past data.
|
|
WINDOW_START = pendulum.datetime(2025, 6, 1, 0, 0, tz="UTC")
|
|
WINDOW_END = pendulum.datetime(2025, 6, 20, 0, 0, tz="UTC")
|
|
START = pendulum.datetime(2025, 6, 15, 0, 0, tz="UTC")
|
|
|
|
|
|
def synthetic_openmeteo(resolution_minutes: int = 15, models: list[str] | None = None) -> dict:
|
|
"""Build an Open-Meteo-shaped response from a pvlib clear-sky series.
|
|
|
|
Open-Meteo stamps an interval mean with the interval END, and that is what the
|
|
provider expects, so the values are generated at those stamps directly.
|
|
"""
|
|
freq = f"{resolution_minutes}min"
|
|
index = pd.date_range(
|
|
start=WINDOW_START.format("YYYY-MM-DD HH:mm"),
|
|
end=WINDOW_END.format("YYYY-MM-DD HH:mm"),
|
|
freq=freq,
|
|
tz="UTC",
|
|
)
|
|
location = pvlib.location.Location(LATITUDE, LONGITUDE, tz="UTC", altitude=37.0)
|
|
clearsky = location.get_clearsky(index, model="ineichen")
|
|
|
|
block = "minutely_15" if resolution_minutes == 15 else "hourly"
|
|
values = {
|
|
"shortwave_radiation": clearsky["ghi"].round(1).tolist(),
|
|
"diffuse_radiation": clearsky["dhi"].round(1).tolist(),
|
|
"direct_normal_irradiance": clearsky["dni"].round(1).tolist(),
|
|
"temperature_2m": [20.0] * len(index),
|
|
"relative_humidity_2m": [50.0] * len(index),
|
|
"wind_speed_10m": [2.0] * len(index),
|
|
}
|
|
|
|
data: dict = {"elevation": 37.0}
|
|
payload = {"time": [t.strftime("%Y-%m-%dT%H:%M") for t in index]}
|
|
if models:
|
|
# Multi-model requests come back with one suffixed series per member.
|
|
for name in models:
|
|
for key, series in values.items():
|
|
payload[f"{key}_{name}"] = series
|
|
else:
|
|
payload.update(values)
|
|
data[block] = payload
|
|
return data
|
|
|
|
|
|
@pytest.fixture
|
|
def pvforecast_instance(config_eos):
|
|
config_eos.merge_settings_from_dict(
|
|
{
|
|
"general": {"latitude": LATITUDE, "longitude": LONGITUDE, "timezone": "UTC"},
|
|
"prediction": {"hours": 96, "historic_hours": 48},
|
|
"pvforecast": {
|
|
"provider": "PVForecastAkkudoktorLocal",
|
|
"planes": [
|
|
{
|
|
"surface_tilt": 30.0,
|
|
"surface_azimuth": 180.0,
|
|
"peakpower": 10.0,
|
|
"inverter_paco": 10000,
|
|
"loss": 14.0,
|
|
}
|
|
],
|
|
"provider_settings": {"PVForecastAkkudoktorLocal": {"resolution_minutes": 15}},
|
|
},
|
|
}
|
|
)
|
|
return PVForecastAkkudoktorLocal(config=config_eos.load, start_datetime=START)
|
|
|
|
|
|
def test_provider_id(pvforecast_instance):
|
|
assert PVForecastAkkudoktorLocal.provider_id() == "PVForecastAkkudoktorLocal"
|
|
assert pvforecast_instance.enabled() is True
|
|
|
|
|
|
@pytest.mark.parametrize("value", [0, 5, 30, 61])
|
|
def test_resolution_must_be_15_or_60(value):
|
|
with pytest.raises(ValueError, match="resolution_minutes must be 15 or 60"):
|
|
PVForecastAkkudoktorLocalCommonSettings(resolution_minutes=value)
|
|
|
|
|
|
def test_invalid_transposition_model():
|
|
with pytest.raises(ValueError, match="Invalid transposition_model"):
|
|
PVForecastAkkudoktorLocalCommonSettings(transposition_model="nonsense")
|
|
|
|
|
|
def test_forecast_frame_is_quarter_hourly_and_plausible(pvforecast_instance):
|
|
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo())
|
|
|
|
assert not frame.empty
|
|
deltas = frame.index.to_series().diff().dropna().unique()
|
|
assert list(deltas) == [pd.Timedelta(minutes=15)]
|
|
|
|
# A 10 kWp south-facing roof under clear June skies: below the inverter cap,
|
|
# but a substantial fraction of it.
|
|
peak = frame["ac_power"].max()
|
|
assert 5000.0 < peak <= 10000.0
|
|
assert (frame["ac_power"] >= 0.0).all()
|
|
assert (frame["ac_power"] <= frame["dc_power"] + 1e-6).all()
|
|
|
|
# Nights are dark.
|
|
midnight = frame.between_time("00:00", "01:00")["ac_power"]
|
|
assert midnight.max() == pytest.approx(0.0)
|
|
|
|
|
|
def test_records_are_shifted_to_interval_start(pvforecast_instance):
|
|
"""Open-Meteo labels an interval by its end; EOS labels it by its start."""
|
|
shifted = pvforecast_instance._forecast_frame(synthetic_openmeteo())
|
|
|
|
pvforecast_instance.config.pvforecast.provider_settings.PVForecastAkkudoktorLocal = (
|
|
PVForecastAkkudoktorLocalCommonSettings(resolution_minutes=15, shift_to_interval_start=False)
|
|
)
|
|
raw = pvforecast_instance._forecast_frame(synthetic_openmeteo())
|
|
|
|
assert raw.index[0] - shifted.index[0] == pd.Timedelta(minutes=15)
|
|
assert raw["ac_power"].to_numpy() == pytest.approx(shifted["ac_power"].to_numpy())
|
|
|
|
|
|
def test_ensemble_members_are_averaged(pvforecast_instance):
|
|
"""Several models in one request must be averaged, not dropped or duplicated."""
|
|
single = pvforecast_instance._forecast_frame(synthetic_openmeteo())
|
|
ensemble = pvforecast_instance._forecast_frame(
|
|
synthetic_openmeteo(models=["icon_seamless", "gfs_seamless", "ecmwf_ifs025"])
|
|
)
|
|
|
|
# The synthetic members are identical, so the mean must reproduce the single run.
|
|
assert ensemble["ac_power"].to_numpy() == pytest.approx(single["ac_power"].to_numpy())
|
|
|
|
|
|
def test_horizon_elevation_wraps_around_north():
|
|
horizon = PVForecastAkkudoktorLocal._horizon_elevation(
|
|
[0.0, 10.0, 20.0, 30.0], np.array([0.0, 90.0, 180.0, 270.0, 359.999])
|
|
)
|
|
assert horizon[:4] == pytest.approx([0.0, 10.0, 20.0, 30.0])
|
|
# Wrapping back to due north interpolates from 30 deg towards 0 deg.
|
|
assert horizon[4] == pytest.approx(0.0, abs=0.01)
|
|
|
|
|
|
def test_horizon_shading_reduces_yield(pvforecast_instance):
|
|
baseline = pvforecast_instance._forecast_frame(synthetic_openmeteo())
|
|
|
|
# A 40 deg wall all around blocks the beam for most of the day.
|
|
pvforecast_instance.config.pvforecast.planes[0].userhorizon = [40.0] * 12
|
|
shaded = pvforecast_instance._forecast_frame(synthetic_openmeteo())
|
|
|
|
assert shaded["ac_power"].sum() < baseline["ac_power"].sum() * 0.9
|
|
assert shaded["ac_power"].min() >= 0.0
|
|
|
|
# The low morning sun (below 40 deg elevation until ~07:00 UTC in June) is behind
|
|
# the wall, so its beam is gone entirely and only diffuse is left.
|
|
assert (
|
|
shaded["ac_power"].between_time("05:00", "07:00").sum()
|
|
< baseline["ac_power"].between_time("05:00", "07:00").sum() * 0.5
|
|
)
|
|
|
|
|
|
def test_update_data_writes_records(pvforecast_instance):
|
|
with patch.object(PVForecastAkkudoktorLocal, "_request_forecast", return_value=synthetic_openmeteo()):
|
|
pvforecast_instance._update_data(force_update=True)
|
|
|
|
assert len(pvforecast_instance.records) > 0
|
|
record = pvforecast_instance.records[0]
|
|
assert record.pvforecast_ac_power is not None
|
|
assert record.pvforecast_dc_power is not None
|
|
|
|
|
|
def _feed_measurements(
|
|
instance: PVForecastAkkudoktorLocal, frame: pd.DataFrame, bias: float, key: str
|
|
) -> None:
|
|
"""Write cumulative PV meter readings that are `bias` times the modelled power.
|
|
|
|
Each test passes its own `key`. `Measurement` is database-backed, so clearing the
|
|
in-memory record list would not remove readings another test already stored.
|
|
"""
|
|
instance.config.measurement.pv_production_emr_keys = [key]
|
|
measurement = get_measurement()
|
|
|
|
hourly = frame["ac_power"].resample("1h").mean()
|
|
hourly = hourly.loc[hourly.index < START]
|
|
|
|
cumulative = 0.0
|
|
for timestamp, power_w in hourly.items():
|
|
measurement.update_value(
|
|
pendulum.instance(timestamp.to_pydatetime()), key, round(cumulative, 6)
|
|
)
|
|
cumulative += float(power_w) * bias / 1000.0
|
|
# Closing reading so the last interval has a difference to work with.
|
|
measurement.update_value(
|
|
pendulum.instance(hourly.index[-1].to_pydatetime()).add(hours=1),
|
|
key,
|
|
round(cumulative, 6),
|
|
)
|
|
|
|
|
|
def test_calibration_is_off_by_default(pvforecast_instance):
|
|
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
|
|
assert pvforecast_instance._fit_calibration(frame) is None
|
|
|
|
|
|
def test_calibration_skips_without_measurement_keys(pvforecast_instance):
|
|
pvforecast_instance.config.pvforecast.provider_settings.PVForecastAkkudoktorLocal = (
|
|
PVForecastAkkudoktorLocalCommonSettings(calibration_enabled=True)
|
|
)
|
|
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
|
|
assert pvforecast_instance._fit_calibration(frame) is None
|
|
|
|
|
|
def test_calibration_recovers_a_systematic_bias(pvforecast_instance):
|
|
"""A plant that consistently delivers 80% of the model must be corrected to 0.8."""
|
|
pvforecast_instance.config.pvforecast.provider_settings.PVForecastAkkudoktorLocal = (
|
|
PVForecastAkkudoktorLocalCommonSettings(
|
|
calibration_enabled=True,
|
|
calibration_days=14,
|
|
calibration_azimuth_bin_degrees=0,
|
|
)
|
|
)
|
|
|
|
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
|
|
_feed_measurements(pvforecast_instance, frame, bias=0.8, key="pv_bias_emr")
|
|
|
|
calibration = pvforecast_instance._fit_calibration(frame)
|
|
assert calibration is not None
|
|
global_factor, factors = calibration
|
|
assert global_factor == pytest.approx(0.8, abs=0.03)
|
|
assert factors == pytest.approx([global_factor])
|
|
|
|
corrected = pvforecast_instance._apply_calibration(frame, factors, 10000.0)
|
|
assert corrected["ac_power"].sum() == pytest.approx(frame["ac_power"].sum() * global_factor)
|
|
|
|
|
|
def test_calibration_factor_is_clamped(pvforecast_instance):
|
|
"""A wildly wrong meter must not be allowed to swing the forecast."""
|
|
pvforecast_instance.config.pvforecast.provider_settings.PVForecastAkkudoktorLocal = (
|
|
PVForecastAkkudoktorLocalCommonSettings(
|
|
calibration_enabled=True,
|
|
calibration_days=14,
|
|
calibration_azimuth_bin_degrees=0,
|
|
calibration_min_factor=0.9,
|
|
calibration_max_factor=1.1,
|
|
)
|
|
)
|
|
|
|
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
|
|
_feed_measurements(pvforecast_instance, frame, bias=0.2, key="pv_clamp_emr")
|
|
|
|
calibration = pvforecast_instance._fit_calibration(frame)
|
|
assert calibration is not None
|
|
global_factor, _ = calibration
|
|
assert global_factor == pytest.approx(0.9)
|
|
|
|
|
|
def test_calibration_respects_the_inverter_cap(pvforecast_instance):
|
|
frame = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
|
|
corrected = PVForecastAkkudoktorLocal._apply_calibration(frame, np.array([1.5]), 10000.0)
|
|
assert corrected["ac_power"].max() <= 10000.0 + 1e-6
|
|
|
|
|
|
def test_forecast_frame_applies_the_calibration(pvforecast_instance):
|
|
"""The correction must reach every caller of the chain, not just `_update_data`."""
|
|
pvforecast_instance.config.pvforecast.provider_settings.PVForecastAkkudoktorLocal = (
|
|
PVForecastAkkudoktorLocalCommonSettings(
|
|
calibration_enabled=True,
|
|
calibration_days=14,
|
|
calibration_azimuth_bin_degrees=0,
|
|
)
|
|
)
|
|
|
|
raw = pvforecast_instance._forecast_frame(synthetic_openmeteo(), calibrate=False)
|
|
_feed_measurements(pvforecast_instance, raw, bias=0.8, key="pv_chain_emr")
|
|
|
|
calibrated = pvforecast_instance._forecast_frame(synthetic_openmeteo())
|
|
ratio = calibrated["ac_power"].sum() / raw["ac_power"].sum()
|
|
assert ratio == pytest.approx(0.8, abs=0.03)
|