mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 12:46:38 +00:00
Add SMARD quarter-hour price provider
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user