mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-06-27 08:26:53 +00:00
Add new electricity price provider: Energy-Charts #381 (#590)
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
Some checks failed
docker-build / platform-excludes (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
Close stale pull requests/issues / Find Stale issues and PRs (push) Has been cancelled
* feat(ElecPriceEnergyCharts): Add new electricity price provider: Energy-Charts * feat(ElecPriceEnergyCharts): update data only if needed * test(elecpriceforecast): add test for energycharts * docs(predictions.md): add ElecPriceEnergyCharts Provider Signed-off-by: redmoon2711 <redmoon2711@gmx.de>
This commit is contained in:
parent
9e789e1786
commit
8c56410338
2
.gitignore
vendored
2
.gitignore
vendored
@ -179,7 +179,7 @@ cython_debug/
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
.idea/
|
||||
|
||||
# General
|
||||
.DS_Store
|
||||
|
@ -119,6 +119,7 @@ Configuration options:
|
||||
- `provider`: Electricity price provider id of provider to be used.
|
||||
|
||||
- `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net.
|
||||
- `ElecPriceEnergyCharts`: Retrieves from Energy-Charts.info.
|
||||
- `ElecPriceImport`: Imports from a file or JSON string.
|
||||
|
||||
- `charges_kwh`: Electricity price charges (€/kWh).
|
||||
@ -133,6 +134,14 @@ prices by extrapolating historical price data combined with the most recent actu
|
||||
from Akkudoktor.net. Electricity price charges given in the `charges_kwh` configuration
|
||||
option are added.
|
||||
|
||||
### ElecPriceEnergyCharts Provider
|
||||
|
||||
The `ElecPriceEnergyCharts` provider retrieves electricity prices directly from **Energy-Charts.info**,
|
||||
which supplies price data for the next 24 hours. For periods beyond 24 hours, the provider generates
|
||||
prices by extrapolating historical price data combined with the most recent actual prices obtained
|
||||
from Energy-Charts.info. Electricity price charges specified in the `charges_kwh` configuration option
|
||||
are included in the calculation as `(market price + charges_kwh) * 1.19 VAT`.
|
||||
|
||||
### ElecPriceImport Provider
|
||||
|
||||
The `ElecPriceImport` provider is designed to import electricity prices from a file or a JSON
|
||||
|
262
src/akkudoktoreos/prediction/elecpriceenergycharts.py
Normal file
262
src/akkudoktoreos/prediction/elecpriceenergycharts.py
Normal file
@ -0,0 +1,262 @@
|
||||
"""Retrieves and processes electricity price forecast data from Energy-Charts.
|
||||
|
||||
This module provides classes and mappings to manage electricity price data obtained from the
|
||||
Energy-Charts API, including support for various electricity price attributes such as temperature,
|
||||
humidity, cloud cover, and solar irradiance. The data is mapped to the `ElecPriceDataRecord`
|
||||
format, enabling consistent access to forecasted and historical electricity price attributes.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import requests
|
||||
from loguru import logger
|
||||
from pydantic import ValidationError
|
||||
from statsmodels.tsa.holtwinters import ExponentialSmoothing
|
||||
|
||||
from akkudoktoreos.core.cache import cache_in_file
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
|
||||
class EnergyChartsElecPrice(PydanticBaseModel):
|
||||
license_info: str
|
||||
unix_seconds: List[int]
|
||||
price: List[float]
|
||||
unit: str
|
||||
deprecated: bool
|
||||
|
||||
|
||||
class ElecPriceEnergyCharts(ElecPriceProvider):
|
||||
"""Fetch and process electricity price forecast data from Energy-Charts.
|
||||
|
||||
ElecPriceEnergyCharts is a singleton-based class that retrieves electricity price forecast data
|
||||
from the Energy-Charts API and maps it to `ElecPriceDataRecord` fields, applying
|
||||
any necessary scaling or unit corrections. It manages the forecast over a range
|
||||
of hours into the future and retains historical data.
|
||||
|
||||
Attributes:
|
||||
hours (int, optional): Number of hours in the future for the forecast.
|
||||
historic_hours (int, optional): Number of past hours for retaining data.
|
||||
start_datetime (datetime, optional): Start datetime for forecasts, defaults to the current datetime.
|
||||
end_datetime (datetime, computed): The forecast's end datetime, computed based on `start_datetime` and `hours`.
|
||||
keep_datetime (datetime, computed): The datetime to retain historical data, computed from `start_datetime` and `historic_hours`.
|
||||
|
||||
Methods:
|
||||
provider_id(): Returns a unique identifier for the provider.
|
||||
_request_forecast(): Fetches the forecast from the Energy-Charts API.
|
||||
_update_data(): Processes and updates forecast data from Energy-Charts in ElecPriceDataRecord format.
|
||||
"""
|
||||
|
||||
highest_orig_datetime: Optional[datetime] = None
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the Energy-Charts provider."""
|
||||
return "ElecPriceEnergyCharts"
|
||||
|
||||
@classmethod
|
||||
def _validate_data(cls, json_str: Union[bytes, Any]) -> EnergyChartsElecPrice:
|
||||
"""Validate Energy-Charts Electricity Price forecast data."""
|
||||
try:
|
||||
energy_charts_data = EnergyChartsElecPrice.model_validate_json(json_str)
|
||||
except ValidationError as e:
|
||||
error_msg = ""
|
||||
for error in e.errors():
|
||||
field = " -> ".join(str(x) for x in error["loc"])
|
||||
message = error["msg"]
|
||||
error_type = error["type"]
|
||||
error_msg += f"Field: {field}\nError: {message}\nType: {error_type}\n"
|
||||
logger.error(f"Energy-Charts schema change: {error_msg}")
|
||||
raise ValueError(error_msg)
|
||||
return energy_charts_data
|
||||
|
||||
@cache_in_file(with_ttl="1 hour")
|
||||
def _request_forecast(self, start_date: Optional[str] = None) -> EnergyChartsElecPrice:
|
||||
"""Fetch electricity price forecast data from Energy-Charts API.
|
||||
|
||||
This method sends a request to Energy-Charts API to retrieve forecast data for a specified
|
||||
date range. The response data is parsed and returned as JSON for further processing.
|
||||
|
||||
Returns:
|
||||
dict: The parsed JSON response from Energy-Charts API containing forecast data.
|
||||
|
||||
Raises:
|
||||
ValueError: If the API response does not include expected `electricity price` data.
|
||||
"""
|
||||
source = "https://api.energy-charts.info"
|
||||
if start_date is None:
|
||||
# Try to take data from 5 weeks back for prediction
|
||||
start_date = to_datetime(
|
||||
self.start_datetime - to_duration("35 days"), as_string="YYYY-MM-DD"
|
||||
)
|
||||
|
||||
last_date = to_datetime(self.end_datetime, as_string="YYYY-MM-DD")
|
||||
url = f"{source}/price?bzn=DE-LU&start={start_date}&end={last_date}"
|
||||
response = requests.get(url, timeout=30)
|
||||
logger.debug(f"Response from {url}: {response}")
|
||||
response.raise_for_status() # Raise an error for bad responses
|
||||
energy_charts_data = self._validate_data(response.content)
|
||||
# We are working on fresh data (no cache), report update time
|
||||
self.update_datetime = to_datetime(in_timezone=self.config.general.timezone)
|
||||
return energy_charts_data
|
||||
|
||||
def _parse_data(self, energy_charts_data: EnergyChartsElecPrice) -> pd.Series:
|
||||
# Assumption that all lists are the same length and are ordered chronologically
|
||||
# in ascending order and have the same timestamps.
|
||||
|
||||
# Get charges_kwh in wh
|
||||
charges_wh = (self.config.elecprice.charges_kwh or 0) / 1000
|
||||
|
||||
# Initialize
|
||||
highest_orig_datetime = None # newest datetime from the api after that we want to update.
|
||||
series_data = pd.Series(dtype=float) # Initialize an empty series
|
||||
|
||||
# Iterate over timestamps and prices together
|
||||
for unix_sec, price_eur_per_mwh in zip(
|
||||
energy_charts_data.unix_seconds, energy_charts_data.price
|
||||
):
|
||||
orig_datetime = to_datetime(unix_sec, in_timezone=self.config.general.timezone)
|
||||
|
||||
# Track the latest datetime
|
||||
if highest_orig_datetime is None or orig_datetime > highest_orig_datetime:
|
||||
highest_orig_datetime = orig_datetime
|
||||
|
||||
# Convert EUR/MWh to EUR/Wh, apply charges and VAT if charges > 0
|
||||
if charges_wh > 0:
|
||||
price_wh = ((price_eur_per_mwh / 1_000_000) + charges_wh) * 1.19
|
||||
else:
|
||||
price_wh = price_eur_per_mwh / 1_000_000
|
||||
|
||||
# Store in series
|
||||
series_data.at[orig_datetime] = price_wh
|
||||
|
||||
return series_data
|
||||
|
||||
def _cap_outliers(self, data: np.ndarray, sigma: int = 2) -> np.ndarray:
|
||||
mean = data.mean()
|
||||
std = data.std()
|
||||
lower_bound = mean - sigma * std
|
||||
upper_bound = mean + sigma * std
|
||||
capped_data = data.clip(min=lower_bound, max=upper_bound)
|
||||
return capped_data
|
||||
|
||||
def _predict_ets(self, history: np.ndarray, seasonal_periods: int, hours: int) -> np.ndarray:
|
||||
clean_history = self._cap_outliers(history)
|
||||
model = ExponentialSmoothing(
|
||||
clean_history, seasonal="add", seasonal_periods=seasonal_periods
|
||||
).fit()
|
||||
return model.forecast(hours)
|
||||
|
||||
def _predict_median(self, history: np.ndarray, hours: int) -> np.ndarray:
|
||||
clean_history = self._cap_outliers(history)
|
||||
return np.full(hours, np.median(clean_history))
|
||||
|
||||
def _update_data(
|
||||
self, force_update: Optional[bool] = False
|
||||
) -> None: # tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""Update forecast data in the ElecPriceDataRecord format.
|
||||
|
||||
Retrieves data from Energy-Charts, maps each Energy-Charts field to the corresponding
|
||||
`ElecPriceDataRecord` and applies any necessary scaling.
|
||||
|
||||
The final mapped and processed data is inserted into the sequence as `ElecPriceDataRecord`.
|
||||
"""
|
||||
# New prices are available every day at 14:00
|
||||
now = pd.Timestamp.now(tz=self.config.general.timezone)
|
||||
midnight = now.normalize()
|
||||
hours_ahead = 23 if now.time() < pd.Timestamp("14:00").time() else 47
|
||||
end = midnight + pd.Timedelta(hours=hours_ahead)
|
||||
|
||||
if not self.start_datetime:
|
||||
raise ValueError(f"Start DateTime not set: {self.start_datetime}")
|
||||
|
||||
# Determine if update is needed and how many days
|
||||
past_days = 35
|
||||
if self.highest_orig_datetime:
|
||||
# Try to get lowest datetime in history
|
||||
try:
|
||||
history = self.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
start_datetime=self.start_datetime,
|
||||
fill_method="linear",
|
||||
)
|
||||
# If start_datetime lower then history
|
||||
if history.index.min() <= self.start_datetime:
|
||||
past_days = 1
|
||||
except AttributeError as e:
|
||||
logger.error(f"Energy-Charts no Index in history: {e}")
|
||||
|
||||
needs_update = end > self.highest_orig_datetime
|
||||
else:
|
||||
needs_update = True
|
||||
|
||||
if needs_update:
|
||||
logger.info(
|
||||
f"Update ElecPriceEnergyCharts is needed, last update:{self.highest_orig_datetime}"
|
||||
)
|
||||
# Set Start_date try to take data from 5 weeks back for prediction
|
||||
start_date = to_datetime(
|
||||
self.start_datetime - to_duration(f"{past_days} days"), as_string="YYYY-MM-DD"
|
||||
)
|
||||
# Get Energy-Charts electricity price data
|
||||
energy_charts_data = self._request_forecast(
|
||||
start_date=start_date, force_update=force_update
|
||||
) # type: ignore
|
||||
|
||||
# Parse and store data
|
||||
series_data = self._parse_data(energy_charts_data)
|
||||
self.highest_orig_datetime = series_data.index.max()
|
||||
self.key_from_series("elecprice_marketprice_wh", series_data)
|
||||
else:
|
||||
logger.info(
|
||||
f"No update ElecPriceEnergyCharts is needed last update:{self.highest_orig_datetime}"
|
||||
)
|
||||
|
||||
# Generate history array for prediction
|
||||
history = self.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
end_datetime=self.highest_orig_datetime,
|
||||
fill_method="linear",
|
||||
)
|
||||
|
||||
amount_datasets = len(self.records)
|
||||
if not self.highest_orig_datetime: # mypy fix
|
||||
error_msg = f"Highest original datetime not available: {self.highest_orig_datetime}"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
# some of our data is already in the future, so we need to predict less. If we got less data we increase the prediction hours
|
||||
needed_hours = int(
|
||||
self.config.prediction.hours
|
||||
- ((self.highest_orig_datetime - self.start_datetime).total_seconds() // 3600)
|
||||
)
|
||||
|
||||
if needed_hours <= 0:
|
||||
logger.warning(
|
||||
f"No prediction needed. needed_hours={needed_hours}, hours={self.config.prediction.hours},highest_orig_datetime {self.highest_orig_datetime}, start_datetime {self.start_datetime}"
|
||||
) # this might keep data longer than self.start_datetime + self.config.prediction.hours in the records
|
||||
return
|
||||
|
||||
if amount_datasets > 800: # we do the full ets with seasons of 1 week
|
||||
prediction = self._predict_ets(history, seasonal_periods=168, hours=needed_hours)
|
||||
elif amount_datasets > 168: # not enough data to do seasons of 1 week, but enough for 1 day
|
||||
prediction = self._predict_ets(history, seasonal_periods=24, hours=needed_hours)
|
||||
elif amount_datasets > 0: # not enough data for ets, do median
|
||||
prediction = self._predict_median(history, hours=needed_hours)
|
||||
else:
|
||||
logger.error("No data available for prediction")
|
||||
raise ValueError("No data available")
|
||||
|
||||
# write predictions into the records, update if exist.
|
||||
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("elecprice_marketprice_wh", prediction_series)
|
@ -32,6 +32,7 @@ from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
|
||||
from akkudoktoreos.prediction.loadimport import LoadImport
|
||||
@ -83,6 +84,7 @@ class Prediction(PredictionContainer):
|
||||
providers: List[
|
||||
Union[
|
||||
ElecPriceAkkudoktor,
|
||||
ElecPriceEnergyCharts,
|
||||
ElecPriceImport,
|
||||
LoadAkkudoktor,
|
||||
LoadImport,
|
||||
@ -97,6 +99,7 @@ class Prediction(PredictionContainer):
|
||||
|
||||
# Initialize forecast providers, all are singletons.
|
||||
elecprice_akkudoktor = ElecPriceAkkudoktor()
|
||||
elecprice_energy_charts = ElecPriceEnergyCharts()
|
||||
elecprice_import = ElecPriceImport()
|
||||
load_akkudoktor = LoadAkkudoktor()
|
||||
load_import = LoadImport()
|
||||
@ -114,6 +117,7 @@ def get_prediction() -> Prediction:
|
||||
prediction = Prediction(
|
||||
providers=[
|
||||
elecprice_akkudoktor,
|
||||
elecprice_energy_charts,
|
||||
elecprice_import,
|
||||
load_akkudoktor,
|
||||
load_import,
|
||||
|
208
tests/test_elecpriceenergycharts.py
Normal file
208
tests/test_elecpriceenergycharts.py
Normal file
@ -0,0 +1,208 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import requests
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.cache import CacheFileStore
|
||||
from akkudoktoreos.core.ems import get_ems
|
||||
from akkudoktoreos.prediction.elecpriceakkudoktor import (
|
||||
AkkudoktorElecPrice,
|
||||
AkkudoktorElecPriceValue,
|
||||
ElecPriceAkkudoktor,
|
||||
)
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import (
|
||||
ElecPriceEnergyCharts,
|
||||
EnergyChartsElecPrice,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime
|
||||
|
||||
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
||||
|
||||
FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON = DIR_TESTDATA.joinpath(
|
||||
"elecpriceforecast_energycharts.json"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(monkeypatch, config_eos):
|
||||
"""Fixture to create a ElecPriceProvider instance."""
|
||||
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceEnergyCharts")
|
||||
config_eos.reset_settings()
|
||||
return ElecPriceEnergyCharts()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_energycharts_json():
|
||||
"""Fixture that returns sample forecast data report."""
|
||||
with FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON.open(
|
||||
"r", encoding="utf-8", newline=None
|
||||
) as f_res:
|
||||
input_data = json.load(f_res)
|
||||
return input_data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_store():
|
||||
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
|
||||
return CacheFileStore()
|
||||
|
||||
|
||||
# ------------------------------------------------
|
||||
# General forecast
|
||||
# ------------------------------------------------
|
||||
|
||||
|
||||
def test_singleton_instance(provider):
|
||||
"""Test that ElecPriceForecast behaves as a singleton."""
|
||||
another_instance = ElecPriceEnergyCharts()
|
||||
assert provider is another_instance
|
||||
|
||||
|
||||
def test_invalid_provider(provider, monkeypatch):
|
||||
"""Test requesting an unsupported provider."""
|
||||
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "<invalid>")
|
||||
provider.config.reset_settings()
|
||||
assert not provider.enabled()
|
||||
|
||||
|
||||
# ------------------------------------------------
|
||||
# Akkudoktor
|
||||
# ------------------------------------------------
|
||||
|
||||
|
||||
@patch("akkudoktoreos.prediction.elecpriceenergycharts.logger.error")
|
||||
def test_validate_data_invalid_format(mock_logger, provider):
|
||||
"""Test validation for invalid Energy-Charts data."""
|
||||
invalid_data = '{"invalid": "data"}'
|
||||
with pytest.raises(ValueError):
|
||||
provider._validate_data(invalid_data)
|
||||
mock_logger.assert_called_once_with(mock_logger.call_args[0][0])
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_request_forecast(mock_get, provider, sample_energycharts_json):
|
||||
"""Test requesting forecast from Energy-Charts."""
|
||||
# Mock response object
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.content = json.dumps(sample_energycharts_json)
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
# Test function
|
||||
energy_charts_data = provider._request_forecast()
|
||||
|
||||
assert isinstance(energy_charts_data, EnergyChartsElecPrice)
|
||||
assert energy_charts_data.unix_seconds[0] == 1733785200
|
||||
assert energy_charts_data.price[0] == 92.85
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_update_data(mock_get, provider, sample_energycharts_json, cache_store):
|
||||
"""Test fetching forecast from Energy-Charts."""
|
||||
# Mock response object
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.content = json.dumps(sample_energycharts_json)
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
cache_store.clear(clear_all=True)
|
||||
|
||||
# Call the method
|
||||
ems_eos = get_ems()
|
||||
ems_eos.set_start_datetime(to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin"))
|
||||
provider.update_data(force_enable=True, force_update=True)
|
||||
|
||||
# Assert: Verify the result is as expected
|
||||
mock_get.assert_called_once()
|
||||
assert (
|
||||
len(provider) == 73
|
||||
) # we have 48 datasets in the api response, we want to know 48h into the future. The data we get has already 23h into the future so we need only 25h more. 48+25=73
|
||||
|
||||
# Assert we get hours prioce values by resampling
|
||||
np_price_array = provider.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
start_datetime=provider.start_datetime,
|
||||
end_datetime=provider.end_datetime,
|
||||
)
|
||||
assert len(np_price_array) == provider.total_hours
|
||||
|
||||
|
||||
@patch("requests.get")
|
||||
def test_update_data_with_incomplete_forecast(mock_get, provider):
|
||||
"""Test `_update_data` with incomplete or missing forecast data."""
|
||||
incomplete_data: dict = {"license_info": "", "unix_seconds": [], "price": [], "unit": "", "deprecated": False}
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.content = json.dumps(incomplete_data)
|
||||
mock_get.return_value = mock_response
|
||||
logger.info("The following errors are intentional and part of the test.")
|
||||
with pytest.raises(ValueError):
|
||||
provider._update_data(force_update=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status_code, exception",
|
||||
[(400, requests.exceptions.HTTPError), (500, requests.exceptions.HTTPError), (200, None)],
|
||||
)
|
||||
@patch("requests.get")
|
||||
def test_request_forecast_status_codes(
|
||||
mock_get, provider, sample_energycharts_json, status_code, exception
|
||||
):
|
||||
"""Test handling of various API status codes."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = status_code
|
||||
mock_response.content = json.dumps(sample_energycharts_json)
|
||||
mock_response.raise_for_status.side_effect = (
|
||||
requests.exceptions.HTTPError if exception else None
|
||||
)
|
||||
mock_get.return_value = mock_response
|
||||
if exception:
|
||||
with pytest.raises(exception):
|
||||
provider._request_forecast()
|
||||
else:
|
||||
provider._request_forecast()
|
||||
|
||||
|
||||
@patch("akkudoktoreos.core.cache.CacheFileStore")
|
||||
def test_cache_integration(mock_cache, provider):
|
||||
"""Test caching of 8-day electricity price data."""
|
||||
mock_cache_instance = mock_cache.return_value
|
||||
mock_cache_instance.get.return_value = None # Simulate no cache
|
||||
provider._update_data(force_update=True)
|
||||
mock_cache_instance.create.assert_called_once()
|
||||
mock_cache_instance.get.assert_called_once()
|
||||
|
||||
|
||||
def test_key_to_array_resampling(provider):
|
||||
"""Test resampling of forecast data to NumPy array."""
|
||||
provider.update_data(force_update=True)
|
||||
array = provider.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
start_datetime=provider.start_datetime,
|
||||
end_datetime=provider.end_datetime,
|
||||
)
|
||||
assert isinstance(array, np.ndarray)
|
||||
assert len(array) == provider.total_hours
|
||||
|
||||
|
||||
# ------------------------------------------------
|
||||
# Development Akkudoktor
|
||||
# ------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="For development only")
|
||||
def test_akkudoktor_development_forecast_data(provider):
|
||||
"""Fetch data from real Energy-Charts server."""
|
||||
# Preset, as this is usually done by update_data()
|
||||
provider.start_datetime = to_datetime("2024-10-26 00:00:00")
|
||||
|
||||
energy_charts_data = provider._request_forecast()
|
||||
|
||||
with FILE_TESTDATA_ELECPRICE_ENERGYCHARTS_JSON.open(
|
||||
"w", encoding="utf-8", newline="\n"
|
||||
) as f_out:
|
||||
json.dump(energy_charts_data, f_out, indent=4)
|
@ -2,6 +2,7 @@ import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktor
|
||||
from akkudoktoreos.prediction.loadimport import LoadImport
|
||||
@ -28,6 +29,7 @@ def forecast_providers():
|
||||
"""Fixture for singleton forecast provider instances."""
|
||||
return [
|
||||
ElecPriceAkkudoktor(),
|
||||
ElecPriceEnergyCharts(),
|
||||
ElecPriceImport(),
|
||||
LoadAkkudoktor(),
|
||||
LoadImport(),
|
||||
@ -68,14 +70,15 @@ def test_initialization(prediction, forecast_providers):
|
||||
def test_provider_sequence(prediction):
|
||||
"""Test the provider sequence is maintained in the Prediction instance."""
|
||||
assert isinstance(prediction.providers[0], ElecPriceAkkudoktor)
|
||||
assert isinstance(prediction.providers[1], ElecPriceImport)
|
||||
assert isinstance(prediction.providers[2], LoadAkkudoktor)
|
||||
assert isinstance(prediction.providers[3], LoadImport)
|
||||
assert isinstance(prediction.providers[4], PVForecastAkkudoktor)
|
||||
assert isinstance(prediction.providers[5], PVForecastImport)
|
||||
assert isinstance(prediction.providers[6], WeatherBrightSky)
|
||||
assert isinstance(prediction.providers[7], WeatherClearOutside)
|
||||
assert isinstance(prediction.providers[8], WeatherImport)
|
||||
assert isinstance(prediction.providers[1], ElecPriceEnergyCharts)
|
||||
assert isinstance(prediction.providers[2], ElecPriceImport)
|
||||
assert isinstance(prediction.providers[3], LoadAkkudoktor)
|
||||
assert isinstance(prediction.providers[4], LoadImport)
|
||||
assert isinstance(prediction.providers[5], PVForecastAkkudoktor)
|
||||
assert isinstance(prediction.providers[6], PVForecastImport)
|
||||
assert isinstance(prediction.providers[7], WeatherBrightSky)
|
||||
assert isinstance(prediction.providers[8], WeatherClearOutside)
|
||||
assert isinstance(prediction.providers[9], WeatherImport)
|
||||
|
||||
|
||||
def test_provider_by_id(prediction, forecast_providers):
|
||||
@ -89,6 +92,7 @@ def test_prediction_repr(prediction):
|
||||
result = repr(prediction)
|
||||
assert "Prediction([" in result
|
||||
assert "ElecPriceAkkudoktor" in result
|
||||
assert "ElecPriceEnergyCharts" in result
|
||||
assert "ElecPriceImport" in result
|
||||
assert "LoadAkkudoktor" in result
|
||||
assert "LoadImport" in result
|
||||
|
105
tests/testdata/elecpriceforecast_energycharts.json
vendored
Normal file
105
tests/testdata/elecpriceforecast_energycharts.json
vendored
Normal file
@ -0,0 +1,105 @@
|
||||
{
|
||||
"license_info": "CC BY 4.0 (creativecommons.org/licenses/by/4.0) from Bundesnetzagentur | SMARD.de",
|
||||
"unix_seconds": [
|
||||
1733785200,
|
||||
1733788800,
|
||||
1733792400,
|
||||
1733796000,
|
||||
1733799600,
|
||||
1733803200,
|
||||
1733806800,
|
||||
1733810400,
|
||||
1733814000,
|
||||
1733817600,
|
||||
1733821200,
|
||||
1733824800,
|
||||
1733828400,
|
||||
1733832000,
|
||||
1733835600,
|
||||
1733839200,
|
||||
1733842800,
|
||||
1733846400,
|
||||
1733850000,
|
||||
1733853600,
|
||||
1733857200,
|
||||
1733860800,
|
||||
1733864400,
|
||||
1733868000,
|
||||
1733871600,
|
||||
1733875200,
|
||||
1733878800,
|
||||
1733882400,
|
||||
1733886000,
|
||||
1733889600,
|
||||
1733893200,
|
||||
1733896800,
|
||||
1733900400,
|
||||
1733904000,
|
||||
1733907600,
|
||||
1733911200,
|
||||
1733914800,
|
||||
1733918400,
|
||||
1733922000,
|
||||
1733925600,
|
||||
1733929200,
|
||||
1733932800,
|
||||
1733936400,
|
||||
1733940000,
|
||||
1733943600,
|
||||
1733947200,
|
||||
1733950800,
|
||||
1733954400
|
||||
],
|
||||
"price": [
|
||||
92.85,
|
||||
89.33,
|
||||
86.5,
|
||||
83.7,
|
||||
87.21,
|
||||
89.07,
|
||||
112.06,
|
||||
135.18,
|
||||
169.76,
|
||||
182.14,
|
||||
184.75,
|
||||
186.5,
|
||||
180.62,
|
||||
190.78,
|
||||
189.41,
|
||||
197.64,
|
||||
200.18,
|
||||
202.38,
|
||||
181,
|
||||
180.17,
|
||||
166.6,
|
||||
151.15,
|
||||
137.94,
|
||||
121.37,
|
||||
115.94,
|
||||
108.22,
|
||||
107.31,
|
||||
106.17,
|
||||
108.69,
|
||||
125.31,
|
||||
163.42,
|
||||
295.5,
|
||||
368.97,
|
||||
377.12,
|
||||
348.64,
|
||||
379.99,
|
||||
385.87,
|
||||
404.91,
|
||||
371.96,
|
||||
380.94,
|
||||
437.87,
|
||||
445.08,
|
||||
359.92,
|
||||
341.25,
|
||||
206.82,
|
||||
171.2,
|
||||
150,
|
||||
135.96
|
||||
],
|
||||
"unit": "EUR / MWh",
|
||||
"deprecated": false
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user