fix: surface DVhub feed-in tariff update failures (#1357)

* fix: surface DVhub feed-in tariff update failures

* test: annotate DVhub sample records for mypy

* fix: require real feed-in tariff coverage in GENETIC0

* test: satisfy GENETIC0 tariff coverage checks

* test: patch DVhub storage method on its class
This commit is contained in:
Normann
2026-10-11 08:39:01 +02:00
committed by GitHub
parent fca07a0c62
commit e203a81666
5 changed files with 184 additions and 61 deletions
+25 -1
View File
@@ -404,7 +404,31 @@ No fees are applied, even if given by the `ElecFee` prediction.
### FeedInTariffDvhubOnline Provider ### FeedInTariffDvhubOnline Provider
TBD `FeedInTariffDvhubOnline` loads day-ahead prices from the dvhub.online `/api/prices`
endpoint for the configured bidding zone. It stores the published 15-minute slots
as `feed_in_tariff_wh` in EUR/Wh, converted from the API's EUR/MWh prices. These
are raw market prices; no import charges or VAT are added.
Only published prices are stored. If the data does not cover the configured
GENETIC or GENETIC0 control horizon, automatic optimization cancels with a
missing tariff error instead of extending the final known price. Request, response, and storage
errors also fail the update, including when older prices are already stored.
Set the control horizon within the published day-ahead window to use this
provider for automatic optimization.
Example configuration:
```json
{
"feedintariff": {
"provider": "FeedInTariffDvhubOnline",
"dvhubonline": {
"base_url": "https://dvhub.online",
"zone": "DE-LU"
}
}
}
```
### FeedInTariffEnergyCharts Provider ### FeedInTariffEnergyCharts Provider
@@ -8,6 +8,7 @@ It also provides a method to assemble these parameters from predictions,
forecasts, and fallback defaults, preparing them for optimization runs. forecasts, and fallback defaults, preparing them for optimization runs.
""" """
import math
from typing import Optional, Union from typing import Optional, Union
from loguru import logger from loguru import logger
@@ -36,6 +37,7 @@ from akkudoktoreos.devices.genetic0.genetic0homeappliance import (
) )
from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0InverterParameters from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0InverterParameters
from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
from akkudoktoreos.utils.datetimeutil import to_duration from akkudoktoreos.utils.datetimeutil import to_duration
# Do not import directly from akkudoktoreos.core.coreabc # Do not import directly from akkudoktoreos.core.coreabc
@@ -506,41 +508,28 @@ class Genetic0OptimizationParameters(
) )
# Retry # Retry
continue continue
try: tariff_values = await bounded_forecast_array(
array = await cls.prediction.key_to_array( cls.prediction,
key="feed_in_tariff_wh", key="feed_in_tariff_wh",
start_datetime=parameter_start_datetime, start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime, end_datetime=parameter_end_datetime,
interval=interval, interval=interval,
fill_method="ffill", )
first = int(
(ems.start_datetime - parameter_start_datetime).total_seconds()
/ interval.total_seconds()
)
last = first + cls.config.optimization.genetic0.horizon_hours
if last > len(tariff_values) or not all(
math.isfinite(value) for value in tariff_values[first:last]
):
logger.error(
"Missing feed-in tariff within the GENETIC0 control horizon; "
"canceling optimization with provider {}.",
cls.config.feedintariff.provider,
) )
feed_in_tariff_wh = array.tolist() return None
except Exception as e: feed_in_tariff_wh = tariff_values.tolist()
logger.info(
"No feed in tariff forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.merge_settings_from_dict(
{
"feedintariff": {
"provider": "FeedInTariffFixed",
"feedintarifffixed": {
"feed_in_tariff_amt_kwh": {
"windows": [
{
"start_time": "00:00",
"duration": "24 hours",
"value": 0.078,
},
],
},
},
},
}
)
# Retry
continue
# Add device data # Add device data
@@ -42,8 +42,8 @@ class FeedInTariffDvhubOnline(FeedInTariffProvider):
market price is stored as ``feed_in_tariff_wh`` (EUR/Wh) — like market price is stored as ``feed_in_tariff_wh`` (EUR/Wh) — like
``FeedInTariffEnergyCharts`` this intentionally adds no import charges or ``FeedInTariffEnergyCharts`` this intentionally adds no import charges or
VAT, so the series is the direct-marketing revenue the optimizer needs. VAT, so the series is the direct-marketing revenue the optimizer needs.
Slots beyond the published day-ahead horizon are left to the consumer's Only published slots are stored. Automatic optimization requires real
forward-fill (same behaviour as ``FeedInTariffImport``). tariff coverage for the GENETIC or GENETIC0 control horizon.
""" """
highest_orig_datetime: Optional[datetime] = None highest_orig_datetime: Optional[datetime] = None
@@ -124,24 +124,11 @@ class FeedInTariffDvhubOnline(FeedInTariffProvider):
) )
end_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") end_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
try: dvhub_data = self._request_forecast(
dvhub_data = self._request_forecast( start_date=start_date, end_date=end_date, force_update=force_update
start_date=start_date, end_date=end_date, force_update=force_update ) # type: ignore[call-arg]
) # type: ignore[call-arg] series_data = self._parse_data(dvhub_data)
series_data = self._parse_data(dvhub_data) if series_data.empty:
if series_data.empty: raise ValueError("No dvhub.online feed-in tariff data available")
raise ValueError("No dvhub.online feed-in tariff data available") await self.key_from_series("feed_in_tariff_wh", series_data)
self.highest_orig_datetime = series_data.index.max() self.highest_orig_datetime = series_data.index.max()
await self.key_from_series("feed_in_tariff_wh", series_data)
except Exception as exc:
if self.highest_orig_datetime is None:
# Cold start: nothing to fall back to — a failed fetch is fatal.
raise
# Transient outage with existing history: keep the history and let
# downstream forward-fill cover the remaining slots.
logger.warning(
"dvhub.online feed-in tariff update failed ({}); keeping existing "
"history until {}.",
exc,
self.highest_orig_datetime,
)
+61 -3
View File
@@ -1,16 +1,20 @@
"""Tests for the dvhub.online feed-in tariff provider.""" """Tests for the dvhub.online feed-in tariff provider."""
import os import os
from unittest.mock import MagicMock, patch from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import requests
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.feedintariffdvhubonline import ( from akkudoktoreos.prediction.feedintariffdvhubonline import (
FeedInTariffDvhubOnline, FeedInTariffDvhubOnline,
FeedInTariffDvhubOnlineCommonSettings, FeedInTariffDvhubOnlineCommonSettings,
) )
from akkudoktoreos.utils.datetimeutil import to_datetime
SAMPLE = { SAMPLE: dict[str, list[dict[str, Any]]] = {
"data": [ "data": [
{"ts": "2026-07-19T12:00:00.000Z", "price": 42.5}, # EUR/MWh {"ts": "2026-07-19T12:00:00.000Z", "price": 42.5}, # EUR/MWh
{"ts": "2026-07-19T12:15:00.000Z", "price": -5.69}, # negative slot {"ts": "2026-07-19T12:15:00.000Z", "price": -5.69}, # negative slot
@@ -23,9 +27,11 @@ SAMPLE = {
@pytest.fixture @pytest.fixture
def provider(config_eos): def provider(config_eos):
config_eos.merge_settings_from_dict( config_eos.merge_settings_from_dict(
{"feedintariff": {"provider": "FeedInTariffDvhubOnline"}} {"feedintariff": {"provider": "FeedInTariffDvhubOnline"}, "prediction": {"hours": 2}}
) )
get_ems().set_start_datetime(to_datetime("2026-07-19T12:00:00+00:00"))
provider = FeedInTariffDvhubOnline() provider = FeedInTariffDvhubOnline()
provider.highest_orig_datetime = None
assert provider.enabled() assert provider.enabled()
provider._db_reset_state() provider._db_reset_state()
return provider return provider
@@ -61,6 +67,58 @@ class TestFeedInTariffDvhubOnline:
start_date="2026-07-19", end_date="2026-07-21", force_update=True start_date="2026-07-19", end_date="2026-07-21", force_update=True
) )
@pytest.mark.asyncio
async def test_update_stores_only_published_slots(self, provider, monkeypatch):
monkeypatch.setattr(provider, "_request_forecast", lambda **_: SAMPLE)
await provider._update_data(force_update=True)
stored = await provider.key_to_raw_series(key="feed_in_tariff_wh")
assert len(stored) == 2
assert stored.tolist() == pytest.approx([42.5 / 1_000_000, -5.69 / 1_000_000])
assert provider.highest_orig_datetime == to_datetime(SAMPLE["data"][1]["ts"])
@pytest.mark.asyncio
@pytest.mark.parametrize(
"error",
[requests.HTTPError("400 Bad Request"), ValueError("invalid JSON"), RuntimeError("bug")],
)
async def test_update_propagates_failure_despite_existing_history(
self, provider, monkeypatch, error
):
previous = to_datetime(SAMPLE["data"][0]["ts"])
provider.highest_orig_datetime = previous
def fail(**kwargs):
raise error
monkeypatch.setattr(provider, "_request_forecast", fail)
with pytest.raises(type(error), match=str(error)):
await provider._update_data(force_update=True)
assert provider.highest_orig_datetime == previous
@pytest.mark.asyncio
async def test_update_rejects_empty_response_with_existing_history(self, provider, monkeypatch):
provider.highest_orig_datetime = to_datetime(SAMPLE["data"][0]["ts"])
monkeypatch.setattr(provider, "_request_forecast", lambda **_: {"data": []})
with pytest.raises(ValueError, match="No dvhub.online feed-in tariff data"):
await provider._update_data(force_update=True)
@pytest.mark.asyncio
async def test_update_does_not_mark_failed_storage_as_fresh(self, provider, monkeypatch):
previous = to_datetime(SAMPLE["data"][0]["ts"])
provider.highest_orig_datetime = previous
monkeypatch.setattr(provider, "_request_forecast", lambda **_: SAMPLE)
with patch.object(
FeedInTariffDvhubOnline,
"key_from_series",
new=AsyncMock(side_effect=RuntimeError("storage failed")),
):
with pytest.raises(RuntimeError, match="storage failed"):
await provider._update_data(force_update=True)
assert provider.highest_orig_datetime == previous
@pytest.mark.skipif( @pytest.mark.skipif(
os.environ.get("EOS_DVHUB_ONLINE_LIVE") != "1", os.environ.get("EOS_DVHUB_ONLINE_LIVE") != "1",
+65
View File
@@ -0,0 +1,65 @@
"""GENETIC0 must not extend a published feed-in tariff beyond real coverage."""
from unittest.mock import AsyncMock, Mock, patch
import numpy as np
import pandas as pd
import pytest
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.optimization.genetic0.genetic0params import (
Genetic0OptimizationParameters,
)
from akkudoktoreos.utils.datetimeutil import to_datetime
@pytest.mark.asyncio
@pytest.mark.parametrize("published_hours", [20, 24])
async def test_dvhub_tariff_requires_real_control_horizon(
config_eos, published_hours, caplog
):
config_eos.merge_settings_from_dict(
{
"prediction": {"hours": 48},
"optimization": {"genetic0": {"horizon_hours": 24}},
"feedintariff": {"provider": "FeedInTariffDvhubOnline"},
"elecfee": {"provider": None},
"devices": {
"max_batteries": 0,
"max_electric_vehicles": 0,
"max_inverters": 0,
"max_home_appliances": 0,
},
}
)
start = to_datetime("2026-08-01T00:00:00+00:00")
get_ems().set_start_datetime(start)
prices = pd.Series(
np.arange(published_hours * 4, dtype=float) / 1_000_000,
index=pd.date_range(start, periods=published_hours * 4, freq="15min"),
)
async def read_array(**kwargs):
return np.full(48, 1.0)
prediction = Mock(
update_data=AsyncMock(),
key_to_array=AsyncMock(side_effect=read_array),
key_to_raw_series=AsyncMock(return_value=prices),
)
with patch.object(Genetic0OptimizationParameters, "prediction", prediction):
parameters = await Genetic0OptimizationParameters.prepare()
prediction.update_data.assert_awaited_once()
assert config_eos.feedintariff.provider == "FeedInTariffDvhubOnline"
if published_hours < 24:
assert parameters is None
assert "Missing feed-in tariff within the GENETIC0 control horizon" in caplog.text
else:
assert parameters is not None
tariffs = parameters.ems.feed_in_tariff_per_wh
assert isinstance(tariffs, list)
assert tariffs[:24] == pytest.approx(
[(4 * hour + 1.5) / 1_000_000 for hour in range(24)]
)
assert np.isnan(tariffs[24:]).all()