diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e60452..d66b69c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased ### Added +- Add `FeedInTariffAkkudoktor`, using raw hourly Akkudoktor/aWATTar day-ahead market prices as + feed-in tariff data without import charges or VAT. Quarter-hour optimization holds each hourly + value constant for four slots. +- Add `FeedInTariffTibber`, using Tibber's native `QUARTER_HOURLY` spot-price component as a + strict 15-minute feed-in tariff. Hourly API responses are rejected instead of expanded. - Flexible consumers (home appliances): schedule any number of consumers via `devices.home_appliances`, each with a unique `device_id`. Every consumer defines its load **either** as an explicit power profile (`load_profile_power_w` at @@ -67,6 +72,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). hourly appliance) and `result.Home_appliance_wh_per_hour` (aggregate over all appliances) are deprecated in favour of `appliance_starts` and `result.home_appliance_energy_wh`. +### Fixed +- FeedInTariffEnergyCharts no longer aborts the whole prediction/optimization when the + Energy-Charts API is briefly unreachable: transient timeouts/connection errors are + retried (with a (connect, read) timeout of (5, 60) s), and if a fetch still fails while + historical data exists, the existing history is kept and the remaining slots are + extrapolated via ETS instead of failing. A genuine cold start (no data at all) still + fails. +- The deprecated `/gesamtlast` endpoint no longer forces a full provider refresh on every + call. Forcing bypassed the provider caches and hammered external APIs, so a single flaky + provider could 404 the whole load prediction. It now defaults to a cache-aware update and + accepts an optional `force_update` flag in the request body for callers that still want + to force. + ## 0.3.0 (2026-03-17) Akkudoktor-EOS can now be run as Home Assistant add-on and standalone. diff --git a/docs/akkudoktoreos/prediction.md b/docs/akkudoktoreos/prediction.md index 509b4985..0cf41993 100644 --- a/docs/akkudoktoreos/prediction.md +++ b/docs/akkudoktoreos/prediction.md @@ -218,9 +218,12 @@ Configuration options: - `provider`: Feed in tariff provider id of provider to be used. - `FeedInTariffFixed`: Provides fixed feed in tariff values. + - `FeedInTariffAkkudoktor`: Retrieves raw day-ahead market prices from the public + Akkudoktor API without import charges or VAT. - `FeedInTariffEnergyCharts`: Retrieves Energy-Charts day-ahead market prices and extends them to the configured prediction horizon when necessary. - `FeedInTariffImport`: Imports from a file or JSON string or by endpoint data provision. + - `FeedInTariffTibber`: Retrieves Tibber's native quarter-hour energy-price component. - `provider_settings.FeedInTariffFixed.feed_in_tariff_kwh`: Fixed feed-in tariff (€/kWh). - `provider_settings.FeedInTariffEnergyCharts.bidding_zone`: Energy-Charts bidding zone. @@ -229,6 +232,46 @@ Configuration options: - `provider_settings.FeedInTariffImport.import_json`: JSON string containing feed-in tariff prediction data. +### FeedInTariffAkkudoktor Provider + +The `FeedInTariffAkkudoktor` provider uses raw day-ahead market prices from +`https://api.akkudoktor.net/prices` as `feed_in_tariff_wh`. It does not add electricity import +charges or VAT. Published prices are extended to the configured prediction horizon with the same +seasonal ETS or median fallback used by the Akkudoktor electricity-price provider. + +The Akkudoktor endpoint currently forwards hourly market prices from aWATTar. With a 15-minute +optimization interval, EOS holds each hourly price constant for its four quarter-hour slots. This +keeps the slot grid consistent but does not create genuine quarter-hour market prices. + +```json +{ + "feedintariff": { + "direct_marketing_enabled": true, + "provider": "FeedInTariffAkkudoktor" + } +} +``` + +### FeedInTariffTibber Provider + +The `FeedInTariffTibber` provider requests `priceInfo` and `priceInfoRange` with +`resolution: QUARTER_HOURLY` and preserves the native 15-minute timestamps. It uses Tibber's +`energy` spot-price component without the `tax` part or EOS electricity-price charges. The +end-customer `total` component is deliberately ignored. + +The provider deliberately rejects hourly API responses instead of silently repeating them. It +reuses `elecprice.tibber.access_token` and `elecprice.tibber.home_id`, so no duplicate credentials +are needed. + +```json +{ + "feedintariff": { + "direct_marketing_enabled": true, + "provider": "FeedInTariffTibber" + } +} +``` + ### FeedInTariffEnergyCharts Provider The `FeedInTariffEnergyCharts` provider uses the raw Energy-Charts day-ahead market price as the diff --git a/src/akkudoktoreos/optimization/genetic/geneticparams.py b/src/akkudoktoreos/optimization/genetic/geneticparams.py index eff6d1e5..b15a94ac 100644 --- a/src/akkudoktoreos/optimization/genetic/geneticparams.py +++ b/src/akkudoktoreos/optimization/genetic/geneticparams.py @@ -29,6 +29,10 @@ from akkudoktoreos.optimization.genetic.geneticdevices import ( ) from akkudoktoreos.utils.datetimeutil import to_duration +MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS = frozenset( + {"FeedInTariffAkkudoktor", "FeedInTariffEnergyCharts", "FeedInTariffTibber"} +) + # Do not import directly from akkudoktoreos.core.coreabc # EnergyManagementSystemMixin - Creates circular dependency with ems.py # StartMixin - Creates circular dependency with ems.py @@ -161,8 +165,7 @@ class GeneticOptimizationParameters( dishwasher = self.__dict__.get("dishwasher") if dishwasher is not None and self.home_appliances is not None: raise ValueError( - "Provide either 'home_appliances' or the deprecated 'dishwasher', " - "not both." + "Provide either 'home_appliances' or the deprecated 'dishwasher', " "not both." ) appliances = self.home_appliances or [] device_ids = [appliance.device_id for appliance in appliances] @@ -405,7 +408,7 @@ class GeneticOptimizationParameters( # Retry continue if cls.config.feedintariff.direct_marketing_enabled: - if cls.config.feedintariff.provider == "FeedInTariffEnergyCharts": + if cls.config.feedintariff.provider in MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS: try: feed_in_tariff_wh = cls.prediction.key_to_array( key="feed_in_tariff_wh", diff --git a/src/akkudoktoreos/prediction/elecpricetibber.py b/src/akkudoktoreos/prediction/elecpricetibber.py index 3357be9f..1cd498c5 100644 --- a/src/akkudoktoreos/prediction/elecpricetibber.py +++ b/src/akkudoktoreos/prediction/elecpricetibber.py @@ -28,16 +28,19 @@ query TibberPriceInfo { today { startsAt total + energy } tomorrow { startsAt total + energy } } priceInfoRange(resolution: QUARTER_HOURLY, last: 672) { nodes { startsAt total + energy } } } @@ -61,16 +64,19 @@ query TibberPriceInfo { today { startsAt total + energy } tomorrow { startsAt total + energy } } priceInfoRange(resolution: QUARTER_HOURLY, last: 672) { nodes { startsAt total + energy } } } @@ -107,6 +113,7 @@ class TibberPricePoint(PydanticBaseModel): startsAt: str total: float + energy: Optional[float] = None class TibberPriceConnection(PydanticBaseModel): diff --git a/src/akkudoktoreos/prediction/feedintariff.py b/src/akkudoktoreos/prediction/feedintariff.py index 70ad8304..164c013e 100644 --- a/src/akkudoktoreos/prediction/feedintariff.py +++ b/src/akkudoktoreos/prediction/feedintariff.py @@ -5,11 +5,15 @@ from pydantic import Field, computed_field, field_validator from akkudoktoreos.config.configabc import SettingsBaseModel from akkudoktoreos.core.coreabc import get_prediction from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider +from akkudoktoreos.prediction.feedintariffakkudoktor import ( + FeedInTariffAkkudoktorCommonSettings, +) from akkudoktoreos.prediction.feedintariffenergycharts import ( FeedInTariffEnergyChartsCommonSettings, ) from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings +from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibberCommonSettings def elecprice_provider_ids() -> list[str]: @@ -19,7 +23,13 @@ def elecprice_provider_ids() -> list[str]: except: # Prediction may not be initialized # Return at least provider used in example - return ["FeedInTariffFixed", "FeedInTariffEnergyCharts", "FeedInTariffImport"] + return [ + "FeedInTariffAkkudoktor", + "FeedInTariffFixed", + "FeedInTariffEnergyCharts", + "FeedInTariffImport", + "FeedInTariffTibber", + ] return [ provider.provider_id() @@ -31,6 +41,10 @@ def elecprice_provider_ids() -> list[str]: class FeedInTariffCommonProviderSettings(SettingsBaseModel): """Feed In Tariff Prediction Provider Configuration.""" + FeedInTariffAkkudoktor: Optional[FeedInTariffAkkudoktorCommonSettings] = Field( + default=None, + json_schema_extra={"description": "FeedInTariffAkkudoktor settings", "examples": [None]}, + ) FeedInTariffFixed: Optional[FeedInTariffFixedCommonSettings] = Field( default=None, json_schema_extra={"description": "FeedInTariffFixed settings", "examples": [None]}, @@ -43,6 +57,10 @@ class FeedInTariffCommonProviderSettings(SettingsBaseModel): default=None, json_schema_extra={"description": "FeedInTariffImport settings", "examples": [None]}, ) + FeedInTariffTibber: Optional[FeedInTariffTibberCommonSettings] = Field( + default=None, + json_schema_extra={"description": "FeedInTariffTibber settings", "examples": [None]}, + ) class FeedInTariffCommonSettings(SettingsBaseModel): @@ -61,9 +79,11 @@ class FeedInTariffCommonSettings(SettingsBaseModel): json_schema_extra={ "description": "Feed in tariff provider id of provider to be used.", "examples": [ + "FeedInTariffAkkudoktor", "FeedInTariffFixed", "FeedInTariffEnergyCharts", "FeedInTariffImport", + "FeedInTariffTibber", ], }, ) @@ -75,9 +95,11 @@ class FeedInTariffCommonSettings(SettingsBaseModel): "examples": [ # Example 1: Empty/default settings (all providers None) { + "FeedInTariffAkkudoktor": None, "FeedInTariffFixed": None, "FeedInTariffEnergyCharts": None, "FeedInTariffImport": None, + "FeedInTariffTibber": None, }, ], }, diff --git a/src/akkudoktoreos/prediction/feedintariffakkudoktor.py b/src/akkudoktoreos/prediction/feedintariffakkudoktor.py new file mode 100644 index 00000000..9276bee3 --- /dev/null +++ b/src/akkudoktoreos/prediction/feedintariffakkudoktor.py @@ -0,0 +1,163 @@ +"""Provide feed-in tariff data from Akkudoktor market prices.""" + +import time +from datetime import datetime +from typing import Optional + +import numpy as np +import pandas as pd +import requests +from loguru import logger + +from akkudoktoreos.config.configabc import SettingsBaseModel +from akkudoktoreos.core.cache import cache_in_file +from akkudoktoreos.prediction.elecpriceakkudoktor import ( + AkkudoktorElecPrice, + ElecPriceAkkudoktor, +) +from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider +from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration + + +class FeedInTariffAkkudoktorCommonSettings(SettingsBaseModel): + """Settings for the Akkudoktor feed-in tariff provider. + + The public Akkudoktor price endpoint only needs the timezone already + configured in ``general.timezone``, so no provider-specific values are + currently required. + """ + + +class FeedInTariffAkkudoktor(FeedInTariffProvider): + """Use raw Akkudoktor day-ahead market prices as feed-in tariff data. + + The upstream aWATTar endpoint currently supplies hourly values. EOS stores + those source values unchanged; consumers requesting a shorter interval can + forward-fill them onto the optimization grid. + + Electricity import charges and VAT are intentionally not added. Prices + returned in EUR/MWh are converted to EUR/Wh and stored under + ``feed_in_tariff_wh``. + """ + + highest_orig_datetime: Optional[datetime] = None + + def historic_hours_min(self) -> int: + """Keep enough history for weekly seasonal price extrapolation.""" + return 24 * 35 + + @classmethod + def provider_id(cls) -> str: + """Return the unique provider identifier.""" + return "FeedInTariffAkkudoktor" + + @cache_in_file(with_ttl="1 hour") + def _request_forecast(self) -> AkkudoktorElecPrice: + """Fetch market prices from the public Akkudoktor API.""" + if not self.ems_start_datetime: + raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}") + + start_date = to_datetime( + self.ems_start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD" + ) + end_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") + timezone = self.config.general.timezone + url = ( + "https://api.akkudoktor.net/prices" f"?start={start_date}&end={end_date}&tz={timezone}" + ) + + max_attempts = 3 + last_exc: Optional[Exception] = None + for attempt in range(1, max_attempts + 1): + try: + response = requests.get(url, timeout=(5, 20)) + logger.debug("Response from {}: {}", url, response) + response.raise_for_status() + data = ElecPriceAkkudoktor._validate_data(response.content) + self.update_datetime = to_datetime(in_timezone=timezone) + return data + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc: + last_exc = exc + logger.warning( + "Akkudoktor feed-in tariff request attempt {}/{} failed: {}", + attempt, + max_attempts, + exc, + ) + if attempt < max_attempts: + time.sleep(2 * attempt) + + raise last_exc # type: ignore[misc] + + def _parse_data(self, data: AkkudoktorElecPrice) -> pd.Series: + """Convert raw EUR/MWh values to a timezone-aware EUR/Wh series.""" + series = pd.Series(dtype=float) + for value in data.values: + timestamp = to_datetime(value.start, in_timezone=self.config.general.timezone) + series.at[timestamp] = value.marketprice / 1_000_000 + return series + + def _predict_prices(self, history: np.ndarray, hours: int) -> np.ndarray: + """Extend published prices to the configured prediction horizon.""" + predictor = ElecPriceAkkudoktor() + if len(history) > 800: + return predictor._predict_ets(history, seasonal_periods=168, hours=hours) + if len(history) > 168: + return predictor._predict_ets(history, seasonal_periods=24, hours=hours) + if len(history) > 0: + logger.warning( + "Using median fallback for Akkudoktor feed-in tariff with only {} values.", + len(history), + ) + return predictor._predict_median(history, hours=hours) + raise ValueError("No Akkudoktor feed-in tariff data available") + + def _update_data(self, force_update: Optional[bool] = False) -> None: + """Update raw prices and extrapolate any missing horizon values.""" + if not self.ems_start_datetime: + raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}") + + try: + data = self._request_forecast(force_update=force_update) # type: ignore[call-arg] + series = self._parse_data(data) + if series.empty: + raise ValueError("No Akkudoktor feed-in tariff data available") + self.highest_orig_datetime = to_datetime( + series.index.max(), in_timezone=self.config.general.timezone + ) + self.key_from_series("feed_in_tariff_wh", series) + except Exception as exc: + if self.highest_orig_datetime is None: + raise + logger.warning( + "Akkudoktor feed-in tariff update failed ({}); retaining existing data.", + exc, + ) + + if self.highest_orig_datetime is None: + raise ValueError("Highest original datetime not available") + + history = np.asarray( + self.key_to_array( + key="feed_in_tariff_wh", + end_datetime=self.highest_orig_datetime, + fill_method="linear", + ), + dtype=float, + ) + covered_hours = ( + int((self.highest_orig_datetime - self.ems_start_datetime).total_seconds() // 3600) + 1 + ) + needed_hours = self.config.prediction.hours - max(covered_hours, 0) + if needed_hours <= 0: + return + + prediction = self._predict_prices(history, needed_hours) + prediction_series = pd.Series( + data=prediction, + index=[ + self.highest_orig_datetime + to_duration(f"{i + 1} hours") + for i in range(len(prediction)) + ], + ) + self.key_from_series("feed_in_tariff_wh", prediction_series) diff --git a/src/akkudoktoreos/prediction/feedintariffenergycharts.py b/src/akkudoktoreos/prediction/feedintariffenergycharts.py index cd286e6f..fb150bd6 100644 --- a/src/akkudoktoreos/prediction/feedintariffenergycharts.py +++ b/src/akkudoktoreos/prediction/feedintariffenergycharts.py @@ -1,5 +1,6 @@ """Provides feed-in tariff data from Energy-Charts market prices.""" +import time from datetime import datetime from typing import Optional @@ -44,6 +45,10 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): highest_orig_datetime: Optional[datetime] = None + def historic_hours_min(self) -> int: + """Keep enough history for weekly seasonal price extrapolation.""" + return 24 * 35 + @classmethod def provider_id(cls) -> str: """Return the unique identifier for the Energy-Charts feed-in tariff provider.""" @@ -69,12 +74,34 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD") url = f"{source}/price?bzn={self._bidding_zone()}&start={start_date}&end={last_date}" - response = requests.get(url, timeout=30) - logger.debug(f"Response from {url}: {response}") - response.raise_for_status() - energy_charts_data = ElecPriceEnergyCharts._validate_data(response.content) - self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) - return energy_charts_data + + # Retry transient network problems (timeouts / connection resets) a few + # times with a short backoff. Uses a (connect, read) timeout tuple so a + # slow-to-respond API does not block forever but also is not aborted + # after a too-short single read window. + max_attempts = 3 + last_exc: Optional[Exception] = None + for attempt in range(1, max_attempts + 1): + try: + response = requests.get(url, timeout=(5, 60)) + logger.debug(f"Response from {url}: {response}") + response.raise_for_status() + energy_charts_data = ElecPriceEnergyCharts._validate_data(response.content) + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) + return energy_charts_data + except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc: + last_exc = exc + logger.warning( + "Energy-Charts request attempt {}/{} failed: {}", + attempt, + max_attempts, + exc, + ) + if attempt < max_attempts: + time.sleep(2 * attempt) + # All attempts exhausted - re-raise the last transient error so the + # caller (_update_data) can decide whether to fall back to history. + raise last_exc # type: ignore[misc] def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series: series_data = pd.Series(dtype=float) @@ -88,14 +115,29 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): def _predict_prices(self, history, slots: int, slots_per_hour: int): energycharts = ElecPriceEnergyCharts() if len(history) > 800 * slots_per_hour: + logger.info( + "Using weekly seasonal ETS forecast for Energy-Charts feed-in tariff " + "with {} historical values.", + len(history), + ) return energycharts._predict_ets( history, seasonal_periods=168 * slots_per_hour, hours=slots ) if len(history) > 168 * slots_per_hour: + logger.info( + "Using daily seasonal ETS forecast for Energy-Charts feed-in tariff " + "with {} historical values.", + len(history), + ) return energycharts._predict_ets( history, seasonal_periods=24 * slots_per_hour, hours=slots ) if len(history) > 0: + logger.warning( + "Using constant median fallback for Energy-Charts feed-in tariff " + "with only {} historical values.", + len(history), + ) return energycharts._predict_median(history, hours=slots) logger.error("No feed-in tariff data available for Energy-Charts prediction") raise ValueError("No data available") @@ -111,33 +153,70 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider): raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}") past_days = 35 + needs_history_refresh = False if self.highest_orig_datetime: - history_series = self.key_to_series( - key="feed_in_tariff_wh", start_datetime=self.ems_start_datetime + raw_history = self.key_to_series( + key="feed_in_tariff_wh", + end_datetime=to_datetime(self.highest_orig_datetime).add(seconds=1), ) - if not history_series.empty and history_series.index.min() <= self.ems_start_datetime: + + # A later update must not mistake the current forecast window for + # sufficient ETS history. Require the same amount of data that the + # weekly prediction branch below needs; otherwise fetch 35 days + # again and repair an already-truncated in-memory history. + if not raw_history.empty: + resolution_seconds = ElecPriceEnergyCharts._resolution_seconds(raw_history) + slots_per_hour = 3600 // resolution_seconds + needs_history_refresh = len(raw_history) <= 800 * slots_per_hour + else: + needs_history_refresh = True + + if not needs_history_refresh and not force_update: past_days = 0 - needs_update = end > self.highest_orig_datetime + needs_update = ( + bool(force_update) or end > self.highest_orig_datetime or needs_history_refresh + ) else: needs_update = True if needs_update: logger.info( - "Update FeedInTariffEnergyCharts is needed, last in history: {}", + "Update FeedInTariffEnergyCharts is needed, last in history: {}, " + "force_update={}, history_refresh={}", self.highest_orig_datetime, + bool(force_update), + needs_history_refresh, ) start_date = to_datetime( self.ems_start_datetime - to_duration(f"{past_days} days"), as_string="YYYY-MM-DD", ) - energy_charts_data = self._request_forecast( - start_date=start_date, force_update=force_update - ) - series_data = self._parse_data(energy_charts_data) - if series_data.empty: - raise ValueError("No Energy-Charts feed-in tariff data available") - self.highest_orig_datetime = series_data.index.max() - self.key_from_series("feed_in_tariff_wh", series_data) + try: + energy_charts_data = self._request_forecast( + start_date=start_date, force_update=force_update + ) + series_data = self._parse_data(energy_charts_data) + if series_data.empty: + raise ValueError("No Energy-Charts feed-in tariff data available") + self.highest_orig_datetime = series_data.index.max() + self.key_from_series("feed_in_tariff_wh", series_data) + except Exception as exc: + if self.highest_orig_datetime is None: + # Cold start: no cached/historical data to fall back to, so a + # failed fetch is fatal. + raise + # Transient API outage with existing history available: do not + # abort the whole prediction update. Keep the existing history + # and let the ETS/median branch below extrapolate the remaining + # slots, so downstream (e.g. /gesamtlast, optimization) still + # gets a usable feed-in tariff series. + logger.warning( + "Energy-Charts feed-in tariff update failed ({}); keeping " + "existing history until {} and extrapolating the remaining " + "slots via ETS.", + exc, + self.highest_orig_datetime, + ) else: logger.info( "No update FeedInTariffEnergyCharts is needed, last in history: {}", diff --git a/src/akkudoktoreos/prediction/feedintarifftibber.py b/src/akkudoktoreos/prediction/feedintarifftibber.py new file mode 100644 index 00000000..65339403 --- /dev/null +++ b/src/akkudoktoreos/prediction/feedintarifftibber.py @@ -0,0 +1,169 @@ +"""Provide native quarter-hour feed-in prices from the Tibber API.""" + +from datetime import datetime +from typing import Optional + +import numpy as np +import pandas as pd +import requests +from loguru import logger + +from akkudoktoreos.config.configabc import SettingsBaseModel +from akkudoktoreos.core.cache import cache_in_file +from akkudoktoreos.prediction.elecpricetibber import ( + TIBBER_GRAPHQL_URL, + TIBBER_PRICE_QUERY_QUARTER_HOURLY, + ElecPriceTibber, + TibberGraphQLResponse, + TibberPricePoint, +) +from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider +from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration + + +class FeedInTariffTibberCommonSettings(SettingsBaseModel): + """Settings for the Tibber feed-in tariff provider. + + Authentication is shared with ``elecprice.tibber`` so the access token and + home id do not have to be configured twice. + """ + + +class FeedInTariffTibber(FeedInTariffProvider): + """Use Tibber's native quarter-hour energy component as feed-in price. + + Tibber documents ``energy`` as the spot-price component. Unlike the + end-customer ``total`` component it excludes taxes. + """ + + highest_orig_datetime: Optional[datetime] = None + + @classmethod + def provider_id(cls) -> str: + """Return the unique provider identifier.""" + return "FeedInTariffTibber" + + def historic_hours_min(self) -> int: + """Keep enough history for seasonal price extrapolation.""" + return 24 * 35 + + @cache_in_file(with_ttl="1 hour") + def _request_forecast(self) -> TibberGraphQLResponse: + """Request strictly quarter-hourly Tibber prices. + + Unlike the electricity-price provider, this provider deliberately has + no hourly fallback because it promises a native 15-minute signal. + """ + access_token = self.config.elecprice.tibber.access_token + if not access_token: + raise ValueError("Tibber access_token is required") + + response = requests.post( + TIBBER_GRAPHQL_URL, + json={"query": TIBBER_PRICE_QUERY_QUARTER_HOURLY}, + headers={ + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + }, + timeout=30, + ) + logger.debug("Response from Tibber GraphQL API for feed-in tariff: {}", response) + response.raise_for_status() + tibber_data = ElecPriceTibber._validate_data(response.content) + self.update_datetime = to_datetime(in_timezone=self.config.general.timezone) + return tibber_data + + def _price_points(self, response: TibberGraphQLResponse) -> list[TibberPricePoint]: + """Collect historical, today, and tomorrow price points.""" + tibber = ElecPriceTibber() + home = tibber._select_home(response) + subscription = home.currentSubscription + if subscription is None: + raise ValueError("Tibber home has no current subscription") + + points: list[TibberPricePoint] = [] + if subscription.priceInfoRange is not None: + points.extend(subscription.priceInfoRange.nodes) + if subscription.priceInfo is not None: + points.extend(subscription.priceInfo.today) + points.extend(subscription.priceInfo.tomorrow) + if not subscription.priceInfo.tomorrow: + logger.warning("Tibber tomorrow prices not available yet") + return points + + def _parse_data(self, response: TibberGraphQLResponse) -> pd.Series: + """Convert Tibber's EUR/kWh spot-price component to EUR/Wh.""" + series = pd.Series(dtype=float) + for point in self._price_points(response): + if point.energy is None: + raise ValueError("Tibber response does not contain the energy price component") + timestamp = to_datetime(point.startsAt, in_timezone=self.config.general.timezone) + series.at[timestamp] = point.energy / 1000.0 + + if series.empty: + raise ValueError("Tibber response contains no feed-in price points") + return ElecPriceTibber()._normalize_series(series) + + def _update_data(self, force_update: Optional[bool] = False) -> None: + """Store native 15-minute values and forecast missing horizon slots.""" + if not self.ems_start_datetime: + raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}") + + try: + data = self._request_forecast(force_update=force_update) # type: ignore[call-arg] + series = self._parse_data(data) + resolution_seconds = ElecPriceTibber()._resolution_seconds(series) + if resolution_seconds != 900: + raise ValueError( + "FeedInTariffTibber requires native 15-minute prices; " + f"received {resolution_seconds}-second intervals" + ) + self.highest_orig_datetime = to_datetime( + series.index.max(), in_timezone=self.config.general.timezone + ) + self.key_from_series("feed_in_tariff_wh", series) + except Exception as exc: + if self.highest_orig_datetime is None: + raise + logger.warning( + "Tibber feed-in tariff update failed ({}); retaining existing 15-minute data.", + exc, + ) + + if self.highest_orig_datetime is None: + raise ValueError("Highest original datetime not available") + + interval_seconds = 900 + history = np.asarray( + self.key_to_array( + key="feed_in_tariff_wh", + end_datetime=self.highest_orig_datetime, + interval=to_duration(f"{interval_seconds} seconds"), + fill_method="linear", + ), + dtype=float, + ) + covered_slots = 0 + if self.highest_orig_datetime >= self.ems_start_datetime: + covered_slots = ( + int( + (self.highest_orig_datetime - self.ems_start_datetime).total_seconds() + // interval_seconds + ) + + 1 + ) + needed_slots = self.config.prediction.hours * 4 - covered_slots + if needed_slots <= 0: + return + + prediction = ElecPriceTibber()._predict_missing_prices( + history, slots=needed_slots, slots_per_hour=4 + ) + prediction_series = pd.Series( + data=prediction, + index=[ + self.highest_orig_datetime + to_duration(f"{(i + 1) * interval_seconds} seconds") + for i in range(len(prediction)) + ], + ) + self.key_from_series("feed_in_tariff_wh", prediction_series) diff --git a/src/akkudoktoreos/prediction/prediction.py b/src/akkudoktoreos/prediction/prediction.py index 1907c536..12359772 100644 --- a/src/akkudoktoreos/prediction/prediction.py +++ b/src/akkudoktoreos/prediction/prediction.py @@ -36,9 +36,11 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport 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.feedintarifftibber import FeedInTariffTibber from akkudoktoreos.prediction.loadakkudoktor import ( LoadAkkudoktor, LoadAkkudoktorAdjusted, @@ -83,8 +85,10 @@ elecprice_tibber = ElecPriceTibber() elecprice_fixed = ElecPriceFixed() elecprice_import = ElecPriceImport() feedintariff_energy_charts = FeedInTariffEnergyCharts() +feedintariff_akkudoktor = FeedInTariffAkkudoktor() feedintariff_fixed = FeedInTariffFixed() feedintariff_import = FeedInTariffImport() +feedintariff_tibber = FeedInTariffTibber() loadforecast_akkudoktor = LoadAkkudoktor() loadforecast_akkudoktor_adjusted = LoadAkkudoktorAdjusted() loadforecast_vrm = LoadVrm() @@ -110,8 +114,10 @@ def prediction_providers() -> ( ElecPriceFixed, ElecPriceImport, FeedInTariffEnergyCharts, + FeedInTariffAkkudoktor, FeedInTariffFixed, FeedInTariffImport, + FeedInTariffTibber, LoadAkkudoktor, LoadAkkudoktorAdjusted, LoadVrm, @@ -140,8 +146,10 @@ def prediction_providers() -> ( elecprice_fixed, \ elecprice_import, \ feedintariff_energy_charts, \ + feedintariff_akkudoktor, \ feedintariff_fixed, \ feedintariff_import, \ + feedintariff_tibber, \ loadforecast_akkudoktor, \ loadforecast_akkudoktor_adjusted, \ loadforecast_vrm, \ @@ -165,8 +173,10 @@ def prediction_providers() -> ( elecprice_fixed, elecprice_import, feedintariff_energy_charts, + feedintariff_akkudoktor, feedintariff_fixed, feedintariff_import, + feedintariff_tibber, loadforecast_akkudoktor, loadforecast_akkudoktor_adjusted, loadforecast_vrm, @@ -195,8 +205,10 @@ class Prediction(PredictionContainer): ElecPriceFixed, ElecPriceImport, FeedInTariffEnergyCharts, + FeedInTariffAkkudoktor, FeedInTariffFixed, FeedInTariffImport, + FeedInTariffTibber, LoadAkkudoktor, LoadAkkudoktorAdjusted, LoadVrm, diff --git a/src/akkudoktoreos/server/eos.py b/src/akkudoktoreos/server/eos.py index ab8343d8..05bdf3a4 100755 --- a/src/akkudoktoreos/server/eos.py +++ b/src/akkudoktoreos/server/eos.py @@ -1162,6 +1162,7 @@ class GesamtlastRequest(PydanticBaseModel): year_energy: float measured_data: List[Dict[str, Any]] hours: int + force_update: bool = False @app.post("/gesamtlast", tags=["prediction"]) @@ -1230,11 +1231,15 @@ async def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]: energy_mr_values.append(energy_mr) get_measurement().key_from_lists(measurement_key, energy_mr_dates, energy_mr_values) - # Ensure there is only one optimization/ energy management run at a time + # Ensure there is only one optimization/ energy management run at a time. + # Do not force a full provider refresh by default (see request.force_update): + # forcing bypasses the provider caches and hammers external APIs on every + # call, which made a single flaky provider (e.g. Energy-Charts) abort the + # whole load prediction. try: await get_ems().run( mode=EnergyManagementMode.PREDICTION, - force_update=True, + force_update=request.force_update, ) except Exception as e: raise HTTPException( diff --git a/tests/test_elecpricetibber.py b/tests/test_elecpricetibber.py index 85250f9f..9ee42856 100644 --- a/tests/test_elecpricetibber.py +++ b/tests/test_elecpricetibber.py @@ -253,6 +253,7 @@ def test_request_forecast_uses_tibber_graphql_api( assert "priceInfoRange" in kwargs["json"]["query"] assert "QUARTER_HOURLY" in kwargs["json"]["query"] assert "total" in kwargs["json"]["query"] + assert "energy" in kwargs["json"]["query"] assert kwargs["timeout"] == 30 @@ -340,9 +341,7 @@ def test_tibber_update_uses_eos_storage_history_when_api_history_is_missing( assert forecast_call["history_hours"] > 840 -def test_tibber_update_preserves_quarter_hour_resolution_and_slots( - tibber_provider, monkeypatch -): +def test_tibber_update_preserves_quarter_hour_resolution_and_slots(tibber_provider, monkeypatch): """15-minute Tibber prices are stored natively and extrapolated on the slot grid. Proves the resolution-agnostic path: (a) the native 15-min resolution survives diff --git a/tests/test_feedintariffakkudoktor.py b/tests/test_feedintariffakkudoktor.py new file mode 100644 index 00000000..9288054c --- /dev/null +++ b/tests/test_feedintariffakkudoktor.py @@ -0,0 +1,102 @@ +import json +from unittest.mock import Mock, patch + +import pytest + +from akkudoktoreos.core.coreabc import get_ems +from akkudoktoreos.optimization.genetic.geneticparams import ( + MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS, +) +from akkudoktoreos.prediction.elecpriceakkudoktor import AkkudoktorElecPrice +from akkudoktoreos.prediction.feedintariffakkudoktor import FeedInTariffAkkudoktor +from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration + + +@pytest.fixture +def provider(config_eos): + config_eos.merge_settings_from_dict( + { + "elecprice": {"charges_kwh": 0.30}, + "feedintariff": {"provider": "FeedInTariffAkkudoktor"}, + } + ) + value = FeedInTariffAkkudoktor() + value.highest_orig_datetime = None + value.records.clear() + assert value.enabled() + return value + + +@pytest.fixture +def response_data(): + return { + "meta": { + "start_timestamp": "1733871600", + "end_timestamp": "1733958000", + "start": "2024-12-11T00:00:00+01:00", + "end": "2024-12-12T00:00:00+01:00", + }, + "values": [ + { + "start_timestamp": 1733871600, + "end_timestamp": 1733875200, + "start": "2024-12-11T00:00:00+01:00", + "end": "2024-12-11T01:00:00+01:00", + "marketprice": 100.0, + "unit": "Eur/MWh", + "marketpriceEurocentPerKWh": 10.0, + }, + { + "start_timestamp": 1733875200, + "end_timestamp": 1733878800, + "start": "2024-12-11T01:00:00+01:00", + "end": "2024-12-11T02:00:00+01:00", + "marketprice": 200.0, + "unit": "Eur/MWh", + "marketpriceEurocentPerKWh": 20.0, + }, + ], + } + + +def test_provider_is_available(config_eos): + assert "FeedInTariffAkkudoktor" in config_eos.feedintariff.providers + assert "FeedInTariffAkkudoktor" in MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS + + +def test_parse_data_uses_raw_market_price_without_import_charges(provider, response_data): + data = AkkudoktorElecPrice.model_validate(response_data) + series = provider._parse_data(data) + assert series.iloc[0] == pytest.approx(0.0001) + + +def test_hourly_prices_are_held_constant_on_quarter_hour_grid(provider, response_data): + data = AkkudoktorElecPrice.model_validate(response_data) + provider.key_from_series("feed_in_tariff_wh", provider._parse_data(data)) + start = to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin") + + values = provider.key_to_array( + key="feed_in_tariff_wh", + start_datetime=start, + end_datetime=start + to_duration("2 hours"), + interval=to_duration("15 minutes"), + fill_method="ffill", + ) + + assert values.tolist() == pytest.approx([0.0001] * 4 + [0.0002] * 4) + + +@patch("requests.get") +def test_request_uses_akkudoktor_prices_endpoint(mock_get, provider, response_data): + response = Mock() + response.content = json.dumps(response_data) + response.raise_for_status = Mock() + mock_get.return_value = response + get_ems().set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin")) + + provider._request_forecast(force_update=True) + + url = mock_get.call_args[0][0] + assert url.startswith("https://api.akkudoktor.net/prices?") + assert "tz=Europe/Berlin" in url + assert mock_get.call_args.kwargs["timeout"] == (5, 20) diff --git a/tests/test_feedintariffenergycharts.py b/tests/test_feedintariffenergycharts.py index c17e1398..ae82a86a 100644 --- a/tests/test_feedintariffenergycharts.py +++ b/tests/test_feedintariffenergycharts.py @@ -4,10 +4,15 @@ import json from pathlib import Path from unittest.mock import Mock, patch +import numpy as np import pytest +import requests from akkudoktoreos.core.coreabc import get_ems -from akkudoktoreos.prediction.elecpriceenergycharts import EnergyChartsElecPrice +from akkudoktoreos.prediction.elecpriceenergycharts import ( + ElecPriceEnergyCharts, + EnergyChartsElecPrice, +) from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts from akkudoktoreos.utils.datetimeutil import to_datetime @@ -99,3 +104,121 @@ def test_update_data_keeps_quarter_hour_resolution(provider): ) assert len(result) == provider.config.prediction.hours * 4 assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0] + + +def test_repeated_updates_keep_ets_history_and_honor_force_update(provider): + """A later update must retain ETS history and a forced update must fetch again.""" + start = to_datetime(in_timezone="Europe/Berlin").start_of("day") + get_ems().set_start_datetime(start) + provider.config.prediction.hours = 72 + + raw_start = start.subtract(days=35) + raw_end = start.add(days=2) + raw_slots = int((raw_end - raw_start).total_seconds() // 900) + 1 + energy_charts_data = EnergyChartsElecPrice( + license_info="", + unix_seconds=[int(raw_start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)], + price=[50.0 + float(i % 96) for i in range(raw_slots)], + unit="EUR/MWh", + deprecated=False, + ) + + ets_history_lengths = [] + + def fake_ets(history, seasonal_periods, hours): + ets_history_lengths.append((len(history), seasonal_periods)) + return np.full(hours, 0.00005) + + with ( + patch.object(provider, "_request_forecast", return_value=energy_charts_data) as request, + patch.object(ElecPriceEnergyCharts, "_predict_ets", side_effect=fake_ets), + patch.object( + ElecPriceEnergyCharts, + "_predict_median", + side_effect=AssertionError("median fallback must not be used"), + ), + ): + provider.update_data(force_enable=True, force_update=True) + provider.update_data(force_enable=True, force_update=False) + + # Raw prices already cover the Energy-Charts publication window, so the + # second update reuses the retained 35-day history without another request. + assert request.call_count == 1 + assert len(ets_history_lengths) == 2 + assert all(length > 800 * 4 for length, _ in ets_history_lengths) + assert all(seasonal_periods == 168 * 4 for _, seasonal_periods in ets_history_lengths) + + provider.update_data(force_enable=True, force_update=True) + + # force_update must bypass the provider's own "no update needed" decision. + assert request.call_count == 2 + assert provider.historic_hours_min() == 24 * 35 + + +def test_request_forecast_retries_transient_errors(provider, sample_energycharts_json): + """A transient timeout is retried; a later success is returned (Fix D).""" + get_ems().set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin")) + + ok_response = Mock() + ok_response.status_code = 200 + ok_response.content = json.dumps(sample_energycharts_json) + ok_response.raise_for_status = Mock() + + with ( + patch("requests.get", side_effect=[requests.exceptions.ReadTimeout("t1"), ok_response]) as get_mock, + patch("akkudoktoreos.prediction.feedintariffenergycharts.time.sleep", return_value=None), + ): + provider._request_forecast(start_date="2024-12-10", force_update=True) + + assert get_mock.call_count == 2 + + +def test_update_data_falls_back_to_history_on_fetch_error(provider): + """A transient fetch error must not abort the update when history exists (Fix A).""" + start = to_datetime(in_timezone="Europe/Berlin").start_of("day") + get_ems().set_start_datetime(start) + provider.config.prediction.hours = 48 + + raw_start = start.subtract(days=35) + raw_slots = int((start.add(days=2) - raw_start).total_seconds() // 900) + 1 + energy_charts_data = EnergyChartsElecPrice( + license_info="", + unix_seconds=[int(raw_start.add(minutes=15 * i).timestamp()) for i in range(raw_slots)], + price=[50.0 + float(i % 96) for i in range(raw_slots)], + unit="EUR/MWh", + deprecated=False, + ) + + def fake_predict(history, slots, slots_per_hour): + return np.full(slots, 0.00005) + + with patch.object(provider, "_predict_prices", side_effect=fake_predict): + # First: successful update seeds history and highest_orig_datetime. + with patch.object(provider, "_request_forecast", return_value=energy_charts_data): + provider.update_data(force_enable=True, force_update=True) + assert provider.highest_orig_datetime is not None + last_good = provider.highest_orig_datetime + + # Second: API times out. With existing history the update must NOT raise + # and the retained history must be kept. + with patch.object( + provider, "_request_forecast", side_effect=requests.exceptions.ReadTimeout("boom") + ): + provider.update_data(force_enable=True, force_update=True) + + # Fix A: the update did not abort (we got here) and the retained history is + # unchanged, so downstream consumers still receive a feed-in tariff series. + assert provider.highest_orig_datetime == last_good + + +def test_update_data_cold_start_fetch_error_raises(provider): + """Without any history a fetch error stays fatal (cold start).""" + start = to_datetime(in_timezone="Europe/Berlin").start_of("day") + get_ems().set_start_datetime(start) + assert provider.highest_orig_datetime is None + + with patch.object( + provider, "_request_forecast", side_effect=requests.exceptions.ReadTimeout("boom") + ): + with pytest.raises(requests.exceptions.ReadTimeout): + provider.update_data(force_enable=True, force_update=True) diff --git a/tests/test_feedintarifftibber.py b/tests/test_feedintarifftibber.py new file mode 100644 index 00000000..9e46e697 --- /dev/null +++ b/tests/test_feedintarifftibber.py @@ -0,0 +1,130 @@ +"""Tests for the native quarter-hour Tibber feed-in tariff provider.""" + +import json +from unittest.mock import Mock, patch + +import pytest + +from akkudoktoreos.core.coreabc import get_ems +from akkudoktoreos.optimization.genetic.geneticparams import ( + MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS, +) +from akkudoktoreos.prediction.elecpricetibber import TibberGraphQLResponse +from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber +from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration + + +def _point(starts_at: str, energy: float, total: float = 0.40) -> dict[str, object]: + return {"startsAt": starts_at, "energy": energy, "total": total} + + +def _payload(points: list[dict[str, object]]) -> dict[str, object]: + return { + "data": { + "viewer": { + "homes": [ + { + "id": "home-1", + "currentSubscription": { + "priceInfo": {"today": points[:4], "tomorrow": points[4:]}, + "priceInfoRange": {"nodes": points}, + }, + } + ] + } + } + } + + +@pytest.fixture +def quarter_hour_points(): + return [ + _point(f"2026-07-15T0{index // 4}:{(index % 4) * 15:02d}:00+02:00", 0.10 + index / 100) + for index in range(8) + ] + + +@pytest.fixture +def provider(config_eos): + FeedInTariffTibber.reset_instance() + config_eos.merge_settings_from_dict( + { + "elecprice": {"tibber": {"access_token": "token-123", "home_id": "home-1"}}, + "feedintariff": { + "direct_marketing_enabled": True, + "provider": "FeedInTariffTibber", + }, + "prediction": {"hours": 2}, + } + ) + value = FeedInTariffTibber() + value.highest_orig_datetime = None + value.records.clear() + get_ems().set_start_datetime( + to_datetime("2026-07-15T00:00:00+02:00", in_timezone="Europe/Berlin") + ) + return value + + +def test_provider_is_registered_and_used_for_direct_marketing(provider, config_eos): + assert provider.enabled() + assert "FeedInTariffTibber" in config_eos.feedintariff.providers + assert "FeedInTariffTibber" in MARKET_PRICE_FEED_IN_TARIFF_PROVIDERS + + +def test_parse_uses_energy_component_at_native_quarter_hour_resolution( + provider, quarter_hour_points +): + response = TibberGraphQLResponse.model_validate(_payload(quarter_hour_points)) + + series = provider._parse_data(response) + + assert series.tolist() == pytest.approx([(0.10 + index / 100) / 1000 for index in range(8)]) + assert series.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0] + + +@patch("requests.post") +def test_request_is_strictly_quarter_hourly_and_requests_energy( + mock_post, provider, quarter_hour_points +): + response = Mock() + response.content = json.dumps(_payload(quarter_hour_points)).encode() + response.raise_for_status = Mock() + mock_post.return_value = response + + provider._request_forecast(force_update=True) + + query = mock_post.call_args.kwargs["json"]["query"] + assert "priceInfo(resolution: QUARTER_HOURLY)" in " ".join(query.split()) + assert "priceInfoRange(resolution: QUARTER_HOURLY" in " ".join(query.split()) + assert "energy" in query + assert mock_post.call_args.kwargs["headers"]["Authorization"] == "Bearer token-123" + + +def test_update_keeps_four_distinct_prices_per_hour(provider, quarter_hour_points, monkeypatch): + response = TibberGraphQLResponse.model_validate(_payload(quarter_hour_points)) + monkeypatch.setattr(provider, "_request_forecast", lambda **_: response) + + provider._update_data(force_update=True) + + start = to_datetime("2026-07-15T00:00:00+02:00", in_timezone="Europe/Berlin") + prices = provider.key_to_array( + key="feed_in_tariff_wh", + start_datetime=start, + end_datetime=start + to_duration("2 hours"), + interval=to_duration("15 minutes"), + fill_method="ffill", + ) + assert prices.tolist() == pytest.approx([(0.10 + index / 100) / 1000 for index in range(8)]) + + +def test_update_rejects_hourly_tibber_data(provider, monkeypatch): + hourly = [ + _point("2026-07-15T00:00:00+02:00", 0.10), + _point("2026-07-15T01:00:00+02:00", 0.11), + ] + response = TibberGraphQLResponse.model_validate(_payload(hourly)) + monkeypatch.setattr(provider, "_request_forecast", lambda **_: response) + + with pytest.raises(ValueError, match="requires native 15-minute prices"): + provider._update_data(force_update=True) diff --git a/tests/test_prediction.py b/tests/test_prediction.py index 9da3e0e8..987c8c6e 100644 --- a/tests/test_prediction.py +++ b/tests/test_prediction.py @@ -7,9 +7,11 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport 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.feedintarifftibber import FeedInTariffTibber from akkudoktoreos.prediction.loadakkudoktor import ( LoadAkkudoktor, LoadAkkudoktorAdjusted, @@ -48,8 +50,10 @@ def forecast_providers(): ElecPriceFixed(), ElecPriceImport(), FeedInTariffEnergyCharts(), + FeedInTariffAkkudoktor(), FeedInTariffFixed(), FeedInTariffImport(), + FeedInTariffTibber(), LoadAkkudoktor(), LoadAkkudoktorAdjusted(), LoadVrm(), @@ -102,22 +106,24 @@ def test_provider_sequence(prediction): assert isinstance(prediction.providers[3], ElecPriceFixed) assert isinstance(prediction.providers[4], ElecPriceImport) assert isinstance(prediction.providers[5], FeedInTariffEnergyCharts) - assert isinstance(prediction.providers[6], FeedInTariffFixed) - assert isinstance(prediction.providers[7], FeedInTariffImport) - assert isinstance(prediction.providers[8], LoadAkkudoktor) - assert isinstance(prediction.providers[9], LoadAkkudoktorAdjusted) - assert isinstance(prediction.providers[10], LoadVrm) - assert isinstance(prediction.providers[11], LoadImport) - assert isinstance(prediction.providers[12], PVForecastAkkudoktor) - assert isinstance(prediction.providers[13], PVForecastVrm) - assert isinstance(prediction.providers[14], PVForecastPVNode) - assert isinstance(prediction.providers[15], PVForecastForecastSolar) - assert isinstance(prediction.providers[16], PVForecastSolcast) - assert isinstance(prediction.providers[17], PVForecastImport) - assert isinstance(prediction.providers[18], WeatherBrightSky) - assert isinstance(prediction.providers[19], WeatherClearOutside) - assert isinstance(prediction.providers[20], WeatherOpenMeteo) - assert isinstance(prediction.providers[21], WeatherImport) + 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) def test_provider_by_id(prediction, forecast_providers): @@ -139,7 +145,9 @@ def test_prediction_repr(prediction): assert "ElecPriceFixed" in result assert "ElecPriceImport" in result assert "FeedInTariffFixed" in result + assert "FeedInTariffAkkudoktor" in result assert "FeedInTariffImport" in result + assert "FeedInTariffTibber" in result assert "LoadAkkudoktor" in result assert "LoadVrm" in result assert "LoadImport" in result