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:
@@ -7,6 +7,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Added
|
### 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
|
- Flexible consumers (home appliances): schedule any number of consumers via
|
||||||
`devices.home_appliances`, each with a unique `device_id`. Every consumer defines its
|
`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
|
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)
|
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`.
|
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)
|
## 0.3.0 (2026-03-17)
|
||||||
|
|
||||||
Akkudoktor-EOS can now be run as Home Assistant add-on and standalone.
|
Akkudoktor-EOS can now be run as Home Assistant add-on and standalone.
|
||||||
|
|||||||
@@ -218,9 +218,12 @@ Configuration options:
|
|||||||
- `provider`: Feed in tariff provider id of provider to be used.
|
- `provider`: Feed in tariff provider id of provider to be used.
|
||||||
|
|
||||||
- `FeedInTariffFixed`: Provides fixed feed in tariff values.
|
- `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
|
- `FeedInTariffEnergyCharts`: Retrieves Energy-Charts day-ahead market prices and extends
|
||||||
them to the configured prediction horizon when necessary.
|
them to the configured prediction horizon when necessary.
|
||||||
- `FeedInTariffImport`: Imports from a file or JSON string or by endpoint data provision.
|
- `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.FeedInTariffFixed.feed_in_tariff_kwh`: Fixed feed-in tariff (€/kWh).
|
||||||
- `provider_settings.FeedInTariffEnergyCharts.bidding_zone`: Energy-Charts bidding zone.
|
- `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
|
- `provider_settings.FeedInTariffImport.import_json`: JSON string containing feed-in tariff
|
||||||
prediction data.
|
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
|
### FeedInTariffEnergyCharts Provider
|
||||||
|
|
||||||
The `FeedInTariffEnergyCharts` provider uses the raw Energy-Charts day-ahead market price as the
|
The `FeedInTariffEnergyCharts` provider uses the raw Energy-Charts day-ahead market price as the
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ from akkudoktoreos.optimization.genetic.geneticdevices import (
|
|||||||
)
|
)
|
||||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
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
|
# Do not import directly from akkudoktoreos.core.coreabc
|
||||||
# EnergyManagementSystemMixin - Creates circular dependency with ems.py
|
# EnergyManagementSystemMixin - Creates circular dependency with ems.py
|
||||||
# StartMixin - Creates circular dependency with ems.py
|
# StartMixin - Creates circular dependency with ems.py
|
||||||
@@ -161,8 +165,7 @@ class GeneticOptimizationParameters(
|
|||||||
dishwasher = self.__dict__.get("dishwasher")
|
dishwasher = self.__dict__.get("dishwasher")
|
||||||
if dishwasher is not None and self.home_appliances is not None:
|
if dishwasher is not None and self.home_appliances is not None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Provide either 'home_appliances' or the deprecated 'dishwasher', "
|
"Provide either 'home_appliances' or the deprecated 'dishwasher', " "not both."
|
||||||
"not both."
|
|
||||||
)
|
)
|
||||||
appliances = self.home_appliances or []
|
appliances = self.home_appliances or []
|
||||||
device_ids = [appliance.device_id for appliance in appliances]
|
device_ids = [appliance.device_id for appliance in appliances]
|
||||||
@@ -405,7 +408,7 @@ class GeneticOptimizationParameters(
|
|||||||
# Retry
|
# Retry
|
||||||
continue
|
continue
|
||||||
if cls.config.feedintariff.direct_marketing_enabled:
|
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:
|
try:
|
||||||
feed_in_tariff_wh = cls.prediction.key_to_array(
|
feed_in_tariff_wh = cls.prediction.key_to_array(
|
||||||
key="feed_in_tariff_wh",
|
key="feed_in_tariff_wh",
|
||||||
|
|||||||
@@ -28,16 +28,19 @@ query TibberPriceInfo {
|
|||||||
today {
|
today {
|
||||||
startsAt
|
startsAt
|
||||||
total
|
total
|
||||||
|
energy
|
||||||
}
|
}
|
||||||
tomorrow {
|
tomorrow {
|
||||||
startsAt
|
startsAt
|
||||||
total
|
total
|
||||||
|
energy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
|
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
|
||||||
nodes {
|
nodes {
|
||||||
startsAt
|
startsAt
|
||||||
total
|
total
|
||||||
|
energy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,16 +64,19 @@ query TibberPriceInfo {
|
|||||||
today {
|
today {
|
||||||
startsAt
|
startsAt
|
||||||
total
|
total
|
||||||
|
energy
|
||||||
}
|
}
|
||||||
tomorrow {
|
tomorrow {
|
||||||
startsAt
|
startsAt
|
||||||
total
|
total
|
||||||
|
energy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
|
priceInfoRange(resolution: QUARTER_HOURLY, last: 672) {
|
||||||
nodes {
|
nodes {
|
||||||
startsAt
|
startsAt
|
||||||
total
|
total
|
||||||
|
energy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,6 +113,7 @@ class TibberPricePoint(PydanticBaseModel):
|
|||||||
|
|
||||||
startsAt: str
|
startsAt: str
|
||||||
total: float
|
total: float
|
||||||
|
energy: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
class TibberPriceConnection(PydanticBaseModel):
|
class TibberPriceConnection(PydanticBaseModel):
|
||||||
|
|||||||
@@ -5,11 +5,15 @@ from pydantic import Field, computed_field, field_validator
|
|||||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||||
from akkudoktoreos.core.coreabc import get_prediction
|
from akkudoktoreos.core.coreabc import get_prediction
|
||||||
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
|
from akkudoktoreos.prediction.feedintariffabc import FeedInTariffProvider
|
||||||
|
from akkudoktoreos.prediction.feedintariffakkudoktor import (
|
||||||
|
FeedInTariffAkkudoktorCommonSettings,
|
||||||
|
)
|
||||||
from akkudoktoreos.prediction.feedintariffenergycharts import (
|
from akkudoktoreos.prediction.feedintariffenergycharts import (
|
||||||
FeedInTariffEnergyChartsCommonSettings,
|
FeedInTariffEnergyChartsCommonSettings,
|
||||||
)
|
)
|
||||||
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings
|
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixedCommonSettings
|
||||||
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings
|
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImportCommonSettings
|
||||||
|
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibberCommonSettings
|
||||||
|
|
||||||
|
|
||||||
def elecprice_provider_ids() -> list[str]:
|
def elecprice_provider_ids() -> list[str]:
|
||||||
@@ -19,7 +23,13 @@ def elecprice_provider_ids() -> list[str]:
|
|||||||
except:
|
except:
|
||||||
# Prediction may not be initialized
|
# Prediction may not be initialized
|
||||||
# Return at least provider used in example
|
# Return at least provider used in example
|
||||||
return ["FeedInTariffFixed", "FeedInTariffEnergyCharts", "FeedInTariffImport"]
|
return [
|
||||||
|
"FeedInTariffAkkudoktor",
|
||||||
|
"FeedInTariffFixed",
|
||||||
|
"FeedInTariffEnergyCharts",
|
||||||
|
"FeedInTariffImport",
|
||||||
|
"FeedInTariffTibber",
|
||||||
|
]
|
||||||
|
|
||||||
return [
|
return [
|
||||||
provider.provider_id()
|
provider.provider_id()
|
||||||
@@ -31,6 +41,10 @@ def elecprice_provider_ids() -> list[str]:
|
|||||||
class FeedInTariffCommonProviderSettings(SettingsBaseModel):
|
class FeedInTariffCommonProviderSettings(SettingsBaseModel):
|
||||||
"""Feed In Tariff Prediction Provider Configuration."""
|
"""Feed In Tariff Prediction Provider Configuration."""
|
||||||
|
|
||||||
|
FeedInTariffAkkudoktor: Optional[FeedInTariffAkkudoktorCommonSettings] = Field(
|
||||||
|
default=None,
|
||||||
|
json_schema_extra={"description": "FeedInTariffAkkudoktor settings", "examples": [None]},
|
||||||
|
)
|
||||||
FeedInTariffFixed: Optional[FeedInTariffFixedCommonSettings] = Field(
|
FeedInTariffFixed: Optional[FeedInTariffFixedCommonSettings] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
json_schema_extra={"description": "FeedInTariffFixed settings", "examples": [None]},
|
json_schema_extra={"description": "FeedInTariffFixed settings", "examples": [None]},
|
||||||
@@ -43,6 +57,10 @@ class FeedInTariffCommonProviderSettings(SettingsBaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
json_schema_extra={"description": "FeedInTariffImport settings", "examples": [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):
|
class FeedInTariffCommonSettings(SettingsBaseModel):
|
||||||
@@ -61,9 +79,11 @@ class FeedInTariffCommonSettings(SettingsBaseModel):
|
|||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"description": "Feed in tariff provider id of provider to be used.",
|
"description": "Feed in tariff provider id of provider to be used.",
|
||||||
"examples": [
|
"examples": [
|
||||||
|
"FeedInTariffAkkudoktor",
|
||||||
"FeedInTariffFixed",
|
"FeedInTariffFixed",
|
||||||
"FeedInTariffEnergyCharts",
|
"FeedInTariffEnergyCharts",
|
||||||
"FeedInTariffImport",
|
"FeedInTariffImport",
|
||||||
|
"FeedInTariffTibber",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -75,9 +95,11 @@ class FeedInTariffCommonSettings(SettingsBaseModel):
|
|||||||
"examples": [
|
"examples": [
|
||||||
# Example 1: Empty/default settings (all providers None)
|
# Example 1: Empty/default settings (all providers None)
|
||||||
{
|
{
|
||||||
|
"FeedInTariffAkkudoktor": None,
|
||||||
"FeedInTariffFixed": None,
|
"FeedInTariffFixed": None,
|
||||||
"FeedInTariffEnergyCharts": None,
|
"FeedInTariffEnergyCharts": None,
|
||||||
"FeedInTariffImport": 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."""
|
"""Provides feed-in tariff data from Energy-Charts market prices."""
|
||||||
|
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
@@ -44,6 +45,10 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
|
|||||||
|
|
||||||
highest_orig_datetime: Optional[datetime] = None
|
highest_orig_datetime: Optional[datetime] = None
|
||||||
|
|
||||||
|
def historic_hours_min(self) -> int:
|
||||||
|
"""Keep enough history for weekly seasonal price extrapolation."""
|
||||||
|
return 24 * 35
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def provider_id(cls) -> str:
|
def provider_id(cls) -> str:
|
||||||
"""Return the unique identifier for the Energy-Charts feed-in tariff provider."""
|
"""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")
|
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}"
|
url = f"{source}/price?bzn={self._bidding_zone()}&start={start_date}&end={last_date}"
|
||||||
response = requests.get(url, timeout=30)
|
|
||||||
|
# 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}")
|
logger.debug(f"Response from {url}: {response}")
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
energy_charts_data = ElecPriceEnergyCharts._validate_data(response.content)
|
energy_charts_data = ElecPriceEnergyCharts._validate_data(response.content)
|
||||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||||
return energy_charts_data
|
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:
|
def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series:
|
||||||
series_data = pd.Series(dtype=float)
|
series_data = pd.Series(dtype=float)
|
||||||
@@ -88,14 +115,29 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
|
|||||||
def _predict_prices(self, history, slots: int, slots_per_hour: int):
|
def _predict_prices(self, history, slots: int, slots_per_hour: int):
|
||||||
energycharts = ElecPriceEnergyCharts()
|
energycharts = ElecPriceEnergyCharts()
|
||||||
if len(history) > 800 * slots_per_hour:
|
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(
|
return energycharts._predict_ets(
|
||||||
history, seasonal_periods=168 * slots_per_hour, hours=slots
|
history, seasonal_periods=168 * slots_per_hour, hours=slots
|
||||||
)
|
)
|
||||||
if len(history) > 168 * slots_per_hour:
|
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(
|
return energycharts._predict_ets(
|
||||||
history, seasonal_periods=24 * slots_per_hour, hours=slots
|
history, seasonal_periods=24 * slots_per_hour, hours=slots
|
||||||
)
|
)
|
||||||
if len(history) > 0:
|
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)
|
return energycharts._predict_median(history, hours=slots)
|
||||||
logger.error("No feed-in tariff data available for Energy-Charts prediction")
|
logger.error("No feed-in tariff data available for Energy-Charts prediction")
|
||||||
raise ValueError("No data available")
|
raise ValueError("No data available")
|
||||||
@@ -111,25 +153,45 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
|
|||||||
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
|
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
|
||||||
|
|
||||||
past_days = 35
|
past_days = 35
|
||||||
|
needs_history_refresh = False
|
||||||
if self.highest_orig_datetime:
|
if self.highest_orig_datetime:
|
||||||
history_series = self.key_to_series(
|
raw_history = self.key_to_series(
|
||||||
key="feed_in_tariff_wh", start_datetime=self.ems_start_datetime
|
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
|
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:
|
else:
|
||||||
needs_update = True
|
needs_update = True
|
||||||
|
|
||||||
if needs_update:
|
if needs_update:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Update FeedInTariffEnergyCharts is needed, last in history: {}",
|
"Update FeedInTariffEnergyCharts is needed, last in history: {}, "
|
||||||
|
"force_update={}, history_refresh={}",
|
||||||
self.highest_orig_datetime,
|
self.highest_orig_datetime,
|
||||||
|
bool(force_update),
|
||||||
|
needs_history_refresh,
|
||||||
)
|
)
|
||||||
start_date = to_datetime(
|
start_date = to_datetime(
|
||||||
self.ems_start_datetime - to_duration(f"{past_days} days"),
|
self.ems_start_datetime - to_duration(f"{past_days} days"),
|
||||||
as_string="YYYY-MM-DD",
|
as_string="YYYY-MM-DD",
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
energy_charts_data = self._request_forecast(
|
energy_charts_data = self._request_forecast(
|
||||||
start_date=start_date, force_update=force_update
|
start_date=start_date, force_update=force_update
|
||||||
)
|
)
|
||||||
@@ -138,6 +200,23 @@ class FeedInTariffEnergyCharts(FeedInTariffProvider):
|
|||||||
raise ValueError("No Energy-Charts feed-in tariff data available")
|
raise ValueError("No Energy-Charts feed-in tariff data available")
|
||||||
self.highest_orig_datetime = series_data.index.max()
|
self.highest_orig_datetime = series_data.index.max()
|
||||||
self.key_from_series("feed_in_tariff_wh", series_data)
|
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:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
"No update FeedInTariffEnergyCharts is needed, last in history: {}",
|
"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.elecpricefixed import ElecPriceFixed
|
||||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||||
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
|
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
|
||||||
|
from akkudoktoreos.prediction.feedintariffakkudoktor import FeedInTariffAkkudoktor
|
||||||
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
|
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
|
||||||
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
|
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
|
||||||
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
||||||
|
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber
|
||||||
from akkudoktoreos.prediction.loadakkudoktor import (
|
from akkudoktoreos.prediction.loadakkudoktor import (
|
||||||
LoadAkkudoktor,
|
LoadAkkudoktor,
|
||||||
LoadAkkudoktorAdjusted,
|
LoadAkkudoktorAdjusted,
|
||||||
@@ -83,8 +85,10 @@ elecprice_tibber = ElecPriceTibber()
|
|||||||
elecprice_fixed = ElecPriceFixed()
|
elecprice_fixed = ElecPriceFixed()
|
||||||
elecprice_import = ElecPriceImport()
|
elecprice_import = ElecPriceImport()
|
||||||
feedintariff_energy_charts = FeedInTariffEnergyCharts()
|
feedintariff_energy_charts = FeedInTariffEnergyCharts()
|
||||||
|
feedintariff_akkudoktor = FeedInTariffAkkudoktor()
|
||||||
feedintariff_fixed = FeedInTariffFixed()
|
feedintariff_fixed = FeedInTariffFixed()
|
||||||
feedintariff_import = FeedInTariffImport()
|
feedintariff_import = FeedInTariffImport()
|
||||||
|
feedintariff_tibber = FeedInTariffTibber()
|
||||||
loadforecast_akkudoktor = LoadAkkudoktor()
|
loadforecast_akkudoktor = LoadAkkudoktor()
|
||||||
loadforecast_akkudoktor_adjusted = LoadAkkudoktorAdjusted()
|
loadforecast_akkudoktor_adjusted = LoadAkkudoktorAdjusted()
|
||||||
loadforecast_vrm = LoadVrm()
|
loadforecast_vrm = LoadVrm()
|
||||||
@@ -110,8 +114,10 @@ def prediction_providers() -> (
|
|||||||
ElecPriceFixed,
|
ElecPriceFixed,
|
||||||
ElecPriceImport,
|
ElecPriceImport,
|
||||||
FeedInTariffEnergyCharts,
|
FeedInTariffEnergyCharts,
|
||||||
|
FeedInTariffAkkudoktor,
|
||||||
FeedInTariffFixed,
|
FeedInTariffFixed,
|
||||||
FeedInTariffImport,
|
FeedInTariffImport,
|
||||||
|
FeedInTariffTibber,
|
||||||
LoadAkkudoktor,
|
LoadAkkudoktor,
|
||||||
LoadAkkudoktorAdjusted,
|
LoadAkkudoktorAdjusted,
|
||||||
LoadVrm,
|
LoadVrm,
|
||||||
@@ -140,8 +146,10 @@ def prediction_providers() -> (
|
|||||||
elecprice_fixed, \
|
elecprice_fixed, \
|
||||||
elecprice_import, \
|
elecprice_import, \
|
||||||
feedintariff_energy_charts, \
|
feedintariff_energy_charts, \
|
||||||
|
feedintariff_akkudoktor, \
|
||||||
feedintariff_fixed, \
|
feedintariff_fixed, \
|
||||||
feedintariff_import, \
|
feedintariff_import, \
|
||||||
|
feedintariff_tibber, \
|
||||||
loadforecast_akkudoktor, \
|
loadforecast_akkudoktor, \
|
||||||
loadforecast_akkudoktor_adjusted, \
|
loadforecast_akkudoktor_adjusted, \
|
||||||
loadforecast_vrm, \
|
loadforecast_vrm, \
|
||||||
@@ -165,8 +173,10 @@ def prediction_providers() -> (
|
|||||||
elecprice_fixed,
|
elecprice_fixed,
|
||||||
elecprice_import,
|
elecprice_import,
|
||||||
feedintariff_energy_charts,
|
feedintariff_energy_charts,
|
||||||
|
feedintariff_akkudoktor,
|
||||||
feedintariff_fixed,
|
feedintariff_fixed,
|
||||||
feedintariff_import,
|
feedintariff_import,
|
||||||
|
feedintariff_tibber,
|
||||||
loadforecast_akkudoktor,
|
loadforecast_akkudoktor,
|
||||||
loadforecast_akkudoktor_adjusted,
|
loadforecast_akkudoktor_adjusted,
|
||||||
loadforecast_vrm,
|
loadforecast_vrm,
|
||||||
@@ -195,8 +205,10 @@ class Prediction(PredictionContainer):
|
|||||||
ElecPriceFixed,
|
ElecPriceFixed,
|
||||||
ElecPriceImport,
|
ElecPriceImport,
|
||||||
FeedInTariffEnergyCharts,
|
FeedInTariffEnergyCharts,
|
||||||
|
FeedInTariffAkkudoktor,
|
||||||
FeedInTariffFixed,
|
FeedInTariffFixed,
|
||||||
FeedInTariffImport,
|
FeedInTariffImport,
|
||||||
|
FeedInTariffTibber,
|
||||||
LoadAkkudoktor,
|
LoadAkkudoktor,
|
||||||
LoadAkkudoktorAdjusted,
|
LoadAkkudoktorAdjusted,
|
||||||
LoadVrm,
|
LoadVrm,
|
||||||
|
|||||||
@@ -1162,6 +1162,7 @@ class GesamtlastRequest(PydanticBaseModel):
|
|||||||
year_energy: float
|
year_energy: float
|
||||||
measured_data: List[Dict[str, Any]]
|
measured_data: List[Dict[str, Any]]
|
||||||
hours: int
|
hours: int
|
||||||
|
force_update: bool = False
|
||||||
|
|
||||||
|
|
||||||
@app.post("/gesamtlast", tags=["prediction"])
|
@app.post("/gesamtlast", tags=["prediction"])
|
||||||
@@ -1230,11 +1231,15 @@ async def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
|
|||||||
energy_mr_values.append(energy_mr)
|
energy_mr_values.append(energy_mr)
|
||||||
get_measurement().key_from_lists(measurement_key, energy_mr_dates, energy_mr_values)
|
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:
|
try:
|
||||||
await get_ems().run(
|
await get_ems().run(
|
||||||
mode=EnergyManagementMode.PREDICTION,
|
mode=EnergyManagementMode.PREDICTION,
|
||||||
force_update=True,
|
force_update=request.force_update,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ def test_request_forecast_uses_tibber_graphql_api(
|
|||||||
assert "priceInfoRange" in kwargs["json"]["query"]
|
assert "priceInfoRange" in kwargs["json"]["query"]
|
||||||
assert "QUARTER_HOURLY" in kwargs["json"]["query"]
|
assert "QUARTER_HOURLY" in kwargs["json"]["query"]
|
||||||
assert "total" in kwargs["json"]["query"]
|
assert "total" in kwargs["json"]["query"]
|
||||||
|
assert "energy" in kwargs["json"]["query"]
|
||||||
assert kwargs["timeout"] == 30
|
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
|
assert forecast_call["history_hours"] > 840
|
||||||
|
|
||||||
|
|
||||||
def test_tibber_update_preserves_quarter_hour_resolution_and_slots(
|
def test_tibber_update_preserves_quarter_hour_resolution_and_slots(tibber_provider, monkeypatch):
|
||||||
tibber_provider, monkeypatch
|
|
||||||
):
|
|
||||||
"""15-minute Tibber prices are stored natively and extrapolated on the slot grid.
|
"""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
|
Proves the resolution-agnostic path: (a) the native 15-min resolution survives
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -4,10 +4,15 @@ import json
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
import pytest
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
from akkudoktoreos.core.coreabc import get_ems
|
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.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
|
||||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
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 len(result) == provider.config.prediction.hours * 4
|
||||||
assert result.index.to_series().diff().dropna().dt.total_seconds().unique().tolist() == [900.0]
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
+24
-16
@@ -7,9 +7,11 @@ from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
|||||||
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
|
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
|
||||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||||
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
|
from akkudoktoreos.prediction.elecpricetibber import ElecPriceTibber
|
||||||
|
from akkudoktoreos.prediction.feedintariffakkudoktor import FeedInTariffAkkudoktor
|
||||||
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
|
from akkudoktoreos.prediction.feedintariffenergycharts import FeedInTariffEnergyCharts
|
||||||
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
|
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
|
||||||
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
||||||
|
from akkudoktoreos.prediction.feedintarifftibber import FeedInTariffTibber
|
||||||
from akkudoktoreos.prediction.loadakkudoktor import (
|
from akkudoktoreos.prediction.loadakkudoktor import (
|
||||||
LoadAkkudoktor,
|
LoadAkkudoktor,
|
||||||
LoadAkkudoktorAdjusted,
|
LoadAkkudoktorAdjusted,
|
||||||
@@ -48,8 +50,10 @@ def forecast_providers():
|
|||||||
ElecPriceFixed(),
|
ElecPriceFixed(),
|
||||||
ElecPriceImport(),
|
ElecPriceImport(),
|
||||||
FeedInTariffEnergyCharts(),
|
FeedInTariffEnergyCharts(),
|
||||||
|
FeedInTariffAkkudoktor(),
|
||||||
FeedInTariffFixed(),
|
FeedInTariffFixed(),
|
||||||
FeedInTariffImport(),
|
FeedInTariffImport(),
|
||||||
|
FeedInTariffTibber(),
|
||||||
LoadAkkudoktor(),
|
LoadAkkudoktor(),
|
||||||
LoadAkkudoktorAdjusted(),
|
LoadAkkudoktorAdjusted(),
|
||||||
LoadVrm(),
|
LoadVrm(),
|
||||||
@@ -102,22 +106,24 @@ def test_provider_sequence(prediction):
|
|||||||
assert isinstance(prediction.providers[3], ElecPriceFixed)
|
assert isinstance(prediction.providers[3], ElecPriceFixed)
|
||||||
assert isinstance(prediction.providers[4], ElecPriceImport)
|
assert isinstance(prediction.providers[4], ElecPriceImport)
|
||||||
assert isinstance(prediction.providers[5], FeedInTariffEnergyCharts)
|
assert isinstance(prediction.providers[5], FeedInTariffEnergyCharts)
|
||||||
assert isinstance(prediction.providers[6], FeedInTariffFixed)
|
assert isinstance(prediction.providers[6], FeedInTariffAkkudoktor)
|
||||||
assert isinstance(prediction.providers[7], FeedInTariffImport)
|
assert isinstance(prediction.providers[7], FeedInTariffFixed)
|
||||||
assert isinstance(prediction.providers[8], LoadAkkudoktor)
|
assert isinstance(prediction.providers[8], FeedInTariffImport)
|
||||||
assert isinstance(prediction.providers[9], LoadAkkudoktorAdjusted)
|
assert isinstance(prediction.providers[9], FeedInTariffTibber)
|
||||||
assert isinstance(prediction.providers[10], LoadVrm)
|
assert isinstance(prediction.providers[10], LoadAkkudoktor)
|
||||||
assert isinstance(prediction.providers[11], LoadImport)
|
assert isinstance(prediction.providers[11], LoadAkkudoktorAdjusted)
|
||||||
assert isinstance(prediction.providers[12], PVForecastAkkudoktor)
|
assert isinstance(prediction.providers[12], LoadVrm)
|
||||||
assert isinstance(prediction.providers[13], PVForecastVrm)
|
assert isinstance(prediction.providers[13], LoadImport)
|
||||||
assert isinstance(prediction.providers[14], PVForecastPVNode)
|
assert isinstance(prediction.providers[14], PVForecastAkkudoktor)
|
||||||
assert isinstance(prediction.providers[15], PVForecastForecastSolar)
|
assert isinstance(prediction.providers[15], PVForecastVrm)
|
||||||
assert isinstance(prediction.providers[16], PVForecastSolcast)
|
assert isinstance(prediction.providers[16], PVForecastPVNode)
|
||||||
assert isinstance(prediction.providers[17], PVForecastImport)
|
assert isinstance(prediction.providers[17], PVForecastForecastSolar)
|
||||||
assert isinstance(prediction.providers[18], WeatherBrightSky)
|
assert isinstance(prediction.providers[18], PVForecastSolcast)
|
||||||
assert isinstance(prediction.providers[19], WeatherClearOutside)
|
assert isinstance(prediction.providers[19], PVForecastImport)
|
||||||
assert isinstance(prediction.providers[20], WeatherOpenMeteo)
|
assert isinstance(prediction.providers[20], WeatherBrightSky)
|
||||||
assert isinstance(prediction.providers[21], WeatherImport)
|
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):
|
def test_provider_by_id(prediction, forecast_providers):
|
||||||
@@ -139,7 +145,9 @@ def test_prediction_repr(prediction):
|
|||||||
assert "ElecPriceFixed" in result
|
assert "ElecPriceFixed" in result
|
||||||
assert "ElecPriceImport" in result
|
assert "ElecPriceImport" in result
|
||||||
assert "FeedInTariffFixed" in result
|
assert "FeedInTariffFixed" in result
|
||||||
|
assert "FeedInTariffAkkudoktor" in result
|
||||||
assert "FeedInTariffImport" in result
|
assert "FeedInTariffImport" in result
|
||||||
|
assert "FeedInTariffTibber" in result
|
||||||
assert "LoadAkkudoktor" in result
|
assert "LoadAkkudoktor" in result
|
||||||
assert "LoadVrm" in result
|
assert "LoadVrm" in result
|
||||||
assert "LoadImport" in result
|
assert "LoadImport" in result
|
||||||
|
|||||||
Reference in New Issue
Block a user