Files
EOS/tests/test_prediction.py
Bobby Noelte 75548990e1
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
feat: add cloud PV forecast providers: pvnode.com, forecast-solar and solcast (#1150)
This PR adds three native PV power forecast providers, giving operators more
cloud forecast sources to choose from via pvforecast.provider, alongside the
existing PVForecastAkkudoktor, PVForecastVrm and PVForecastImport:

    PVForecastPVNode — native 15-minute forecasts from the pvnode.com
    V2 API. Saved-site mode (GET /v2/forecast/{site_id}) where the operator enters their API key +
    site id, or inline mode (POST /v2/forecast/inline) using the configured planes.
    PVForecastForecastSolar — the free Forecast.Solar API (no key
    required for the public endpoint). Multi-plane systems issue one request per plane and the
    instantaneous powers are summed per timestamp.
    PVForecastSolcast — the Solcast rooftop-site API (API key + resource id).

Implementation notes

    All three populate the existing pvforecast_ac_power (and mirror pvforecast_dc_power)
    prediction keys, so they slot into the optimizer unchanged.
    Timezone handling: pvnode and Forecast.Solar return site-local wall-clock timestamps with an
    IANA timezone field, which are resolved to absolute instants before resampling; Solcast returns
    UTC period_end and is normalised to the period start.
    Each provider follows the existing provider pattern (provider_id(), _request_forecast() with
    @cache_in_file, _update_data()), is registered in pvforecast.py and prediction.py, and is
    upstream-neutral.

Validation

    27 unit tests (timezone resolution, null/zero handling, kW→W and period-start conversion,
    azimuth conversion, multi-plane summation, request URL/auth, HTTP-error handling).
    ruff check (F/D/S/bandit) and ruff format clean.
    Each provider was additionally validated against its live API with a real plant, confirming
    the response shapes (pvnode: 288 native 15-min slots; Forecast.Solar: instantaneous watts;
    Solcast: kW estimates with period_end/PT30M).

Documentation

    Provider descriptions and configuration examples added to docs/akkudoktoreos/prediction.md.
    CHANGELOG.md entry under Unreleased.
    Regenerated docs/_generated/configpvforecast.md and configexample.md.

Notes for reviewers

Forecast.Solar's free endpoint is rate-limited (12 req/hour) and Solcast's free tier limits daily
calls; both providers rely on the standard 1-hour cache_in_file TTL to stay within budget.

Authors:

The code is created by Christin. Only minor adaptions by Bobby.

Signed-off-by: Christin <info@bikinibottom.capital>
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
Co-authored-by: Christin <info@bikinibottom.capital>
2026-07-17 16:51:00 +02:00

156 lines
6.3 KiB
Python

import pytest
from pydantic import ValidationError
from akkudoktoreos.core.coreabc import get_prediction
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.loadakkudoktor import (
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
)
from akkudoktoreos.prediction.loadimport import LoadImport
from akkudoktoreos.prediction.loadvrm import LoadVrm
from akkudoktoreos.prediction.prediction import (
Prediction,
PredictionCommonSettings,
)
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
from akkudoktoreos.prediction.pvforecastsolcast import PVForecastSolcast
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
from akkudoktoreos.prediction.weatherbrightsky import WeatherBrightSky
from akkudoktoreos.prediction.weatherclearoutside import WeatherClearOutside
from akkudoktoreos.prediction.weatherimport import WeatherImport
from akkudoktoreos.prediction.weatheropenmeteo import WeatherOpenMeteo
@pytest.fixture
def prediction():
"""All EOS predictions."""
return get_prediction()
@pytest.fixture
def forecast_providers():
"""Fixture for singleton forecast provider instances."""
return [
ElecPriceAkkudoktor(),
ElecPriceEnergyCharts(),
ElecPriceFixed(),
ElecPriceImport(),
FeedInTariffFixed(),
FeedInTariffImport(),
LoadAkkudoktor(),
LoadAkkudoktorAdjusted(),
LoadVrm(),
LoadImport(),
PVForecastAkkudoktor(),
PVForecastVrm(),
PVForecastPVNode(),
PVForecastForecastSolar(),
PVForecastSolcast(),
PVForecastImport(),
WeatherBrightSky(),
WeatherClearOutside(),
WeatherOpenMeteo(),
WeatherImport(),
]
@pytest.mark.parametrize(
"field_name, invalid_value, expected_error",
[
("hours", -1, "Input should be greater than or equal to 0"),
("historic_hours", -5, "Input should be greater than or equal to 0"),
],
)
def test_prediction_common_settings_invalid(field_name, invalid_value, expected_error, config_eos):
"""Test invalid settings for PredictionCommonSettings."""
valid_data = {
"hours": 48,
"historic_hours": 24,
}
assert PredictionCommonSettings(**valid_data) is not None
valid_data[field_name] = invalid_value
with pytest.raises(ValidationError, match=expected_error):
PredictionCommonSettings(**valid_data)
def test_initialization(prediction, forecast_providers):
"""Test that Prediction is initialized with the correct providers in sequence."""
assert isinstance(prediction, Prediction)
for idx, provider in enumerate(prediction.providers):
assert provider.provider_id() == forecast_providers[idx].provider_id()
def test_provider_sequence(prediction):
"""Test the provider sequence is maintained in the Prediction instance."""
assert isinstance(prediction.providers[0], ElecPriceAkkudoktor)
assert isinstance(prediction.providers[1], ElecPriceEnergyCharts)
assert isinstance(prediction.providers[2], ElecPriceFixed)
assert isinstance(prediction.providers[3], ElecPriceImport)
assert isinstance(prediction.providers[4], FeedInTariffFixed)
assert isinstance(prediction.providers[5], FeedInTariffImport)
assert isinstance(prediction.providers[6], LoadAkkudoktor)
assert isinstance(prediction.providers[7], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[8], LoadVrm)
assert isinstance(prediction.providers[9], LoadImport)
assert isinstance(prediction.providers[10], PVForecastAkkudoktor)
assert isinstance(prediction.providers[11], PVForecastVrm)
assert isinstance(prediction.providers[12], PVForecastPVNode)
assert isinstance(prediction.providers[13], PVForecastForecastSolar)
assert isinstance(prediction.providers[14], PVForecastSolcast)
assert isinstance(prediction.providers[15], PVForecastImport)
assert isinstance(prediction.providers[16], WeatherBrightSky)
assert isinstance(prediction.providers[17], WeatherClearOutside)
assert isinstance(prediction.providers[18], WeatherOpenMeteo)
assert isinstance(prediction.providers[19], WeatherImport)
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()
def test_prediction_repr(prediction):
"""Test that the Prediction instance's representation is correct."""
result = repr(prediction)
assert "Prediction([" in result
assert "ElecPriceAkkudoktor" in result
assert "ElecPriceEnergyCharts" in result
assert "ElecPriceFixed" in result
assert "ElecPriceImport" in result
assert "FeedInTariffFixed" in result
assert "FeedInTariffImport" in result
assert "LoadAkkudoktor" in result
assert "LoadVrm" in result
assert "LoadImport" in result
assert "PVForecastAkkudoktor" in result
assert "PVForecastVrm" in result
assert "PVForecastImport" in result
assert "WeatherBrightSky" in result
assert "WeatherClearOutside" in result
assert "WeatherOpenMeteo" in result
assert "WeatherImport" in result
@pytest.mark.asyncio
async def test_empty_providers(prediction, forecast_providers):
"""Test behavior when Prediction does not have providers."""
# Clear all prediction providers from prediction
providers_bkup = prediction.providers.copy()
prediction.providers.clear()
assert prediction.providers == []
await prediction.update_data() # Should not raise an error even with no providers
# Cleanup after Test
prediction.providers = providers_bkup