mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-31 12:46:38 +00:00
Add resilient market feed-in tariff providers
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
@@ -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: {}",
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user