Files
EOS/tests/test_elecpriceakkudoktor.py
T
Bobby NoelteandClaude Opus 5.5 2c58874c52 fix(cache): use a reproducible function hash for cache_in_file keys
Build the cache_in_file key from an MD5 hash of the wrapped function
instead of inspect.getsource(). Reading the source fails for functions
whose source is not on disk, and the key could go stale when the file
changes after import.

The hash covers what decides the result besides the arguments: the
qualified name, the bytecode with the names of called functions,
attributes and globals, the constants including nested functions, the
defaults and the values captured from an enclosing function. Wrappers made
with functools.wraps are unwrapped. Code objects, functions and sets are
hashed without memory addresses or hash-seed dependent order, so the key
stays the same across restarts and the file cache keeps hitting.

Additional fixes
- Build the key from the arguments bound to the signature, so *args,
  **kwargs and the arguments of wrapped functions are part of it.
- Apply control parameters given at call time to that call only; they no
  longer change the decorator settings for later calls.
- Apply all control parameters of a call, so until_date is no longer
  dropped when force_update is also passed; keep the precedence
  until_datetime > with_ttl > until_date.
- Recreate an unreadable cache file in that call only, instead of
  disabling the cache for the rest of the process.
- Pass force_update in two provider tests that relied on a force_update
  left over from an earlier test.
- Fix "Calback" typo in cache_energy_management_store_callback docstring.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 16:16:26 +01:00

250 lines
8.9 KiB
Python

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.coreabc import get_ems
from akkudoktoreos.prediction.elecpriceakkudoktor import (
AkkudoktorElecPrice,
AkkudoktorElecPriceValue,
ElecPriceAkkudoktor,
)
from akkudoktoreos.utils.datetimeutil import to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON = DIR_TESTDATA.joinpath(
"elecpriceforecast_akkudoktor_1.json"
)
@pytest.fixture
def provider(config_eos):
"""Fixture to create a ElecPriceProvider instance."""
config_eos.merge_settings_from_dict(
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
},
}
)
provider = ElecPriceAkkudoktor()
provider.highest_orig_datetime = None
assert provider.enabled()
provider._db_reset_state()
return provider
@pytest.fixture
def sample_akkudoktor_1_json():
"""Fixture that returns sample forecast data report."""
with FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_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()
class TestElecPriceAkkudokor:
# ------------------------------------------------
# General forecast
# ------------------------------------------------
def test_singleton_instance(self, provider):
"""Test that ElecPriceForecast behaves as a singleton."""
another_instance = ElecPriceAkkudoktor()
assert provider is another_instance
def test_invalid_provider(self, 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.elecpriceakkudoktor.logger.error")
def test_validate_data_invalid_format(self, mock_logger, provider):
"""Test validation for invalid Akkudoktor 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(self, mock_get, provider, sample_akkudoktor_1_json):
"""Test requesting forecast from Akkudoktor."""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_akkudoktor_1_json)
mock_get.return_value = mock_response
# Test function
akkudoktor_data = provider._request_forecast()
assert isinstance(akkudoktor_data, AkkudoktorElecPrice)
assert akkudoktor_data.values[0].model_dump() == AkkudoktorElecPriceValue(
start_timestamp=1733785200000,
end_timestamp=1733788800000,
start="2024-12-09T23:00:00.000Z",
end="2024-12-10T00:00:00.000Z",
marketprice=92.85,
unit="Eur/MWh",
marketpriceEurocentPerKWh=9.29,
).model_dump()
@pytest.mark.asyncio
@patch("requests.get")
async def test_update_data(self, mock_get, provider, sample_akkudoktor_1_json, cache_store):
"""Test fetching forecast from Akkudoktor."""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_akkudoktor_1_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"))
await 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 price values by resampling
np_price_array = await provider.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=provider.ems_start_datetime,
end_datetime=provider.end_datetime,
fill_method="ffill",
)
assert len(np_price_array) == provider.total_hours
# with open(FILE_TESTDATA_ELECPRICEAKKUDOKTOR_2_JSON, "w") as f_out:
# f_out.write(provider.to_json())
@pytest.mark.asyncio
@patch("requests.get")
async def test_update_data_with_incomplete_forecast(self, mock_get, provider):
"""Test `_update_data` with incomplete or missing forecast data."""
incomplete_data: dict = {"meta": {}, "values": []}
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):
await 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(
self, mock_get, provider, sample_akkudoktor_1_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_akkudoktor_1_json)
mock_response.raise_for_status.side_effect = (
requests.exceptions.HTTPError if exception else None
)
mock_get.return_value = mock_response
# force_update: bypass the file cache, the request must be made in every case.
if exception:
with pytest.raises(exception):
provider._request_forecast(force_update=True)
else:
provider._request_forecast(force_update=True)
@pytest.mark.asyncio
@patch("requests.get")
@patch("akkudoktoreos.core.cache.CacheFileStore")
async def test_cache_integration(self, mock_cache, mock_get, provider, sample_akkudoktor_1_json):
"""Test caching of 8-day electricity price data."""
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_akkudoktor_1_json)
mock_get.return_value = mock_response
# Mock cache object
mock_cache_instance = mock_cache.return_value
mock_cache_instance.get.return_value = None # Simulate no cache
await provider._update_data(force_update=True)
mock_cache_instance.create.assert_called_once()
mock_cache_instance.get.assert_called_once()
@pytest.mark.asyncio
@patch("requests.get")
async def test_key_to_array_resampling(self, mock_get, provider, sample_akkudoktor_1_json):
"""Test resampling of forecast data to NumPy array."""
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_akkudoktor_1_json)
mock_get.return_value = mock_response
get_ems().set_start_datetime(
to_datetime("2024-12-11 00:00:00", in_timezone="Europe/Berlin")
)
await provider.update_data(force_update=True)
mock_get.assert_called_once()
array = await provider.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=provider.ems_start_datetime,
end_datetime=provider.end_datetime,
fill_method="ffill",
)
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(self, provider):
"""Fetch data from real Akkudoktor server."""
# Preset, as this is usually done by update_data()
provider.ems_start_datetime = to_datetime("2024-10-26 00:00:00")
akkudoktor_data = provider._request_forecast()
with FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON.open(
"w", encoding="utf-8", newline="\n"
) as f_out:
json.dump(akkudoktor_data, f_out, indent=4)