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

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:
Bobby Noelte
2026-07-30 19:54:05 +02:00
committed by GitHub
parent 52fe489d4e
commit 8344974c16
16 changed files with 1232 additions and 95 deletions
+43 -2
View File
@@ -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
+93
View File
@@ -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
# ------------------------------------------------------------------
+8 -2
View File
@@ -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."""
+73
View File
@@ -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"
+46
View File
@@ -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)