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
+185 -21
View File
@@ -21,7 +21,6 @@ from typing import (
Any,
Dict,
Iterator,
Literal,
Optional,
Tuple,
Type,
@@ -61,6 +60,11 @@ from akkudoktoreos.core.pydantic import (
PydanticDateTimeData,
PydanticDateTimeDataFrame,
)
from akkudoktoreos.core.types import (
BoundaryMode,
FillMethod,
ResampleMethod,
)
from akkudoktoreos.utils.datetimeutil import (
DateTime,
Duration,
@@ -69,6 +73,8 @@ from akkudoktoreos.utils.datetimeutil import (
to_duration,
)
# ==================== Base Class ====================
class DataABC(ConfigMixin, StartMixin, PydanticBaseModel):
"""Base class for handling generic data.
@@ -1196,9 +1202,10 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
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]:
"""Extract an array indexed by fixed time intervals from data records within an optional date range.
@@ -1209,14 +1216,25 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
end_datetime (datetime, optional): The end date for filtering the records (exclusive).
interval (duration, optional): The fixed time interval. Defaults to 1 hour.
fill_method (str): Method to handle missing values during resampling.
- 'linear': Linearly interpolate missing values (for numeric data only).
- 'time': Interpolate missing values (for numeric data only).
- 'ffill': Forward fill missing values.
- 'bfill': Backward fill missing values.
- 'none': Defaults to 'linear' for numeric values, otherwise 'ffill'.
- Defaults to 'linear' for numeric values, otherwise 'ffill'.
resample_method (str):
Method used to aggregate values within a resampling interval.
- "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).
dropna: (bool, optional): Whether to drop NAN/ None values before processing.
Defaults to True.
boundary (Literal["strict", "context"]):
boundary (Literal["strict", "context"]): resampling boundary
"strict" → only values inside [start, end)
"context" → include one value before and after for proper resampling
align_to_interval (bool): When True, snap the resample origin to the nearest
@@ -1249,6 +1267,9 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
if fill_method not in ("ffill", "bfill", "linear", "time", "none", None):
raise ValueError(f"Unsupported fill method: {fill_method}")
if resample_method not in ("first", "mean", "interval_mean"):
raise ValueError(f"Unsupported resample method: {resample_method}")
if boundary not in ("strict", "context"):
raise ValueError(f"Unsupported boundary mode: {boundary}")
@@ -1368,10 +1389,32 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
if is_numeric:
# Step 1: aggregate — collapses sub-interval data (e.g. 4x 15min → 1h mean).
# Produces NaN for buckets where no data existed at all.
resampled = pd.to_numeric(
series.resample(resample_freq, origin=resample_origin).mean(),
errors="coerce", # ← ensures float64, not object dtype
)
numeric_series = pd.to_numeric(
series, errors="coerce"
) # ← ensures float64, not object dtype
if resample_method == "first":
resampled = numeric_series.resample(
resample_freq,
origin=resample_origin,
).first()
elif resample_method == "mean":
resampled = numeric_series.resample(
resample_freq,
origin=resample_origin,
).mean()
elif resample_method == "interval_mean":
# Treat each value as valid until the next timestamp.
expanded = numeric_series.resample("1s").ffill()
resampled = expanded.resample(
resample_freq,
origin=resample_origin,
).mean()
else:
raise ValueError(f"Unsupported resample method: {resample_method}")
# Step 2: fill gaps — interpolates or fills the NaN buckets from step 1.
if fill_method in ("linear", "time"):
@@ -2385,23 +2428,59 @@ class DataContainer(SingletonMixin, DataABC):
start_datetime: Optional[DateTime] = None,
end_datetime: Optional[DateTime] = None,
interval: Optional[Duration] = None,
fill_method: Optional[str] = None,
boundary: Optional[str] = "context",
fill_method: Optional[FillMethod] = None,
resample_method: ResampleMethod = "mean",
dropna: Optional[bool] = True,
boundary: BoundaryMode = "context",
align_to_interval: bool = False,
) -> NDArray[Shape["*"], Any]:
"""Retrieve an array indexed by fixed time intervals for a specified key from the data in each DataProvider.
Iterates through providers to find and return the first available array for the specified key.
Args:
key (str): The field name to retrieve, representing a data attribute in DataRecords.
key (str): The field name in the DataRecord from which to extract values.
start_datetime (datetime, optional): The start date for filtering the records (inclusive).
end_datetime (datetime, optional): The end date for filtering the records (exclusive).
interval (duration, optional): The fixed time interval. Defaults to 1 hour.
fill_method (str): Method to handle missing values during resampling.
- 'linear': Linearly interpolate missing values (for numeric data only).
- 'time': Interpolate missing values (for numeric data only).
- 'ffill': Forward fill missing values.
- 'bfill': Backward fill missing values.
- 'none': Defaults to 'linear' for numeric values, otherwise 'ffill'.
- Defaults to 'linear' for numeric values, otherwise 'ffill'.
resample_method (str):
Method used to aggregate values within a resampling interval.
- "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).
dropna: (bool, optional): Whether to drop NAN/ None values before processing.
Defaults to True.
boundary (Literal["strict", "context"]):
"strict" → only values inside [start, end)
"context" → include one value before and after for proper resampling
align_to_interval (bool): When True, snap the resample origin to the nearest
UTC epoch-aligned boundary of ``interval`` before resampling. This ensures
that bucket timestamps always fall on wall-clock-round times regardless of
when ``start_datetime`` falls:
- 15-minute interval → buckets on :00, :15, :30, :45
- 1-hour interval → buckets on the hour
When False (default), the origin is ``query_start`` (or ``"start_day"`` when
no start is given), preserving the existing behaviour where buckets are
aligned to the query window rather than the clock.
Set to True when storing compacted records back to the database so that the
resulting timestamps are predictable and human-readable. Leave False for
forecast or reporting queries where alignment to the exact query window is
more important than clock-round boundaries.
Returns:
np.ndarray: A NumPy array containing aggregated data for the specified key.
@@ -2421,7 +2500,10 @@ class DataContainer(SingletonMixin, DataABC):
end_datetime=end_datetime,
interval=interval,
fill_method=fill_method,
resample_method=resample_method,
dropna=dropna,
boundary=boundary,
align_to_interval=align_to_interval,
)
break
except KeyError:
@@ -2437,23 +2519,60 @@ class DataContainer(SingletonMixin, DataABC):
keys: list[str],
start_datetime: Optional[DateTime] = None,
end_datetime: Optional[DateTime] = None,
interval: Optional[Any] = None, # Duration assumed
fill_method: Optional[str] = None,
interval: Optional[Duration] = None,
fill_method: Optional[FillMethod] = None,
resample_method: ResampleMethod = "mean",
dropna: Optional[bool] = True,
boundary: BoundaryMode = "context",
align_to_interval: bool = False,
) -> pd.DataFrame:
"""Retrieve a dataframe indexed by fixed time intervals for specified keys from the data in each DataProvider.
Generates a pandas DataFrame using the NumPy arrays for each specified key, ensuring a common time index.
Args:
keys (list[str]): A list of field names to retrieve.
start_datetime (datetime, optional): Start date for filtering records (inclusive).
end_datetime (datetime, optional): End date for filtering records (exclusive).
keys (list[str]): The field names in the DataRecords from which to extract values.
start_datetime (datetime, optional): The start date for filtering the records (inclusive).
end_datetime (datetime, optional): The end date for filtering the records (exclusive).
interval (duration, optional): The fixed time interval. Defaults to 1 hour.
fill_method (str, optional): Method to handle missing values during resampling.
fill_method (str): Method to handle missing values during resampling.
- 'linear': Linearly interpolate missing values (for numeric data only).
- 'time': Interpolate missing values (for numeric data only).
- 'ffill': Forward fill missing values.
- 'bfill': Backward fill missing values.
- 'none': Defaults to 'linear' for numeric values, otherwise 'ffill'.
- Defaults to 'linear' for numeric values, otherwise 'ffill'.
resample_method (str):
Method used to aggregate values within a resampling interval.
- "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).
dropna: (bool, optional): Whether to drop NAN/ None values before processing.
Defaults to True.
boundary (Literal["strict", "context"]):
"strict" → only values inside [start, end)
"context" → include one value before and after for proper resampling
align_to_interval (bool): When True, snap the resample origin to the nearest
UTC epoch-aligned boundary of ``interval`` before resampling. This ensures
that bucket timestamps always fall on wall-clock-round times regardless of
when ``start_datetime`` falls:
- 15-minute interval → buckets on :00, :15, :30, :45
- 1-hour interval → buckets on the hour
When False (default), the origin is ``query_start`` (or ``"start_day"`` when
no start is given), preserving the existing behaviour where buckets are
aligned to the query window rather than the clock.
Set to True when storing compacted records back to the database so that the
resulting timestamps are predictable and human-readable. Leave False for
forecast or reporting queries where alignment to the exact query window is
more important than clock-round boundaries.
Returns:
pd.DataFrame: A DataFrame where each column represents a key's array with a common time index.
@@ -2503,7 +2622,15 @@ class DataContainer(SingletonMixin, DataABC):
for key in keys:
try:
array = await self.key_to_array(
key, start_datetime, end_datetime, interval, fill_method
key=key,
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
fill_method=fill_method,
resample_method=resample_method,
dropna=dropna,
boundary=boundary,
align_to_interval=align_to_interval,
)
if len(array) != len(reference_index):
@@ -2520,6 +2647,43 @@ class DataContainer(SingletonMixin, DataABC):
return pd.DataFrame(data, index=reference_index)
async def key_delete_by_datetime(
self,
key: str,
start_datetime: Optional[DateTime] = None,
end_datetime: Optional[DateTime] = None,
) -> None:
"""Delete an attribute specified by `key` from records in the sequence within a given datetime range.
This method removes the attribute identified by `key` from records that have a `date_time` value falling
within the specified `start_datetime` (inclusive) and `end_datetime` (exclusive) range.
- If only `start_datetime` is specified, attributes will be removed from records from that date onward.
- If only `end_datetime` is specified, attributes will be removed from records up to that date.
- If neither `start_datetime` nor `end_datetime` is given, the attribute will be removed from all records.
Args:
key (str): The attribute name to delete from each record.
start_datetime (datetime, optional): The start datetime to begin attribute deletion (inclusive).
end_datetime (datetime, optional): The end datetime to stop attribute deletion (exclusive).
Raises:
KeyError: If `key` is not a valid attribute of the records.
"""
key_error = True
for provider in self.enabled_providers:
try:
await provider.key_delete_by_datetime(
key=key, start_datetime=start_datetime, end_datetime=end_datetime
)
key_error = False
except KeyError:
key_error = True
continue
if key_error:
raise KeyError(f"key `{key}` is not in predictions")
def provider_by_id(self, provider_id: str) -> DataProvider:
"""Retrieves a data provider by its unique identifier.
+8 -3
View File
@@ -17,7 +17,6 @@ from typing import (
Generic,
Iterable,
Iterator,
Literal,
Optional,
Protocol,
Self,
@@ -34,6 +33,11 @@ from akkudoktoreos.core.coreabc import (
DatabaseMixin,
SingletonMixin,
)
from akkudoktoreos.core.types import (
BoundaryMode,
FillMethod,
ResampleMethod,
)
from akkudoktoreos.utils.datetimeutil import (
DateTime,
Duration,
@@ -543,9 +547,10 @@ class DatabaseRecordProtocolMixin(
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]: ...
+59
View File
@@ -0,0 +1,59 @@
"""Common type aliases used throughout AkkudoktorEOS.
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.
The aliases defined in this module describe common concepts and API contracts,
including resampling methods, interpolation and fill methods, boundary
handling, and other shared parameter types.
Guidelines:
- Import shared type aliases from this module instead of redefining them.
- Keep this module lightweight and free of runtime dependencies wherever
possible.
- Only define reusable types here. Implementation-specific types should
remain in the modules where they are used.
The contents of this module are intended for static type checking and
documentation and have no significant runtime behavior.
"""
from typing import Literal, TypeAlias
FillMethod: TypeAlias = Literal[
"linear",
"time",
"ffill",
"bfill",
]
"""Method used to fill missing values before or after resampling.
- "linear": Linear interpolation.
- "time": Time-based interpolation.
- "ffill": Forward-fill using the previous value.
- "bfill": Backward-fill using the next value.
"""
ResampleMethod: TypeAlias = Literal[
"first",
"mean",
"interval_mean",
]
"""Method used to aggregate multiple samples within a resampling interval.
- "first": Use the first sample.
- "mean": Arithmetic mean of the samples.
- "interval_mean": Time-weighted mean assuming piecewise-constant values.
"""
BoundaryMode: TypeAlias = Literal[
"strict",
"context",
]
"""Controls whether resampling includes context outside the requested time range.
- "strict": Use only data inside the requested interval.
- "context": Include one sample before and after for correct interpolation/resampling.
"""
@@ -18,6 +18,9 @@ from akkudoktoreos.core.emplan import (
FRBCInstruction,
)
from akkudoktoreos.core.pydantic import PydanticDateTimeDataFrame
from akkudoktoreos.core.types import (
FillMethod,
)
from akkudoktoreos.devices.devicesabc import (
ApplianceOperationMode,
BatteryOperationMode,
@@ -673,7 +676,7 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
)
pred = get_prediction()
for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in [
prediction_specs: list[tuple[str, FillMethod, str, float]] = [
(
"pvforecast_ac_power",
"linear",
@@ -722,7 +725,9 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
"loadakkudoktor_mean_energy_wh",
power_to_energy_per_interval_factor,
),
]:
]
for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in prediction_specs:
if pred_key in pred.record_keys:
array = await pred.key_to_array(
key=pred_key,
@@ -18,6 +18,9 @@ from akkudoktoreos.core.emplan import (
FRBCInstruction,
)
from akkudoktoreos.core.pydantic import PydanticDateTimeDataFrame
from akkudoktoreos.core.types import (
FillMethod,
)
from akkudoktoreos.devices.devicesabc import (
ApplianceOperationMode,
BatteryOperationMode,
@@ -675,7 +678,7 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel):
)
pred = get_prediction()
for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in [
prediction_specs: list[tuple[str, FillMethod, str, float]] = [
(
"pvforecast_ac_power",
"linear",
@@ -724,7 +727,9 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel):
"loadakkudoktor_mean_energy_wh",
power_to_energy_per_interval_factor,
),
]:
]
for pred_key, pred_fill_method, pred_solution_key, pred_solution_factor in prediction_specs:
if pred_key in pred.record_keys:
array = await pred.key_to_array(
key=pred_key,
@@ -77,6 +77,7 @@ Methods:
"""
import random
from typing import Any, List, Optional, Union
import requests
@@ -230,7 +231,9 @@ class PVForecastAkkudoktor(PVForecastProvider):
"""
base_url = "https://api.akkudoktor.net/forecast"
query_params: dict[str, Any] = {
"lat": self.config.general.latitude,
# Randomize lat a little bit to circumvent caching by api.akkudoktor.
# Caching used to provide very old data
"lat": round(float(self.config.general.latitude) + random.uniform(0.000, 0.001), 6), # noqa: S311
"lon": self.config.general.longitude,
}
@@ -332,11 +335,22 @@ class PVForecastAkkudoktor(PVForecastProvider):
"""
akkudoktor_data: Optional[AkkudoktorForecast] = None
# Hopefully skip internediate caches
headers = {
"Accept": "application/json",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
}
for plane in self.config.pvforecast.planes:
plane_url = self._url(plane)
response = requests.get(plane_url, timeout=10)
response = requests.get(plane_url, headers=headers, timeout=10)
logger.info("[PVForecastAkkudoktor] URL: {}", response.request.url)
logger.info("[PVForecastAkkudoktor] Headers: {}", response.request.headers)
logger.info("[PVForecastAkkudoktor] Status: {}", response.status_code)
logger.info("[PVForecastAkkudoktor] Response headers: {}", response.headers)
response.raise_for_status() # Raise an error for bad responses
logger.debug(f"Response from {plane_url}: {response}")
plane_data = self._validate_data(response.content)
if akkudoktor_data is None:
@@ -369,6 +383,8 @@ class PVForecastAkkudoktor(PVForecastProvider):
raise ValueError(error_msg)
# Get Akkudoktor PV Forecast data for the given configuration.
if force_update:
logger.info("[PVForecastAkkudoktor] force update.")
akkudoktor_data = self._request_forecast(force_update=force_update) # type: ignore
# Timezone of the PV system
@@ -377,16 +393,35 @@ class PVForecastAkkudoktor(PVForecastProvider):
logger.error(f"Akkudoktor schema change: {error_msg}")
raise ValueError(error_msg)
prediction_horizon = self.ems_start_datetime.start_of("day")
prediction_horizon = prediction_horizon.add(hours=self.config.prediction.hours)
# Assumption that all lists are the same length and are ordered chronologically
# in ascending order and have the same timestamps.
if len(akkudoktor_data.values[0]) < self.config.prediction.hours:
# Expect one value set per prediction hour
error_msg = (
f"The forecast must cover at least {self.config.prediction.hours} hours, "
f"but only {len(akkudoktor_data.values[0])} data sets are given in forecast data."
)
logger.error(f"Akkudoktor schema change: {error_msg}")
raise ValueError(error_msg)
for plane_idx, plane_values in enumerate(akkudoktor_data.values):
last_datetime = plane_values[-1].datetime
dt = to_datetime(last_datetime, in_timezone=self.config.general.timezone)
prediction_horizon = self.ems_start_datetime.start_of("day")
prediction_horizon = prediction_horizon.add(hours=self.config.prediction.hours - 1)
if compare_datetimes(dt, prediction_horizon).lt:
error_msg = (
f"The forecast must cover at least the `{prediction_horizon}` prediction horizon, "
f"but only data up to `{dt}` is given in "
f"forecast data for plane `{plane_idx}`."
)
logger.error(f"Akkudoktor schema change: {error_msg}")
raise ValueError(error_msg)
if len(plane_values) < self.config.prediction.hours:
# Expect one value set per prediction hour
error_msg = (
f"The forecast must cover at least `{self.config.prediction.hours}` hours, "
f"but only `{len(plane_values)}` data sets are given in "
f"forecast data for plane `{plane_idx}`."
)
logger.error(f"Akkudoktor schema change: {error_msg}")
raise ValueError(error_msg)
if not self.ems_start_datetime:
raise ValueError(f"Start DateTime not set: {self.ems_start_datetime}")
@@ -416,7 +451,8 @@ class PVForecastAkkudoktor(PVForecastProvider):
raise ValueError(
f"The forecast must cover at least {self.config.prediction.hours} hours, "
f"but only {len(self)} hours starting from {self.ems_start_datetime} "
f"were predicted."
f"were predicted.\n"
f"{akkudoktor_data}"
)
def report_ac_power_and_measurement(self) -> str:
+246 -26
View File
@@ -46,6 +46,11 @@ from akkudoktoreos.core.pydantic import (
PydanticDateTimeDataFrame,
PydanticDateTimeSeries,
)
from akkudoktoreos.core.types import (
BoundaryMode,
FillMethod,
ResampleMethod,
)
from akkudoktoreos.core.version import __version__
from akkudoktoreos.devices.devices import ResourceKey
from akkudoktoreos.optimization.genetic0.genetic0params import (
@@ -973,16 +978,77 @@ async def fastapi_prediction_dataframe_get(
Optional[str],
Query(description="Time duration for each interval. Defaults to 1 hour."),
] = None,
fill_method: Annotated[
Optional[FillMethod],
Query(description="Method to handle missing values during resampling."),
] = None,
resample_method: Annotated[
ResampleMethod,
Query(description="Method used to aggregate values within a resampling interval."),
] = "mean",
dropna: Annotated[
Optional[bool],
Query(description="Drop NAN/ None values before processing."),
] = None,
boundary: Annotated[
BoundaryMode,
Query(description="Resampling boundary mode."),
] = "context",
align_to_interval: Annotated[
bool,
Query(
description="Snap resample origin to the nearest UTC epoch-aligned boundary of interval."
),
] = False,
) -> PydanticDateTimeDataFrame:
"""Get prediction for given key within given date range as series.
"""Get prediction for given keys within given date range as dataframe.
Args:
key (str): Prediction key
key (list[str]): Prediction keys
start_datetime (Optional[str]): Starting datetime (inclusive).
Defaults to start datetime of latest prediction.
end_datetime (Optional[str]: Ending datetime (exclusive).
Defaults to end datetime of latest prediction.
interval (Optional[str]): Time duration for each interval.
Defaults to 1 hour.
fill_method (str): Method to handle missing values during resampling.
Defaults to end datetime of latest prediction.
- 'linear': Linearly interpolate missing values (for numeric data only).
- 'time': Interpolate missing values (for numeric data only).
- 'ffill': Forward fill missing values.
- 'bfill': Backward fill missing values.
- Defaults to 'linear' for numeric values, otherwise 'ffill'.
resample_method (str):
Method used to aggregate values within a resampling interval.
- "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).
dropna: (bool, optional): Whether to drop NAN/ None values before processing.
Defaults to True.
boundary (Literal["strict", "context"]): resampling boundary
"strict" → only values inside [start, end)
"context" → include one value before and after for proper resampling
align_to_interval (bool): When True, snap the resample origin to the nearest
UTC epoch-aligned boundary of ``interval`` before resampling. This ensures
that bucket timestamps always fall on wall-clock-round times regardless of
when ``start_datetime`` falls:
- 15-minute interval → buckets on :00, :15, :30, :45
- 1-hour interval → buckets on the hour
When False (default), the origin is ``query_start`` (or ``"start_day"`` when
no start is given), preserving the existing behaviour where buckets are
aligned to the query window rather than the clock.
Set to True when storing compacted records back to the database so that the
resulting timestamps are predictable and human-readable. Leave False for
forecast or reporting queries where alignment to the exact query window is
more important than clock-round boundaries.
"""
for key in keys:
if key not in get_prediction().record_keys:
@@ -995,10 +1061,25 @@ async def fastapi_prediction_dataframe_get(
end_datetime = get_prediction().end_datetime
else:
end_datetime = to_datetime(end_datetime)
df = await get_prediction().keys_to_dataframe(
keys=keys, start_datetime=start_datetime, end_datetime=end_datetime, interval=interval
)
return PydanticDateTimeDataFrame.from_dataframe(df, tz=get_config().general.timezone)
try:
prediction_df = await get_prediction().keys_to_dataframe(
keys=keys,
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
fill_method=fill_method,
resample_method=resample_method,
dropna=dropna,
boundary=boundary,
align_to_interval=align_to_interval,
)
except Exception as e:
raise HTTPException(
status_code=400, detail=f"Error on prediction dataframe for '{key}': {e}"
)
return PydanticDateTimeDataFrame.from_dataframe(prediction_df, tz=get_config().general.timezone)
@app.get("/v1/prediction/list", tags=["prediction"])
@@ -1016,6 +1097,28 @@ async def fastapi_prediction_list_get(
Optional[str],
Query(description="Time duration for each interval. Defaults to 1 hour."),
] = None,
fill_method: Annotated[
Optional[FillMethod],
Query(description="Method to handle missing values during resampling."),
] = None,
resample_method: Annotated[
ResampleMethod,
Query(description="Method used to aggregate values within a resampling interval."),
] = "mean",
dropna: Annotated[
Optional[bool],
Query(description="Drop NAN/ None values before processing."),
] = None,
boundary: Annotated[
BoundaryMode,
Query(description="Resampling boundary mode."),
] = "context",
align_to_interval: Annotated[
bool,
Query(
description="Snap resample origin to the nearest UTC epoch-aligned boundary of interval."
),
] = False,
) -> List[Any]:
"""Get prediction for given key within given date range as value list.
@@ -1027,6 +1130,44 @@ async def fastapi_prediction_list_get(
Defaults to end datetime of latest prediction.
interval (Optional[str]): Time duration for each interval.
Defaults to 1 hour.
fill_method (str): Method to handle missing values during resampling.
- 'linear': Linearly interpolate missing values (for numeric data only).
- 'time': Interpolate missing values (for numeric data only).
- 'ffill': Forward fill missing values.
- 'bfill': Backward fill missing values.
- Defaults to 'linear' for numeric values, otherwise 'ffill'.
resample_method (str):
Method used to aggregate values within a resampling interval.
- "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).
dropna: (bool, optional): Whether to drop NAN/ None values before processing.
Defaults to True.
boundary (Literal["strict", "context"]): resampling boundary
"strict" → only values inside [start, end)
"context" → include one value before and after for proper resampling
align_to_interval (bool): When True, snap the resample origin to the nearest
UTC epoch-aligned boundary of ``interval`` before resampling. This ensures
that bucket timestamps always fall on wall-clock-round times regardless of
when ``start_datetime`` falls:
- 15-minute interval → buckets on :00, :15, :30, :45
- 1-hour interval → buckets on the hour
When False (default), the origin is ``query_start`` (or ``"start_day"`` when
no start is given), preserving the existing behaviour where buckets are
aligned to the query window rather than the clock.
Set to True when storing compacted records back to the database so that the
resulting timestamps are predictable and human-readable. Leave False for
forecast or reporting queries where alignment to the exact query window is
more important than clock-round boundaries.
"""
if key not in get_prediction().record_keys:
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
@@ -1042,13 +1183,23 @@ async def fastapi_prediction_list_get(
interval = to_duration("1 hour")
else:
interval = to_duration(interval)
prediction_array = await get_prediction().key_to_array(
key=key,
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
)
prediction_list = prediction_array.tolist()
try:
prediction_array = await get_prediction().key_to_array(
key=key,
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
fill_method=fill_method,
resample_method=resample_method,
dropna=dropna,
boundary=boundary,
align_to_interval=align_to_interval,
)
prediction_list = prediction_array.tolist()
except Exception as e:
raise HTTPException(status_code=400, detail=f"Error on prediction list for '{key}': {e}")
return prediction_list
@@ -1150,6 +1301,53 @@ async def fastapi_prediction_update_provider(
return Response()
@app.delete("/v1/prediction/range", tags=["prediction"])
async def fastapi_prediction_range_delete(
key: Annotated[str, Query(description="Prediction key.")],
start_datetime: Annotated[Optional[str], Query(description="Start datetime.")] = None,
end_datetime: Annotated[Optional[str], Query(description="End datetime.")] = None,
) -> PydanticDateTimeSeries:
"""Delete prediction values for a key within a datetime range."""
try:
if key not in get_prediction().record_keys:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Key '{key}' not found in predictions",
)
try:
start_dt = to_datetime(start_datetime) if start_datetime else None
end_dt = to_datetime(end_datetime) if end_datetime else None
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid datetime: {e}",
)
try:
await get_prediction().key_delete_by_datetime(
key=key,
start_datetime=start_dt,
end_datetime=end_dt,
)
except KeyError:
# No data for key in predictions
pass
pdseries = await get_prediction().key_to_series(key=key)
return PydanticDateTimeSeries.from_series(pdseries)
except HTTPException:
raise
except Exception as e:
trace = "".join(traceback.TracebackException.from_exception(e).format())
logger.exception(f"Unexpected error deleting prediction range: {key}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Internal server error:\n{e}\n{trace}",
)
@app.get("/v1/energy-management/optimization/solution", tags=["energy-management"])
def fastapi_energy_management_optimization_solution_get() -> OptimizationSolution:
"""Get the latest solution of the optimization."""
@@ -1210,9 +1408,9 @@ def fastapi_energy_management_plan_get() -> EnergyManagementPlan:
async def fastapi_strompreis() -> list[float]:
"""Deprecated: Electricity Market Price Prediction per Wh [amount/Wh].
Electricity prices start at 00.00.00 today and are provided for 48 hours.
If no prices are available the missing ones at the start of the series are
filled with the first available price.
Electricity prices start at 00.00.00 today and are provided for 48 hours
in 1-hour intervals. If no prices are available the missing ones at the
start of the series are filled with the first available price.
Note:
Electricity price charges are added.
@@ -1251,7 +1449,9 @@ async def fastapi_strompreis() -> list[float]:
key="elecprice_marketprice_wh",
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=to_duration("1 hour"),
fill_method="ffill",
resample_method="interval_mean",
)
elecprice_list = elecprice_array.tolist()
except Exception as e:
@@ -1275,9 +1475,9 @@ async def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
Endpoint to handle total load prediction adjusted by latest measured data.
Total load prediction starts at 00.00.00 today and is provided for 48 hours.
If no prediction values are available the missing ones at the start of the series are
filled with the first available prediction value.
Total load prediction starts at 00.00.00 today and is provided for 48 hours
in 1-hour intervals. If no prediction values are available the missing ones
at the start of the series are filled with the first available prediction value.
Note:
Use '/v1/prediction/list?key=loadforecast_power_w' instead.
@@ -1355,6 +1555,11 @@ async def fastapi_gesamtlast(request: GesamtlastRequest) -> list[float]:
key="loadforecast_power_w",
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=to_duration("1 hour"),
fill_method="ffill",
resample_method="interval_mean",
dropna=True,
boundary="context",
)
prediction_list = prediction_array.tolist()
except Exception as e:
@@ -1372,9 +1577,9 @@ async def fastapi_gesamtlast_simple(year_energy: float) -> list[float]:
Endpoint to handle total load prediction.
Total load prediction starts at 00.00.00 today and is provided for 48 hours.
If no prediction values are available the missing ones at the start of the series are
filled with the first available prediction value.
Total load prediction starts at 00.00.00 today and is provided for 48 hours
in 1-hour intervals. If no prediction values are available the missing ones
at the start of the series are filled with the first available prediction value.
Args:
year_energy (float): Yearly energy consumption in Wh.
@@ -1415,6 +1620,11 @@ async def fastapi_gesamtlast_simple(year_energy: float) -> list[float]:
key="loadforecast_power_w",
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=to_duration("1 hour"),
fill_method="ffill",
resample_method="interval_mean",
dropna=True,
boundary="context",
)
prediction_list = prediction_array.tolist()
except Exception as e:
@@ -1437,9 +1647,9 @@ async def fastapi_pvforecast() -> ForecastResponse:
Endpoint to handle PV forecast prediction.
PVForecast starts at 00.00.00 today and is provided for 48 hours.
If no forecast values are available the missing ones at the start of the series are
filled with the first available forecast value.
PVForecast starts at 00.00.00 today and is provided for 48 hours
in 1-hour intervals. If no forecast values are available the missing ones
at the start of the series are filled with the first available forecast value.
Note:
Set PVForecastAkkudoktor as provider, then update data with
@@ -1471,12 +1681,22 @@ async def fastapi_pvforecast() -> ForecastResponse:
key="pvforecast_ac_power",
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=to_duration("1 hour"),
fill_method="ffill",
resample_method="interval_mean",
dropna=True,
boundary="context",
)
ac_power_list = ac_power_array.tolist()
temp_air_array = await get_prediction().key_to_array(
key="pvforecastakkudoktor_temp_air",
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=to_duration("1 hour"),
fill_method="ffill",
resample_method="interval_mean",
dropna=True,
boundary="context",
)
temp_air_list = temp_air_array.tolist()
except Exception as e: