Add resilient market feed-in tariff providers

This commit is contained in:
Andreas
2026-07-15 16:10:34 +02:00
parent 67cf6f7d8a
commit d4056af0f6
15 changed files with 928 additions and 45 deletions
+2 -3
View File
@@ -253,6 +253,7 @@ def test_request_forecast_uses_tibber_graphql_api(
assert "priceInfoRange" in kwargs["json"]["query"]
assert "QUARTER_HOURLY" in kwargs["json"]["query"]
assert "total" in kwargs["json"]["query"]
assert "energy" in kwargs["json"]["query"]
assert kwargs["timeout"] == 30
@@ -340,9 +341,7 @@ def test_tibber_update_uses_eos_storage_history_when_api_history_is_missing(
assert forecast_call["history_hours"] > 840
def test_tibber_update_preserves_quarter_hour_resolution_and_slots(
tibber_provider, monkeypatch
):
def test_tibber_update_preserves_quarter_hour_resolution_and_slots(tibber_provider, monkeypatch):
"""15-minute Tibber prices are stored natively and extrapolated on the slot grid.
Proves the resolution-agnostic path: (a) the native 15-min resolution survives
+102
View File
@@ -0,0 +1,102 @@
import json
from unittest.mock import Mock, patch
import pytest
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.optimization.genetic.geneticparams import (
MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS,
)
from akkudoktoreos.prediction.elecpriceakkudoktor import AkkudoktorElecPrice
from akkudoktoreos.prediction.feedintariffakkudoktor import FeedInTariffAkkudoktor
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
@pytest.fixture
def provider(config_eos):
config_eos.merge_settings_from_dict(
{
"elecprice": {"charges_kwh": 0.30},
"feedintariff": {"provider": "FeedInTariffAkkudoktor"},
}
)
value = FeedInTariffAkkudoktor()
value.highest_orig_datetime = None
value.records.clear()
assert value.enabled()
return value
@pytest.fixture
def response_data():
return {
"meta": {
"start_timestamp": "1733871600",
"end_timestamp": "1733958000",
"start": "2024-12-11T00:00:00+01:00",
"end": "2024-12-12T00:00:00+01:00",
},
"values": [
{
"start_timestamp": 1733871600,
"end_timestamp": 1733875200,
"start": "2024-12-11T00:00:00+01:00",
"end": "2024-12-11T01:00:00+01:00",
"marketprice": 100.0,
"unit": "Eur/MWh",
"marketpriceEurocentPerKWh": 10.0,
},
{
"start_timestamp": 1733875200,
"end_timestamp": 1733878800,
"start": "2024-12-11T01:00:00+01:00",
"end": "2024-12-11T02:00:00+01:00",
"marketprice": 200.0,
"unit": "Eur/MWh",
"marketpriceEurocentPerKWh": 20.0,
},
],
}
def test_provider_is_available(config_eos):
assert "FeedInTariffAkkudoktor" in config_eos.feedintariff.providers
assert "FeedInTariffAkkudoktor" in MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS
def test_parse_data_uses_raw_market_price_without_import_charges(provider, response_data):
data = AkkudoktorElecPrice.model_validate(response_data)
series = provider._parse_data(data)
assert series.iloc[0] == pytest.approx(0.0001)
def test_hourly_prices_are_held_constant_on_quarter_hour_grid(provider, response_data):
data = AkkudoktorElecPrice.model_validate(response_data)
provider.key_from_series("feed_in_tariff_wh", provider._parse_data(data))
start = to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin")
values = provider.key_to_array(
key="feed_in_tariff_wh",
start_datetime=start,
end_datetime=start + to_duration("2 hours"),
interval=to_duration("15 minutes"),
fill_method="ffill",
)
assert values.tolist() == pytest.approx([0.0001] * 4 + [0.0002] * 4)
@patch("requests.get")
def test_request_uses_akkudoktor_prices_endpoint(mock_get, provider, response_data):
response = Mock()
response.content = json.dumps(response_data)
response.raise_for_status = Mock()
mock_get.return_value = response
get_ems().set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin"))
provider._request_forecast(force_update=True)
url = mock_get.call_args[0][0]
assert url.startswith("https://api.akkudoktor.net/prices?")
assert "tz=Europe/Berlin" in url
assert mock_get.call_args.kwargs["timeout"] == (5, 20)
+124 -1
View File
@@ -4,10 +4,15 @@ import json
from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pytest
import requests
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecpriceenergycharts import EnergyChartsElecPrice
from akkudoktoreos.prediction.elecpriceenergycharts import (
ElecPriceEnergyCharts,
EnergyChartsElecPrice,
)
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.utils.datetimeutil import to_datetime
@@ -99,3 +104,121 @@ def test_update_data_keeps_quarter_hour_resolution(provider):
)
assert len(result) == provider.config.prediction.hours * 4
assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
def test_repeated_updates_keep_ets_history_and_honor_force_update(provider):
"""A later update must retain ETS history and a forced update must fetch again."""
start = to_datetime(in_timezone="Europe/Berlin").start_of("day")
get_ems().set_start_datetime(start)
provider.config.prediction.hours = 72
raw_start = start.subtract(days=35)
raw_end = start.add(days=2)
raw_slots = int((raw_end - raw_start).total_seconds() // 900) + 1
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(raw_start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[50.0 + float(i % 96) for i in range(raw_slots)],
unit="EUR/MWh",
deprecated=False,
)
ets_history_lengths = []
def fake_ets(history, seasonal_periods, hours):
ets_history_lengths.append((len(history), seasonal_periods))
return np.full(hours, 0.00005)
with (
patch.object(provider, "_request_forecast", return_value=energy_charts_data) as request,
patch.object(ElecPriceEnergyCharts, "_predict_ets", side_effect=fake_ets),
patch.object(
ElecPriceEnergyCharts,
"_predict_median",
side_effect=AssertionError("median fallback must not be used"),
),
):
provider.update_data(force_enable=True, force_update=True)
provider.update_data(force_enable=True, force_update=False)
# Raw prices already cover the Energy-Charts publication window, so the
# second update reuses the retained 35-day history without another request.
assert request.call_count == 1
assert len(ets_history_lengths) == 2
assert all(length > 800 * 4 for length, _ in ets_history_lengths)
assert all(seasonal_periods == 168 * 4 for _, seasonal_periods in ets_history_lengths)
provider.update_data(force_enable=True, force_update=True)
# force_update must bypass the provider's own "no update needed" decision.
assert request.call_count == 2
assert provider.historic_hours_min() == 24 * 35
def test_request_forecast_retries_transient_errors(provider, sample_energycharts_json):
"""A transient timeout is retried; a later success is returned (Fix D)."""
get_ems().set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin"))
ok_response = Mock()
ok_response.status_code = 200
ok_response.content = json.dumps(sample_energycharts_json)
ok_response.raise_for_status = Mock()
with (
patch("requests.get", side_effect=[requests.exceptions.ReadTimeout("t1"), ok_response]) as get_mock,
patch("akkudoktoreos.prediction.feedintariffenergycharts.time.sleep", return_value=None),
):
provider._request_forecast(start_date="2024-12-10", force_update=True)
assert get_mock.call_count == 2
def test_update_data_falls_back_to_history_on_fetch_error(provider):
"""A transient fetch error must not abort the update when history exists (Fix A)."""
start = to_datetime(in_timezone="Europe/Berlin").start_of("day")
get_ems().set_start_datetime(start)
provider.config.prediction.hours = 48
raw_start = start.subtract(days=35)
raw_slots = int((start.add(days=2) - raw_start).total_seconds() // 900) + 1
energy_charts_data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(raw_start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)],
price=[50.0 + float(i % 96) for i in range(raw_slots)],
unit="EUR/MWh",
deprecated=False,
)
def fake_predict(history, slots, slots_per_hour):
return np.full(slots, 0.00005)
with patch.object(provider, "_predict_prices", side_effect=fake_predict):
# First: successful update seeds history and highest_orig_datetime.
with patch.object(provider, "_request_forecast", return_value=energy_charts_data):
provider.update_data(force_enable=True, force_update=True)
assert provider.highest_orig_datetime is not None
last_good = provider.highest_orig_datetime
# Second: API times out. With existing history the update must NOT raise
# and the retained history must be kept.
with patch.object(
provider, "_request_forecast", side_effect=requests.exceptions.ReadTimeout("boom")
):
provider.update_data(force_enable=True, force_update=True)
# Fix A: the update did not abort (we got here) and the retained history is
# unchanged, so downstream consumers still receive a feed-in tariff series.
assert provider.highest_orig_datetime == last_good
def test_update_data_cold_start_fetch_error_raises(provider):
"""Without any history a fetch error stays fatal (cold start)."""
start = to_datetime(in_timezone="Europe/Berlin").start_of("day")
get_ems().set_start_datetime(start)
assert provider.highest_orig_datetime is None
with patch.object(
provider, "_request_forecast", side_effect=requests.exceptions.ReadTimeout("boom")
):
with pytest.raises(requests.exceptions.ReadTimeout):
provider.update_data(force_enable=True, force_update=True)
+130
View File
@@ -0,0 +1,130 @@
"""Tests for the native quarter-hour Tibber feed-in tariff provider."""
import json
from unittest.mock import Mock, patch
import pytest
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.optimization.genetic.geneticparams import (
MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS,
)
from akkudoktoreos.prediction.elecpricetibber import TibberGraphQLResponse
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
def _point(starts_at: str, energy: float, total: float = 0.40) -> dict[str, object]:
return {"startsAt": starts_at, "energy": energy, "total": total}
def _payload(points: list[dict[str, object]]) -> dict[str, object]:
return {
"data": {
"viewer": {
"homes": [
{
"id": "home-1",
"currentSubscription": {
"priceInfo": {"today": points[:4], "tomorrow": points[4:]},
"priceInfoRange": {"nodes": points},
},
}
]
}
}
}
@pytest.fixture
def quarter_hour_points():
return [
_point(f"2026-07-15T0{index // 4}:{(index % 4) * 15:02d}:00+02:00", 0.10 + index / 100)
for index in range(8)
]
@pytest.fixture
def provider(config_eos):
FeedInTariffTibber.reset_instance()
config_eos.merge_settings_from_dict(
{
"elecprice": {"tibber": {"access_token": "token-123", "home_id": "home-1"}},
"feedintariff": {
"direct_marketing_enabled": True,
"provider": "FeedInTariffTibber",
},
"prediction": {"hours": 2},
}
)
value = FeedInTariffTibber()
value.highest_orig_datetime = None
value.records.clear()
get_ems().set_start_datetime(
to_datetime("2026-07-15T00:00:00+02:00", in_timezone="Europe/Berlin")
)
return value
def test_provider_is_registered_and_used_for_direct_marketing(provider, config_eos):
assert provider.enabled()
assert "FeedInTariffTibber" in config_eos.feedintariff.providers
assert "FeedInTariffTibber" in MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS
def test_parse_uses_energy_component_at_native_quarter_hour_resolution(
provider, quarter_hour_points
):
response = TibberGraphQLResponse.model_validate(_payload(quarter_hour_points))
series = provider._parse_data(response)
assert series.tolist() == pytest.approx([(0.10 + index / 100) / 1000 for index in range(8)])
assert series.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
@patch("requests.post")
def test_request_is_strictly_quarter_hourly_and_requests_energy(
mock_post, provider, quarter_hour_points
):
response = Mock()
response.content = json.dumps(_payload(quarter_hour_points)).encode()
response.raise_for_status = Mock()
mock_post.return_value = response
provider._request_forecast(force_update=True)
query = mock_post.call_args.kwargs["json"]["query"]
assert "priceInfo(resolution: QUARTER_HOURLY)" in " ".join(query.split())
assert "priceInfoRange(resolution: QUARTER_HOURLY" in " ".join(query.split())
assert "energy" in query
assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer token-123"
def test_update_keeps_four_distinct_prices_per_hour(provider, quarter_hour_points, monkeypatch):
response = TibberGraphQLResponse.model_validate(_payload(quarter_hour_points))
monkeypatch.setattr(provider, "_request_forecast", lambda **_: response)
provider._update_data(force_update=True)
start = to_datetime("2026-07-15T00:00:00+02:00", in_timezone="Europe/Berlin")
prices = provider.key_to_array(
key="feed_in_tariff_wh",
start_datetime=start,
end_datetime=start + to_duration("2 hours"),
interval=to_duration("15 minutes"),
fill_method="ffill",
)
assert prices.tolist() == pytest.approx([(0.10 + index / 100) / 1000 for index in range(8)])
def test_update_rejects_hourly_tibber_data(provider, monkeypatch):
hourly = [
_point("2026-07-15T00:00:00+02:00", 0.10),
_point("2026-07-15T01:00:00+02:00", 0.11),
]
response = TibberGraphQLResponse.model_validate(_payload(hourly))
monkeypatch.setattr(provider, "_request_forecast", lambda **_: response)
with pytest.raises(ValueError, match="requires native 15-minute prices"):
provider._update_data(force_update=True)
+24 -16
View File
@@ -7,9 +7,11 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
from akkudoktoreos.prediction.feedintariffakkudoktor import FeedInTariffAkkudoktor
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber
from akkudoktoreos.prediction.loadakkudoktor import (
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
@@ -48,8 +50,10 @@ def forecast_providers():
ElecPriceFixed(),
ElecPriceImport(),
FeedInTariffEnergyCharts(),
FeedInTariffAkkudoktor(),
FeedInTariffFixed(),
FeedInTariffImport(),
FeedInTariffTibber(),
LoadAkkudoktor(),
LoadAkkudoktorAdjusted(),
LoadVrm(),
@@ -102,22 +106,24 @@ def test_provider_sequence(prediction):
assert isinstance(prediction.providers[3], ElecPriceFixed)
assert isinstance(prediction.providers[4], ElecPriceImport)
assert isinstance(prediction.providers[5], FeedInTariffEnergyCharts)
assert isinstance(prediction.providers[6], FeedInTariffFixed)
assert isinstance(prediction.providers[7], FeedInTariffImport)
assert isinstance(prediction.providers[8], LoadAkkudoktor)
assert isinstance(prediction.providers[9], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[10], LoadVrm)
assert isinstance(prediction.providers[11], LoadImport)
assert isinstance(prediction.providers[12], PVForecastAkkudoktor)
assert isinstance(prediction.providers[13], PVForecastVrm)
assert isinstance(prediction.providers[14], PVForecastPVNode)
assert isinstance(prediction.providers[15], PVForecastForecastSolar)
assert isinstance(prediction.providers[16], PVForecastSolcast)
assert isinstance(prediction.providers[17], PVForecastImport)
assert isinstance(prediction.providers[18], WeatherBrightSky)
assert isinstance(prediction.providers[19], WeatherClearOutside)
assert isinstance(prediction.providers[20], WeatherOpenMeteo)
assert isinstance(prediction.providers[21], WeatherImport)
assert isinstance(prediction.providers[6], FeedInTariffAkkudoktor)
assert isinstance(prediction.providers[7], FeedInTariffFixed)
assert isinstance(prediction.providers[8], FeedInTariffImport)
assert isinstance(prediction.providers[9], FeedInTariffTibber)
assert isinstance(prediction.providers[10], LoadAkkudoktor)
assert isinstance(prediction.providers[11], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[12], LoadVrm)
assert isinstance(prediction.providers[13], LoadImport)
assert isinstance(prediction.providers[14], PVForecastAkkudoktor)
assert isinstance(prediction.providers[15], PVForecastVrm)
assert isinstance(prediction.providers[16], PVForecastPVNode)
assert isinstance(prediction.providers[17], PVForecastForecastSolar)
assert isinstance(prediction.providers[18], PVForecastSolcast)
assert isinstance(prediction.providers[19], PVForecastImport)
assert isinstance(prediction.providers[20], WeatherBrightSky)
assert isinstance(prediction.providers[21], WeatherClearOutside)
assert isinstance(prediction.providers[22], WeatherOpenMeteo)
assert isinstance(prediction.providers[23], WeatherImport)
def test_provider_by_id(prediction, forecast_providers):
@@ -139,7 +145,9 @@ def test_prediction_repr(prediction):
assert "ElecPriceFixed" in result
assert "ElecPriceImport" in result
assert "FeedInTariffFixed" in result
assert "FeedInTariffAkkudoktor" in result
assert "FeedInTariffImport" in result
assert "FeedInTariffTibber" in result
assert "LoadAkkudoktor" in result
assert "LoadVrm" in result
assert "LoadImport" in result