mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-07-31 22:26:12 +00:00
chore: adapt deprecated endpoints to 15-minutes predictions (#1195)
Bump Version / Bump Version Workflow (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (python) (push) Canceled after 0s
docker-build / platform-excludes (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Run Pytest on Pull Request / test (push) Canceled after 0s
docker-build / build (push) Canceled after 0s
docker-build / merge (push) Canceled after 0s
Bump Version / Bump Version Workflow (push) Canceled after 0s
CodeQL Advanced / Analyze (actions) (push) Canceled after 0s
CodeQL Advanced / Analyze (python) (push) Canceled after 0s
docker-build / platform-excludes (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Run Pytest on Pull Request / test (push) Canceled after 0s
docker-build / build (push) Canceled after 0s
docker-build / merge (push) Canceled after 0s
Ensure the deprecated endpoints get /strompreis, post /gesamtlast, get /gesamtlast_simple,
get /pvforecast to work on 1-hour intervalls even if the prediction provides 15-minutes
intervall data. This keeps the interface compliant to the legacy functionality.
Besides this adaptations several other improvements and fixes are included in this PR.
* feat: extend data management method key_to array by resample_method
One can now define the resample method on how to aggregate the values in an interval
for resampling. Three methods are provided:
- "first": Use the first value in each interval.
- "mean": Compute the arithmetic mean of all samples in each interval.
- "interval_mean": Compute the time-weighted mean assuming each value remains valid
until the next timestamp (piecewise-constant signal).
* feat: extend the get /v1/prediction/dataframe endpoint with resampling parameters
Make all parameters for resampling available at the endpoint.
* feat: extend the get /v1/prediction/list endpoint with resampling parameters
Make all parameters for resampling available at the endpoint.
* feat: add new delete /v1/prediction/range endpoint
The endpoint allows to delete prediction values for a given time span.
* fix: adapt for PVForecastAkkudoktor server side cache handling
/api.akkudoktor/forecast does it's own caching on requests. Call it with slightly
randomized request values to avoid getting cached values in the case we need fresh
data. The requests are anyway rate limited to one request per hour on our side.
* chore: add core.types
This module centralizes reusable type definitions shared across multiple
packages. Defining common types here avoids duplication of complex type
annotations (such as Literal aliases), ensures consistent typing across the
code base, and helps prevent circular import dependencies between modules.
* chore: extend cache testing
* chore: add system test for deprecated /strompreis endpoint
* chore: add unit test module for server endpoints
Add a new test module to do unit tests on server endpoints. First test added
for deprecated get /strompreis endpoint.
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
+43
-2
@@ -631,8 +631,8 @@ class TestCacheFileDecorators:
|
||||
cache_file.seek(0) # Move to the start of the file
|
||||
assert cache_file.read() == result1
|
||||
|
||||
def test_cache_in_file_handles_ttl(self, cache_file_store):
|
||||
"""Test that the cache_infile decorator handles the with_ttl parameter."""
|
||||
def test_cache_in_file_handles_ttl_in_call(self, cache_file_store):
|
||||
"""Test that the cache_infile decorator handles the with_ttl parameter in decorator."""
|
||||
|
||||
# Define a simple function to decorate
|
||||
@cache_in_file(mode="w+")
|
||||
@@ -672,6 +672,47 @@ class TestCacheFileDecorators:
|
||||
result = my_function(with_ttl="1 second") # type: ignore[call-arg]
|
||||
assert result == result1
|
||||
|
||||
def test_cache_in_file_handles_ttl_in_decorator(self, cache_file_store):
|
||||
"""Test that the cache_infile decorator handles the with_ttl parameter in call."""
|
||||
|
||||
# Define a simple function to decorate
|
||||
@cache_in_file(mode="w+", with_ttl="1 second")
|
||||
def my_function():
|
||||
return "New result"
|
||||
|
||||
# Call the decorated function
|
||||
result1 = my_function()
|
||||
assert result1 == "New result"
|
||||
assert len(cache_file_store._store) == 1
|
||||
key = list(cache_file_store._store.keys())[0]
|
||||
|
||||
# Assert result was written to cache file
|
||||
key = next(iter(cache_file_store._store))
|
||||
cache_file = cache_file_store._store[key].cache_file
|
||||
assert cache_file is not None
|
||||
cache_file.seek(0) # Move to the start of the file
|
||||
assert cache_file.read() == result1
|
||||
|
||||
# Modify cache file
|
||||
result2 = "Cached result"
|
||||
cache_file.seek(0)
|
||||
cache_file.write(result2)
|
||||
cache_file.seek(0) # Move to the start of the file
|
||||
assert cache_file.read() == result2
|
||||
|
||||
# Call the decorated function again
|
||||
result = my_function(with_ttl="1 second") # type: ignore[call-arg]
|
||||
cache_file.seek(0) # Move to the start of the file
|
||||
assert cache_file.read() == result2
|
||||
assert result == result2
|
||||
|
||||
# Wait one second to let the cache time out
|
||||
sleep(2)
|
||||
|
||||
# Call again - cache should be timed out
|
||||
result = my_function(with_ttl="1 second") # type: ignore[call-arg]
|
||||
assert result == result1
|
||||
|
||||
def test_cache_in_file_handles_bytes_return(self, cache_file_store):
|
||||
"""Test that the cache_infile decorator handles bytes returned from the function."""
|
||||
# Clear store to assure it is empty
|
||||
|
||||
@@ -427,6 +427,34 @@ class TestDataSequence:
|
||||
fill_method="invalid",
|
||||
)
|
||||
|
||||
async def test_key_to_array_resample_first(self, sequence):
|
||||
"""Test that resample_method='first' returns the first sample in each interval."""
|
||||
interval = to_duration("1 hour")
|
||||
|
||||
for minute, value in (
|
||||
(0, 1.0),
|
||||
(15, 2.0),
|
||||
(30, 3.0),
|
||||
(45, 4.0),
|
||||
):
|
||||
await sequence.insert_by_datetime(
|
||||
self.create_test_record(
|
||||
pendulum.datetime(2023, 11, 6, 0, minute),
|
||||
value,
|
||||
)
|
||||
)
|
||||
|
||||
array = await sequence.key_to_array(
|
||||
key="data_value",
|
||||
start_datetime=pendulum.datetime(2023, 11, 6, 0),
|
||||
end_datetime=pendulum.datetime(2023, 11, 6, 1),
|
||||
interval=interval,
|
||||
resample_method="first",
|
||||
)
|
||||
|
||||
assert len(array) == 1
|
||||
assert array[0] == 1.0
|
||||
|
||||
async def test_key_to_array_resample_mean(self, sequence):
|
||||
"""Test that numeric resampling uses mean when multiple values fall into one interval."""
|
||||
interval = to_duration("1 hour")
|
||||
@@ -447,6 +475,7 @@ class TestDataSequence:
|
||||
start_datetime=pendulum.datetime(2023, 11, 6, 0),
|
||||
end_datetime=pendulum.datetime(2023, 11, 6, 1),
|
||||
interval=interval,
|
||||
resample_method="mean",
|
||||
)
|
||||
|
||||
assert isinstance(array, np.ndarray)
|
||||
@@ -454,6 +483,70 @@ class TestDataSequence:
|
||||
# The first interval mean = (1+2+3+4)/4 = 2.5
|
||||
assert array[0] == pytest.approx(2.5)
|
||||
|
||||
async def test_key_to_array_resample_interval_mean(self, sequence):
|
||||
"""Test that interval_mean computes a time-weighted mean."""
|
||||
|
||||
interval = to_duration("1 hour")
|
||||
|
||||
await sequence.insert_by_datetime(
|
||||
self.create_test_record(
|
||||
pendulum.datetime(2023, 11, 6, 0, 0),
|
||||
10.0,
|
||||
)
|
||||
)
|
||||
await sequence.insert_by_datetime(
|
||||
self.create_test_record(
|
||||
pendulum.datetime(2023, 11, 6, 0, 45),
|
||||
20.0,
|
||||
)
|
||||
)
|
||||
await sequence.insert_by_datetime(
|
||||
self.create_test_record(
|
||||
pendulum.datetime(2023, 11, 6, 1, 0),
|
||||
20.0,
|
||||
)
|
||||
)
|
||||
|
||||
array = await sequence.key_to_array(
|
||||
key="data_value",
|
||||
start_datetime=pendulum.datetime(2023, 11, 6, 0),
|
||||
end_datetime=pendulum.datetime(2023, 11, 6, 1),
|
||||
interval=interval,
|
||||
resample_method="interval_mean",
|
||||
fill_method="none",
|
||||
)
|
||||
|
||||
assert len(array) == 1
|
||||
|
||||
# 10 for 45 min, 20 for 15 min
|
||||
expected = (10 * 45 + 20 * 15) / 60
|
||||
|
||||
assert array[0] == pytest.approx(expected)
|
||||
|
||||
async def test_key_to_array_invalid_resample_method(self, sequence):
|
||||
"""Test invalid resample_method raises an error."""
|
||||
|
||||
interval = to_duration("1 hour")
|
||||
|
||||
await sequence.insert_by_datetime(
|
||||
self.create_test_record(
|
||||
pendulum.datetime(2023, 11, 6),
|
||||
1.0,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="Unsupported resample method: invalid",
|
||||
):
|
||||
await sequence.key_to_array(
|
||||
key="data_value",
|
||||
start_datetime=pendulum.datetime(2023, 11, 6),
|
||||
end_datetime=pendulum.datetime(2023, 11, 6, 1),
|
||||
interval=interval,
|
||||
resample_method="invalid",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# key_to_array — align_to_interval parameter
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -27,6 +27,11 @@ from akkudoktoreos.core.databaseabc import (
|
||||
DatabaseTimestamp,
|
||||
_DatabaseTimestampUnbound,
|
||||
)
|
||||
from akkudoktoreos.core.types import (
|
||||
BoundaryMode,
|
||||
FillMethod,
|
||||
ResampleMethod,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import (
|
||||
DateTime,
|
||||
Duration,
|
||||
@@ -258,9 +263,10 @@ class SampleSequence(DatabaseRecordProtocolMixin[SampleRecord]):
|
||||
start_datetime: Optional[DateTime] = None,
|
||||
end_datetime: Optional[DateTime] = None,
|
||||
interval: Optional[Duration] = None,
|
||||
fill_method: Optional[str] = None,
|
||||
fill_method: Optional[FillMethod] = None,
|
||||
resample_method: ResampleMethod = "mean",
|
||||
dropna: Optional[bool] = True,
|
||||
boundary: Literal["strict", "context"] = "context",
|
||||
boundary: BoundaryMode = "context",
|
||||
align_to_interval: bool = False,
|
||||
) -> NDArray[Shape["*"], Any]:
|
||||
"""Minimal resampling stub sufficient for compaction tests."""
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Test server endpoints directly calling the endpoint function (unit tests)."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.server.eos import fastapi_strompreis
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
def __init__(self):
|
||||
self.settings = None
|
||||
|
||||
def merge_settings(self, settings):
|
||||
self.settings = settings
|
||||
|
||||
|
||||
class _FakeEms:
|
||||
def __init__(self):
|
||||
self.run_kwargs = None
|
||||
|
||||
async def run(self, **kwargs):
|
||||
self.run_kwargs = kwargs
|
||||
|
||||
|
||||
class TestServerEndpointDirect:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fastapi_strompreis(self, monkeypatch):
|
||||
class _FakePrediction:
|
||||
def __init__(self):
|
||||
self.key_to_array_kwargs = None
|
||||
|
||||
async def key_to_array(self, **kwargs):
|
||||
self.key_to_array_kwargs = kwargs
|
||||
|
||||
# two hours of hourly values
|
||||
return np.array([4.0, 16.0])
|
||||
|
||||
config = _FakeConfig()
|
||||
ems = _FakeEms()
|
||||
prediction = _FakePrediction()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"akkudoktoreos.server.eos.get_config",
|
||||
lambda: config,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"akkudoktoreos.server.eos.get_ems",
|
||||
lambda: ems,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"akkudoktoreos.server.eos.get_prediction",
|
||||
lambda: prediction,
|
||||
)
|
||||
|
||||
result = await fastapi_strompreis()
|
||||
|
||||
assert ems.run_kwargs is not None
|
||||
assert prediction.key_to_array_kwargs is not None
|
||||
assert result == [4.0, 16.0]
|
||||
|
||||
assert ems.run_kwargs == {
|
||||
"mode": ems.run_kwargs["mode"], # see below
|
||||
"force_update": True,
|
||||
}
|
||||
|
||||
kwargs = prediction.key_to_array_kwargs
|
||||
|
||||
assert kwargs["key"] == "elecprice_marketprice_wh"
|
||||
assert kwargs["interval"] == to_duration("1 hour")
|
||||
assert kwargs["fill_method"] == "ffill"
|
||||
assert kwargs["resample_method"] == "interval_mean"
|
||||
@@ -786,3 +786,49 @@ class TestSystem:
|
||||
assert result.status_code == HTTPStatus.OK
|
||||
cache = result.json()
|
||||
assert cache == {}
|
||||
|
||||
def test_deprecated_strompreis(self, server_setup_for_class, is_system_test):
|
||||
"""Test deprecated /strompreis endpoint.
|
||||
|
||||
Deprecated /strompreis aggregates 15-minute spot prices to hourly means.
|
||||
"""
|
||||
server = server_setup_for_class["server"]
|
||||
eos_dir = server_setup_for_class["eos_dir"]
|
||||
start_datetime = to_datetime().start_of("day")
|
||||
end_datetime = start_datetime.add(days=2)
|
||||
|
||||
# Reset electricity market price
|
||||
result = requests.delete(
|
||||
f"{server}/v1/prediction/range",
|
||||
params = {
|
||||
"key": "elecprice_marketprice_wh",
|
||||
}
|
||||
)
|
||||
assert result.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
|
||||
|
||||
if not is_system_test:
|
||||
return
|
||||
|
||||
# Call /strompreis
|
||||
result = requests.get(f"{server}/strompreis")
|
||||
assert result.status_code == HTTPStatus.OK
|
||||
|
||||
strompreis_data = result.json()
|
||||
assert len(strompreis_data) == 48
|
||||
|
||||
# Get the same data by v1 interface
|
||||
result = requests.get(
|
||||
f"{server}/v1/prediction/list",
|
||||
params = {
|
||||
"key": "elecprice_marketprice_wh",
|
||||
"start_datetime": to_datetime(start_datetime, as_string=True),
|
||||
"end_datetime": to_datetime(end_datetime, as_string=True),
|
||||
"interval" : "1 hour",
|
||||
"fill_method" : "ffill",
|
||||
"resample_method" : "interval_mean",
|
||||
}
|
||||
)
|
||||
assert result.status_code == HTTPStatus.OK
|
||||
|
||||
v1_data = result.json()
|
||||
assert strompreis_data == pytest.approx(v1_data)
|
||||
|
||||
Reference in New Issue
Block a user