diff --git a/docs/akkudoktoreos/prediction.md b/docs/akkudoktoreos/prediction.md index 7aabbd67..ed94720f 100644 --- a/docs/akkudoktoreos/prediction.md +++ b/docs/akkudoktoreos/prediction.md @@ -404,7 +404,31 @@ No fees are applied, even if given by the `ElecFee` prediction. ### 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 diff --git a/src/akkudoktoreos/optimization/genetic0/genetic0params.py b/src/akkudoktoreos/optimization/genetic0/genetic0params.py index eed85f59..0480e414 100644 --- a/src/akkudoktoreos/optimization/genetic0/genetic0params.py +++ b/src/akkudoktoreos/optimization/genetic0/genetic0params.py @@ -8,6 +8,7 @@ It also provides a method to assemble these parameters from predictions, forecasts, and fallback defaults, preparing them for optimization runs. """ +import math from typing import Optional, Union from loguru import logger @@ -36,6 +37,7 @@ from akkudoktoreos.devices.genetic0.genetic0homeappliance import ( ) from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0InverterParameters from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel +from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array from akkudoktoreos.utils.datetimeutil import to_duration # Do not import directly from akkudoktoreos.core.coreabc @@ -506,41 +508,28 @@ class Genetic0OptimizationParameters( ) # Retry continue - try: - array = await cls.prediction.key_to_array( - key="feed_in_tariff_wh", - start_datetime=parameter_start_datetime, - end_datetime=parameter_end_datetime, - interval=interval, - fill_method="ffill", + tariff_values = await bounded_forecast_array( + cls.prediction, + key="feed_in_tariff_wh", + start_datetime=parameter_start_datetime, + end_datetime=parameter_end_datetime, + interval=interval, + ) + 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() - except Exception as e: - 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 + return None + feed_in_tariff_wh = tariff_values.tolist() # Add device data diff --git a/src/akkudoktoreos/prediction/feedintariffdvhubonline.py b/src/akkudoktoreos/prediction/feedintariffdvhubonline.py index 7632ba90..214b64af 100644 --- a/src/akkudoktoreos/prediction/feedintariffdvhubonline.py +++ b/src/akkudoktoreos/prediction/feedintariffdvhubonline.py @@ -42,8 +42,8 @@ class FeedInTariffDvhubOnline(FeedInTariffProvider): market price is stored as ``feed_in_tariff_wh`` (EUR/Wh) — like ``FeedInTariffEnergyCharts`` this intentionally adds no import charges or 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 - forward-fill (same behaviour as ``FeedInTariffImport``). + Only published slots are stored. Automatic optimization requires real + tariff coverage for the GENETIC or GENETIC0 control horizon. """ 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") - try: - dvhub_data = self._request_forecast( - start_date=start_date, end_date=end_date, force_update=force_update - ) # type: ignore[call-arg] - series_data = self._parse_data(dvhub_data) - if series_data.empty: - raise ValueError("No dvhub.online feed-in tariff data available") - 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, - ) + dvhub_data = self._request_forecast( + start_date=start_date, end_date=end_date, force_update=force_update + ) # type: ignore[call-arg] + series_data = self._parse_data(dvhub_data) + if series_data.empty: + 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() diff --git a/tests/test_feedintariffdvhubonline.py b/tests/test_feedintariffdvhubonline.py index e412e90c..ced6c56e 100644 --- a/tests/test_feedintariffdvhubonline.py +++ b/tests/test_feedintariffdvhubonline.py @@ -1,16 +1,20 @@ """Tests for the dvhub.online feed-in tariff provider.""" import os -from unittest.mock import MagicMock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch import pytest +import requests +from akkudoktoreos.core.coreabc import get_ems from akkudoktoreos.prediction.feedintariffdvhubonline import ( FeedInTariffDvhubOnline, FeedInTariffDvhubOnlineCommonSettings, ) +from akkudoktoreos.utils.datetimeutil import to_datetime -SAMPLE = { +SAMPLE: dict[str, list[dict[str, Any]]] = { "data": [ {"ts": "2026-07-19T12:00:00.000Z", "price": 42.5}, # EUR/MWh {"ts": "2026-07-19T12:15:00.000Z", "price": -5.69}, # negative slot @@ -23,9 +27,11 @@ SAMPLE = { @pytest.fixture def provider(config_eos): 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.highest_orig_datetime = None assert provider.enabled() provider._db_reset_state() return provider @@ -61,6 +67,58 @@ class TestFeedInTariffDvhubOnline: 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( os.environ.get("EOS_DVHUB_ONLINE_LIVE") != "1", diff --git a/tests/test_genetic0_tariff_coverage.py b/tests/test_genetic0_tariff_coverage.py new file mode 100644 index 00000000..4cda3908 --- /dev/null +++ b/tests/test_genetic0_tariff_coverage.py @@ -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()