Add SMARD quarter-hour price provider

This commit is contained in:
Andreas
2026-08-01 12:21:06 +02:00
parent f7e2ac3619
commit 69ef57d9c9
18 changed files with 1126 additions and 72 deletions
+56
View File
@@ -8,6 +8,7 @@ import requests
from loguru import logger
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.config.configabc import ValueTimeWindowSequence
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecpriceakkudoktor import (
AkkudoktorElecPrice,
@@ -158,6 +159,61 @@ def test_update_data_keeps_quarter_hour_resolution(provider):
assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
def test_parse_data_adds_constant_charges_variable_network_fees_and_vat(provider):
"""Build the gross retail price from market price and the matching Module 3 fee."""
provider.config.elecprice.charges_kwh = None
provider.config.elecprice.charge_components_kwh = {
"electricity_tax": 0.0205,
"concession_fee": 0.0132,
"kwkg_levy": 0.00446,
"section_19_levy": 0.01559,
"offshore_grid_levy": 0.00941,
}
provider.config.elecprice.vat_rate = 1.19
provider.config.elecprice.network_fees_kwh = ValueTimeWindowSequence(
windows=[
{"start_time": "00:00", "duration": "7 hours", "value": 0.0095},
{"start_time": "07:00", "duration": "8 hours", "value": 0.0953},
{"start_time": "15:00", "duration": "5 hours", "value": 0.1565},
{"start_time": "20:00", "duration": "4 hours", "value": 0.0953},
]
)
start = to_datetime("2026-01-15 00:00:00", in_timezone="Europe/Berlin")
timestamps = [start, start.add(hours=7), start.add(hours=15), start.add(hours=20)]
data = EnergyChartsElecPrice(
license_info="",
unix_seconds=[int(timestamp.timestamp()) for timestamp in timestamps],
price=[100.0] * len(timestamps),
unit="EUR/MWh",
deprecated=False,
)
result_kwh = provider._parse_data(data) * 1000
assert result_kwh.iloc[0] == pytest.approx((0.1 + 0.06316 + 0.0095) * 1.19)
assert result_kwh.iloc[1] == pytest.approx((0.1 + 0.06316 + 0.0953) * 1.19)
assert result_kwh.iloc[2] == pytest.approx((0.1 + 0.06316 + 0.1565) * 1.19)
assert result_kwh.iloc[3] == pytest.approx((0.1 + 0.06316 + 0.0953) * 1.19)
def test_market_price_charge_round_trip(provider):
"""Seasonal forecasting can remove and reapply timestamp-dependent retail charges."""
provider.config.elecprice.charges_kwh = None
provider.config.elecprice.charge_components_kwh = {"statutory_charges": 0.06316}
provider.config.elecprice.vat_rate = 1.19
provider.config.elecprice.network_fees_kwh = ValueTimeWindowSequence(
windows=[{"start_time": "15:00", "duration": "5 hours", "value": 0.1565}]
)
timestamp = to_datetime("2026-01-15 16:30:00", in_timezone="Europe/Berlin")
market_price_wh = -0.00002
retail_price_wh = provider._price_with_charges(market_price_wh, timestamp)
assert provider._price_without_charges(retail_price_wh, timestamp) == pytest.approx(
market_price_wh
)
@patch("requests.get")
def test_update_data_with_incomplete_forecast(mock_get, provider):
"""Test `_update_data` with incomplete or missing forecast data."""
+77
View File
@@ -0,0 +1,77 @@
# ruff: noqa: S101
import json
from unittest.mock import Mock, patch
import pytest
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARD
from akkudoktoreos.utils.datetimeutil import to_datetime
@pytest.fixture
def provider(config_eos):
"""Configure and return the direct SMARD singleton provider."""
config_eos.elecprice = ElecPriceCommonSettings(provider="ElecPriceSMARD")
provider = ElecPriceSMARD()
provider.highest_orig_datetime = None
get_ems().set_start_datetime(
to_datetime("2026-07-27 00:00:00", in_timezone="Europe/Berlin")
)
return provider
def _response(payload):
response = Mock()
response.content = json.dumps(payload).encode()
response.raise_for_status.return_value = None
return response
@patch("akkudoktoreos.prediction.elecpricesmard.requests.get")
def test_request_forecast_fetches_index_and_overlapping_chunks(mock_get, provider):
"""SMARD index and weekly chunks are combined, sorted, and stripped of null values."""
chunk_start = 1785103200000
mock_get.side_effect = [
_response({"timestamps": [chunk_start]}),
_response(
{
"meta_data": {"version": 1, "created": 1785500527370},
"series": [
[1785103200000, 86.04],
[1785106800000, None],
[1785110400000, -1.25],
],
}
),
]
result = provider._request_forecast(
start_date="2026-07-27", force_update=True
)
assert result.unix_seconds == [1785103200, 1785110400]
assert result.price == [86.04, -1.25]
assert result.license_info == "CC BY 4.0 Bundesnetzagentur | SMARD.de"
assert mock_get.call_count == 2
assert mock_get.call_args_list[0].args[0].endswith("/4169/DE/index_quarterhour.json")
assert mock_get.call_args_list[1].args[0].endswith(
"/4169/DE/4169_DE_quarterhour_1785103200000.json"
)
def test_chunk_selection_includes_preceding_overlapping_chunk(provider):
"""A range beginning mid-week includes the chunk that started before it."""
index = provider._validate_index(
json.dumps({"timestamps": [1000, 2000, 3000]}).encode()
)
start = to_datetime(2.5, in_timezone="UTC")
end = to_datetime(3.5, in_timezone="UTC")
assert provider._chunk_timestamps(index, start, end) == [2000, 3000]
def test_smard_provider_is_enabled(provider):
assert provider.enabled()
+41
View File
@@ -0,0 +1,41 @@
# ruff: noqa: S101
from unittest.mock import patch
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecpriceenergycharts import EnergyChartsElecPrice
from akkudoktoreos.prediction.elecpricesmard import ElecPriceSMARD
from akkudoktoreos.prediction.feedintariffsmard import FeedInTariffSMARD
from akkudoktoreos.utils.datetimeutil import to_datetime
def test_feed_in_tariff_smard_reuses_raw_smard_market_prices(config_eos):
"""The feed-in provider delegates to SMARD and stores no import-price components."""
config_eos.merge_settings_from_dict(
{
"elecprice": {"provider": "ElecPriceSMARD"},
"feedintariff": {
"direct_marketing_enabled": True,
"provider": "FeedInTariffSMARD",
},
}
)
get_ems().set_start_datetime(
to_datetime("2026-08-01 00:00:00", in_timezone="Europe/Berlin")
)
provider = FeedInTariffSMARD()
data = EnergyChartsElecPrice(
license_info="CC BY 4.0 Bundesnetzagentur | SMARD.de",
unix_seconds=[1785535200],
price=[169.44],
unit="EUR/MWh",
deprecated=False,
)
with patch.object(ElecPriceSMARD, "_request_forecast", return_value=data) as request:
result = provider._request_forecast(start_date="2026-08-01", force_update=True)
assert provider.enabled()
assert result is data
assert provider._parse_data(result).iloc[0] == 169.44 / 1_000_000
request.assert_called_once_with(start_date="2026-08-01", force_update=True)
+30 -22
View File
@@ -6,11 +6,13 @@ 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.elecpricesmard import ElecPriceSMARD
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.feedintariffsmard import FeedInTariffSMARD
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber
from akkudoktoreos.prediction.loadakkudoktor import (
LoadAkkudoktor,
@@ -46,6 +48,7 @@ def forecast_providers():
return [
ElecPriceAkkudoktor(),
ElecPriceEnergyCharts(),
ElecPriceSMARD(),
ElecPriceTibber(),
ElecPriceFixed(),
ElecPriceImport(),
@@ -53,6 +56,7 @@ def forecast_providers():
FeedInTariffAkkudoktor(),
FeedInTariffFixed(),
FeedInTariffImport(),
FeedInTariffSMARD(),
FeedInTariffTibber(),
LoadAkkudoktor(),
LoadAkkudoktorAdjusted(),
@@ -102,28 +106,30 @@ 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], ElecPriceTibber)
assert isinstance(prediction.providers[3], ElecPriceFixed)
assert isinstance(prediction.providers[4], ElecPriceImport)
assert isinstance(prediction.providers[5], FeedInTariffEnergyCharts)
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)
assert isinstance(prediction.providers[2], ElecPriceSMARD)
assert isinstance(prediction.providers[3], ElecPriceTibber)
assert isinstance(prediction.providers[4], ElecPriceFixed)
assert isinstance(prediction.providers[5], ElecPriceImport)
assert isinstance(prediction.providers[6], FeedInTariffEnergyCharts)
assert isinstance(prediction.providers[7], FeedInTariffAkkudoktor)
assert isinstance(prediction.providers[8], FeedInTariffFixed)
assert isinstance(prediction.providers[9], FeedInTariffImport)
assert isinstance(prediction.providers[10], FeedInTariffSMARD)
assert isinstance(prediction.providers[11], FeedInTariffTibber)
assert isinstance(prediction.providers[12], LoadAkkudoktor)
assert isinstance(prediction.providers[13], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[14], LoadVrm)
assert isinstance(prediction.providers[15], LoadImport)
assert isinstance(prediction.providers[16], PVForecastAkkudoktor)
assert isinstance(prediction.providers[17], PVForecastVrm)
assert isinstance(prediction.providers[18], PVForecastPVNode)
assert isinstance(prediction.providers[19], PVForecastForecastSolar)
assert isinstance(prediction.providers[20], PVForecastSolcast)
assert isinstance(prediction.providers[21], PVForecastImport)
assert isinstance(prediction.providers[22], WeatherBrightSky)
assert isinstance(prediction.providers[23], WeatherClearOutside)
assert isinstance(prediction.providers[24], WeatherOpenMeteo)
assert isinstance(prediction.providers[25], WeatherImport)
def test_provider_by_id(prediction, forecast_providers):
@@ -141,12 +147,14 @@ def test_prediction_repr(prediction):
assert "Prediction([" in result
assert "ElecPriceAkkudoktor" in result
assert "ElecPriceEnergyCharts" in result
assert "ElecPriceSMARD" in result
assert "ElecPriceTibber" in result
assert "ElecPriceFixed" in result
assert "ElecPriceImport" in result
assert "FeedInTariffFixed" in result
assert "FeedInTariffAkkudoktor" in result
assert "FeedInTariffImport" in result
assert "FeedInTariffSMARD" in result
assert "FeedInTariffTibber" in result
assert "LoadAkkudoktor" in result
assert "LoadVrm" in result