mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-13 09:31:56 +00:00
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:
@@ -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,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user