fix: move data management to async (#1015)

FAstAPI is an async framework. Data may be imported and exported, load and save, set and get
asynchronously. Prevent interleaving data operations to corrupt the data. In the previous design
sync and async data access was intermixed leading to data corruption.

The basic data classes DataSequence and DataContainer and the derived classes like Provider and
Measurement now are async. Data access is protected by several async locks.

To support the async design of the data classes the database interface became async.

The energy management is also adapted to the new async design. Optimization is still off-loaded
to another thread, but the prepration for the optimization and the post optimization actions now
follow the async design.

Adapter operations are now also protected by async locks.

Tests were adapted to the async design and new tests were created.

Besides this major fix several other improvements and fixes are included in this PR.

* fix: key_to_dict/list/array only regard data records with key value set.

  Before the exclusion of no value data records was only done if the dropna flag was set.

* fix: test for visual result pdf generation

  Due to updates in the library the generated charts text was a little bit different.
  Adapt the test to create the comaprison pdf in the test data durectory and
  update the reference pdf.

* chore: Remove MutableMapping from DataSequence and DataContainer.

  Mutable Mapping does not fit to the now async design.

* chore: Add NoDB database backend

  This backend implements the full database backend interface but performs
  no actual persistence. It is intended for configurations where database
  persistence is disabled (`provider=None`).

* chore: Improve measurement data import testing with real world scenarios.

  Added two new endpoints to support testing.

* chore: Add mermaid to supported documentation tools

* chore: Add documentation about async design

* chore: Add documentation about generic data handling

  Covers the basics of measurement and prediction time series data handling.

* chore: Add empty lines around markdown lists.

* chore: sync pre-commit config to updated package versions

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2026-07-15 16:38:53 +02:00
committed by GitHub
parent 38011780c5
commit eb9e966de9
99 changed files with 11971 additions and 5981 deletions

View File

@@ -24,7 +24,7 @@ prediction_eos = get_prediction()
ems_eos = get_ems()
def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
async def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
"""Prepare and return optimization parameters with real world data.
Returns:
@@ -43,6 +43,7 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
"optimization": {
"horizon_hours": 24,
"interval": 3600,
"algorithm": "GENETIC",
"genetic": {
"individuals": 300,
"generations": 400,
@@ -129,10 +130,10 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
print(
f"Real data prediction from {prediction_eos.ems_start_datetime} to {prediction_eos.end_datetime}"
)
prediction_eos.update_data()
await prediction_eos.update_data()
# PV Forecast (in W)
pv_forecast = prediction_eos.key_to_array(
pv_forecast = await prediction_eos.key_to_array(
key="pvforecast_ac_power",
start_datetime=prediction_eos.ems_start_datetime,
end_datetime=prediction_eos.end_datetime,
@@ -140,7 +141,7 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
print(f"pv_forecast: {pv_forecast}")
# Temperature Forecast (in degree C)
temperature_forecast = prediction_eos.key_to_array(
temperature_forecast = await prediction_eos.key_to_array(
key="weather_temp_air",
start_datetime=prediction_eos.ems_start_datetime,
end_datetime=prediction_eos.end_datetime,
@@ -148,7 +149,7 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
print(f"temperature_forecast: {temperature_forecast}")
# Electricity Price (in Euro per Wh)
strompreis_euro_pro_wh = prediction_eos.key_to_array(
strompreis_euro_pro_wh = await prediction_eos.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=prediction_eos.ems_start_datetime,
end_datetime=prediction_eos.end_datetime,
@@ -156,7 +157,7 @@ def prepare_optimization_real_parameters() -> GeneticOptimizationParameters:
print(f"strompreis_euro_pro_wh: {strompreis_euro_pro_wh}")
# Overall System Load (in W)
gesamtlast = prediction_eos.key_to_array(
gesamtlast = await prediction_eos.key_to_array(
key="load_mean",
start_datetime=prediction_eos.ems_start_datetime,
end_datetime=prediction_eos.end_datetime,
@@ -215,6 +216,7 @@ def prepare_optimization_parameters() -> GeneticOptimizationParameters:
"optimization": {
"horizon_hours": 48,
"interval": 3600,
"algorithm": "GENETIC",
"genetic": {
"individuals": 300,
"generations": 400,
@@ -414,7 +416,7 @@ def run_optimization(
with open(parameters_file, "r") as f:
parameters = GeneticOptimizationParameters(**json.load(f))
elif real_world:
parameters = prepare_optimization_real_parameters()
parameters = asyncio.run(prepare_optimization_real_parameters())
else:
parameters = prepare_optimization_parameters()
logger.info("Optimization Parameters:")

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env python3
import argparse
import asyncio
import cProfile
import pstats
import sys
@@ -159,7 +160,7 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str:
provider = prediction_eos.provider_by_id(provider_id)
prediction_eos.update_data()
asyncio.run(prediction_eos.update_data())
# Return result of prediction
if verbose:
@@ -176,7 +177,7 @@ def run_prediction(provider_id: str, verbose: bool = False) -> str:
print(f"enabled: {provider.enabled()}")
for key in provider.record_keys:
print(f"\n{key}\n----------")
print(f"Array: {provider.key_to_array(key)}")
print(f"Array: {asyncio.run(provider.key_to_array(key))}")
return provider.model_dump_json(indent=4)
@@ -225,4 +226,4 @@ def main():
if __name__ == "__main__":
main()
asyncio.run(main())

View File

@@ -38,6 +38,7 @@ def adapter(config_eos, mock_ems: MagicMock) -> NodeREDAdapter:
return ad
@pytest.mark.asyncio
class TestNodeREDAdapter:
def test_provider_id(self, adapter: NodeREDAdapter):
@@ -52,37 +53,37 @@ class TestNodeREDAdapter:
assert adapter.enabled() is True
@patch("requests.get")
def test_update_datetime(self, mock_get, adapter: NodeREDAdapter):
async def test_update_datetime(self, mock_get, adapter: NodeREDAdapter):
adapter.ems.stage.return_value = EnergyManagementStage.DATA_ACQUISITION
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"foo": "bar"}
now = to_datetime()
adapter.update_data(force_enable=True)
await adapter.update_data(force_enable=True)
mock_get.assert_called_once()
assert compare_datetimes(adapter.update_datetime, now).approximately_equal
@patch("requests.get")
def test_update_data_data_acquisition_success(self, mock_get , adapter: NodeREDAdapter):
async def test_update_data_data_acquisition_success(self, mock_get , adapter: NodeREDAdapter):
adapter.ems.stage.return_value = EnergyManagementStage.DATA_ACQUISITION
mock_get.return_value.status_code = 200
mock_get.return_value.json.return_value = {"foo": "bar"}
adapter.update_data(force_enable=True)
await adapter.update_data(force_enable=True)
mock_get.assert_called_once()
url, = mock_get.call_args[0]
assert "/eos/data_aquisition" in url
@patch("requests.get", side_effect=Exception("boom"))
def test_update_data_data_acquisition_failure(self, mock_get, adapter: NodeREDAdapter):
async def test_update_data_data_acquisition_failure(self, mock_get, adapter: NodeREDAdapter):
adapter.ems.stage.return_value = EnergyManagementStage.DATA_ACQUISITION
with pytest.raises(RuntimeError):
adapter.update_data(force_enable=True)
await adapter.update_data(force_enable=True)
@patch("requests.post")
def test_update_data_control_dispatch_instructions(self, mock_post, adapter: NodeREDAdapter):
async def test_update_data_control_dispatch_instructions(self, mock_post, adapter: NodeREDAdapter):
adapter.ems.stage.return_value = EnergyManagementStage.CONTROL_DISPATCH
instr1 = DDBCInstruction(
@@ -98,7 +99,7 @@ class TestNodeREDAdapter:
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {}
adapter.update_data(force_enable=True)
await adapter.update_data(force_enable=True)
_, kwargs = mock_post.call_args
payload = kwargs["json"]
@@ -110,18 +111,18 @@ class TestNodeREDAdapter:
assert "/eos/control_dispatch" in url
@patch("requests.post")
def test_update_data_disabled_provider(self, mock_post, adapter: NodeREDAdapter):
async def test_update_data_disabled_provider(self, mock_post, adapter: NodeREDAdapter):
adapter.config.adapter.provider = ["HomeAssistant"] # NodeRED disabled
adapter.update_data(force_enable=False)
await adapter.update_data(force_enable=False)
mock_post.assert_not_called()
@patch("requests.post")
def test_update_data_force_enable_overrides_disabled(self, mock_post, adapter: NodeREDAdapter):
async def test_update_data_force_enable_overrides_disabled(self, mock_post, adapter: NodeREDAdapter):
adapter.config.adapter.provider = ["HomeAssistant"]
adapter.ems.stage.return_value = EnergyManagementStage.CONTROL_DISPATCH
mock_post.return_value.status_code = 200
mock_post.return_value.json.return_value = {}
adapter.update_data(force_enable=True)
await adapter.update_data(force_enable=True)
mock_post.assert_called_once()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,308 @@
import asyncio
import json
from datetime import datetime, timezone
from typing import Any, ClassVar, List, Optional, Union
import numpy as np
import pandas as pd
import pendulum
import pytest
import pytest_asyncio
from pydantic import Field, PrivateAttr
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.core.dataabc import (
DataABC,
DataContainer,
DataImportProvider,
DataProvider,
DataRecord,
DataSequence,
)
from akkudoktoreos.core.databaseabc import DatabaseTimestamp
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
# ---------------------------------------------------------------------------
# Derived classes for testing
# ---------------------------------------------------------------------------
class DerivedRecord(DataRecord):
"""DataRecord with a numeric field and configured field-like data."""
data_value: Optional[float] = Field(default=None, description="Data Value")
@classmethod
def configured_data_keys(cls) -> Optional[list[str]]:
return ["dish_washer_emr", "solar_power", "temp"]
class DerivedDataProvider(DataProvider):
"""Concrete DataProvider for testing."""
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
provider_enabled: ClassVar[bool] = True
provider_updated: ClassVar[bool] = False
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
def db_namespace(self) -> str:
return "DerivedDataProvider"
def provider_id(self) -> str:
return "DerivedDataProvider"
def enabled(self) -> bool:
return self.provider_enabled
async def _update_data(self, force_update: Optional[bool] = False) -> None:
DerivedDataProvider.provider_updated = True
class DerivedDataContainer(DataContainer):
providers: List[Union[DerivedDataProvider, DataProvider]] = Field(
default_factory=list, description="List of data providers"
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def make_record(date, value: float) -> DerivedRecord:
return DerivedRecord(date_time=to_datetime(date), data_value=value)
async def make_provider_with_records() -> DerivedDataProvider:
"""Return a fresh provider with three hourly records."""
provider = DerivedDataProvider()
await provider.delete_by_datetime() # wipe singleton state
await provider.insert_by_datetime(make_record(datetime(2024, 1, 1, 0), 1.0))
await provider.insert_by_datetime(make_record(datetime(2024, 1, 1, 1), 2.0))
await provider.insert_by_datetime(make_record(datetime(2024, 1, 1, 2), 3.0))
return provider
async def make_container() -> DerivedDataContainer:
"""Return a container with one populated provider."""
provider = await make_provider_with_records()
container = DerivedDataContainer()
container.providers.clear()
container.providers.append(provider)
return container
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
class TestDataContainer:
# -----------------------------------------------------------------------
# Fixtures
# -----------------------------------------------------------------------
@pytest_asyncio.fixture
async def container(self):
"""Empty container (no providers)."""
c = DerivedDataContainer()
c.providers.clear()
return c
@pytest_asyncio.fixture
async def populated(self):
"""Container with one provider holding three records."""
return await make_container()
# -----------------------------------------------------------------------
# Provider management
# -----------------------------------------------------------------------
async def test_append_provider(self, container):
assert len(container.providers) == 0
provider = DerivedDataProvider()
container.providers.append(provider)
assert len(container.providers) == 1
assert isinstance(container.providers[0], DerivedDataProvider)
async def test_enabled_providers_reflects_enabled_flag(self, populated):
DerivedDataProvider.provider_enabled = True
assert len(populated.enabled_providers) == 1
DerivedDataProvider.provider_enabled = False
assert len(populated.enabled_providers) == 0
DerivedDataProvider.provider_enabled = True # restore
async def test_provider_by_id_found(self, populated):
provider = populated.provider_by_id("DerivedDataProvider")
assert isinstance(provider, DerivedDataProvider)
async def test_provider_by_id_unknown_raises(self, populated):
with pytest.raises(ValueError, match="Unknown provider id"):
populated.provider_by_id("NonExistentProvider")
# -----------------------------------------------------------------------
# record_keys / record_keys_writable
# -----------------------------------------------------------------------
async def test_record_keys_contains_expected_fields(self, populated):
keys = populated.record_keys
assert "data_value" in keys
assert "date_time" in keys
# configured keys
for k in ("dish_washer_emr", "solar_power", "temp"):
assert k in keys
async def test_record_keys_writable_contains_expected_fields(self, populated):
keys = populated.record_keys_writable
assert "data_value" in keys
for k in ("dish_washer_emr", "solar_power", "temp"):
assert k in keys
async def test_record_keys_empty_when_no_providers(self, container):
assert container.record_keys == []
assert container.record_keys_writable == []
# -----------------------------------------------------------------------
# iter / len / repr / keys()
# -----------------------------------------------------------------------
async def test_iter_yields_record_keys(self, populated):
keys = list(populated)
assert "data_value" in keys
async def test_len_equals_number_of_record_keys(self, populated):
assert len(populated) == len(populated.record_keys)
async def test_repr_contains_class_and_provider(self, populated):
r = repr(populated)
assert r.startswith("DerivedDataContainer(")
assert "DerivedDataProvider" in r
async def test_keys_view(self, populated):
kv = populated.keys()
assert "data_value" in kv
# -----------------------------------------------------------------------
# key_to_series
# -----------------------------------------------------------------------
async def test_key_to_series_returns_series(self, populated):
series = await populated.key_to_series("data_value")
assert isinstance(series, pd.Series)
assert series.name == "data_value"
async def test_key_to_series_values(self, populated):
series = await populated.key_to_series("data_value")
assert sorted(series.tolist()) == [1.0, 2.0, 3.0]
async def test_key_to_series_with_datetime_range(self, populated):
start = to_datetime(datetime(2024, 1, 1, 1))
end = to_datetime(datetime(2024, 1, 1, 3))
series = await populated.key_to_series("data_value", start_datetime=start, end_datetime=end)
assert len(series) == 2
assert sorted(series.tolist()) == [2.0, 3.0]
async def test_key_to_series_unknown_key_raises(self, populated):
with pytest.raises(KeyError, match="No data found for key"):
await populated.key_to_series("non_existent_key")
async def test_key_to_series_no_enabled_providers_raises(self, populated):
DerivedDataProvider.provider_enabled = False
try:
with pytest.raises(KeyError, match="No data found for key"):
await populated.key_to_series("data_value")
finally:
DerivedDataProvider.provider_enabled = True
# -----------------------------------------------------------------------
# key_to_array
# -----------------------------------------------------------------------
async def test_key_to_array_returns_ndarray(self, populated):
start = to_datetime(datetime(2024, 1, 1, 0))
end = to_datetime(datetime(2024, 1, 1, 3))
array = await populated.key_to_array("data_value", start_datetime=start, end_datetime=end)
assert isinstance(array, np.ndarray)
assert len(array) == 3
async def test_key_to_array_unknown_key_raises(self, populated):
with pytest.raises(KeyError, match="No data found for key"):
await populated.key_to_array("non_existent_key")
# -----------------------------------------------------------------------
# update_data
# -----------------------------------------------------------------------
async def test_update_data_calls_provider(self, populated):
DerivedDataProvider.provider_updated = False
DerivedDataProvider.provider_enabled = True
await populated.update_data(force_enable=True)
assert DerivedDataProvider.provider_updated is True
async def test_update_data_skips_disabled_provider(self, populated):
DerivedDataProvider.provider_enabled = False
DerivedDataProvider.provider_updated = False
await populated.update_data()
assert DerivedDataProvider.provider_updated is False
DerivedDataProvider.provider_enabled = True # restore
async def test_update_data_force_enable_runs_disabled_provider(self, populated):
DerivedDataProvider.provider_enabled = False
DerivedDataProvider.provider_updated = False
await populated.update_data(force_enable=True)
assert DerivedDataProvider.provider_updated is True
DerivedDataProvider.provider_enabled = True # restore
# -----------------------------------------------------------------------
# save / load
# -----------------------------------------------------------------------
async def test_save_and_load_roundtrip(self, populated):
"""Save then wipe in-memory records and verify load restores data if db is available."""
start = to_datetime(datetime(2024, 1, 1, 0))
end = to_datetime(datetime(2024, 1, 1, 3))
# Confirm data is present before save
series_before = await populated.key_to_series(
"data_value", start_datetime=start, end_datetime=end
)
assert sorted(series_before.tolist()) == [1.0, 2.0, 3.0]
for provider in populated.providers:
assert provider.db_enabled == False
saved = await populated.save()
if not saved:
# No database configured — verify save correctly reported nothing was persisted
pytest.skip("No database configured, skipping roundtrip persistence check")
# Wipe in-memory state only (not the database)
for provider in populated.providers:
provider.records.clear()
assert all(len(p.records) == 0 for p in populated.providers)
loaded = await populated.load()
assert loaded is True
# Verify data is restored via the public async API
series_after = await populated.key_to_series(
"data_value", start_datetime=start, end_datetime=end
)
assert sorted(series_after.tolist()) == [1.0, 2.0, 3.0]
# -----------------------------------------------------------------------
# db_get_stats
# -----------------------------------------------------------------------
async def test_db_get_stats_returns_dict(self, populated):
stats = await populated.db_get_stats()
assert isinstance(stats, dict)
assert "DerivedDataProvider" in stats

View File

@@ -0,0 +1,332 @@
import asyncio
import json
from datetime import datetime, timezone
from typing import Any, ClassVar, List, Optional, Union
import numpy as np
import pandas as pd
import pendulum
import pytest
from pydantic import Field, PrivateAttr, ValidationError
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.core.dataabc import (
DataABC,
DataContainer,
DataImportProvider,
DataProvider,
DataRecord,
DataSequence,
)
from akkudoktoreos.core.databaseabc import DatabaseTimestamp
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
# Derived classes for testing
# ---------------------------
class DerivedConfig(SettingsBaseModel):
env_var: Optional[int] = Field(default=None, description="Test config by environment var")
instance_field: Optional[str] = Field(default=None, description="Test config by instance field")
class_constant: Optional[int] = Field(default=None, description="Test config by class constant")
class DerivedBase(DataABC):
instance_field: Optional[str] = Field(default=None, description="Field Value")
class_constant: ClassVar[int] = 30
class DerivedRecord(DataRecord):
"""Date Record derived from base class DataRecord.
The derived data record got the
- `data_value` field and the
- `dish_washer_emr`, `solar_power`, `temp` configurable field like data.
"""
data_value: Optional[float] = Field(default=None, description="Data Value")
@classmethod
def configured_data_keys(cls) -> Optional[list[str]]:
return ["dish_washer_emr", "solar_power", "temp"]
class DerivedSequence(DataSequence):
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
def db_namespace(self) -> str:
return "DerivedSequence"
class DerivedSequence2(DataSequence):
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
def db_namespace(self) -> str:
return "DerivedSequence2"
class DerivedDataProvider(DataProvider):
"""A concrete subclass of DataProvider for testing purposes."""
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
provider_enabled: ClassVar[bool] = False
provider_updated: ClassVar[bool] = False
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
def db_namespace(self) -> str:
return "DerivedDataProvider"
# Implement abstract methods for test purposes
def provider_id(self) -> str:
return "DerivedDataProvider"
def enabled(self) -> bool:
return self.provider_enabled
async def _update_data(self, force_update: Optional[bool] = False) -> None:
# Simulate update logic
DerivedDataProvider.provider_updated = True
class DerivedDataImportProvider(DataImportProvider):
"""A concrete subclass of DataImportProvider for testing purposes."""
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
provider_enabled: ClassVar[bool] = False
provider_updated: ClassVar[bool] = False
_updates: list = PrivateAttr(default_factory=list)
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
# Implement abstract methods for test purposes
def provider_id(self) -> str:
return "DerivedDataImportProvider"
def enabled(self) -> bool:
return self.provider_enabled
async def _update_data(self, force_update: Optional[bool] = False) -> None:
# Simulate update logic
DerivedDataProvider.provider_updated = True
async def _update_value(self, date, *args, **kwargs) -> None:
# Simulate update logic
self._updates.append((date, args, kwargs))
await super()._update_value(date, *args, **kwargs)
class DerivedDataContainer(DataContainer):
providers: List[Union[DerivedDataProvider, DataProvider]] = Field(
default_factory=list, description="List of data providers"
)
# Tests
# ----------
@pytest.mark.asyncio
class TestDataProvider:
# Fixtures and helper functions
@pytest.fixture
def provider(self):
"""Fixture to provide an instance of TestDataProvider for testing."""
DerivedDataProvider.provider_enabled = True
DerivedDataProvider.provider_updated = False
return DerivedDataProvider()
@pytest.fixture
def sample_start_datetime(self):
"""Fixture for a sample start datetime."""
return to_datetime(datetime(2024, 11, 1, 12, 0))
def create_test_record(self, date, value):
"""Helper function to create a test DataRecord."""
return DerivedRecord(date_time=date, data_value=value)
# Tests
async def test_singleton_behavior(self, provider):
"""Test that DataProvider enforces singleton behavior."""
instance1 = provider
instance2 = DerivedDataProvider()
assert instance1 is instance2, (
"Singleton pattern is not enforced; instances are not the same."
)
async def test_update_method_with_defaults(self, provider, sample_start_datetime, monkeypatch):
"""Test the `update` method with default parameters."""
ems_eos = get_ems()
ems_eos.set_start_datetime(sample_start_datetime)
await provider.update_data()
assert provider.ems_start_datetime == sample_start_datetime
async def test_update_method_force_enable(self, provider, monkeypatch):
"""Test that `update` executes when `force_enable` is True, even if `enabled` is False."""
# Override enabled to return False for this test
DerivedDataProvider.provider_enabled = False
DerivedDataProvider.provider_updated = False
await provider.update_data(force_enable=True)
assert provider.enabled() is False, "Provider should be disabled, but enabled() is True."
assert DerivedDataProvider.provider_updated is True, (
"Provider should have been executed, but was not."
)
async def test_delete_by_datetime(self, provider, sample_start_datetime):
"""Test `delete_by_datetime` method for removing records by datetime range."""
# Add records to the provider for deletion testing
records = [
self.create_test_record(sample_start_datetime - to_duration("3 hours"), 1),
self.create_test_record(sample_start_datetime - to_duration("1 hour"), 2),
self.create_test_record(sample_start_datetime + to_duration("1 hour"), 3),
]
for record in records:
await provider.insert_by_datetime(record)
await provider.delete_by_datetime(
start_datetime=sample_start_datetime - to_duration("2 hours"),
end_datetime=sample_start_datetime + to_duration("2 hours"),
)
assert len(provider.records) == 1, (
"Only one record should remain after deletion by datetime."
)
assert provider.records[0].date_time == sample_start_datetime - to_duration("3 hours"), (
"Unexpected record remains."
)
@pytest.mark.asyncio
class TestDataImportProvider:
@pytest.fixture
def provider(self):
DerivedDataImportProvider.provider_enabled = True
DerivedDataImportProvider.provider_updated = True
p = DerivedDataImportProvider()
p._updates.clear()
p.records.clear()
return p
async def test_import_from_dict_basic(self, provider):
data = {
"start_datetime": "2024-01-01 00:00:00",
"interval": "1 hour",
"solar_power": [1, 2, 3],
}
await provider.import_from_dict(data)
assert provider.records is not None
assert provider.records[0]["solar_power"] == 1
assert provider.records[1]["solar_power"] == 2
async def test_import_from_dict_default_start_and_interval(self, provider):
data = {"solar_power": [10, 20]}
await provider.import_from_dict(data)
assert len(provider._updates) == 2
async def test_import_from_dict_with_prefix(self, provider):
data = {
"dish_washer_emr": [1, 2],
"data_value": [5, 6],
}
await provider.import_from_dict(data, key_prefix="dish")
assert len(provider._updates) == 2
assert all(update[1][0] == "dish_washer_emr" for update in provider._updates)
async def test_import_from_dict_mismatching_lengths(self, provider):
data = {
"solar_power": [1, 2],
"temp": [1],
}
with pytest.raises(ValueError):
await provider.import_from_dict(data)
async def test_import_from_dict_invalid_interval(self, provider):
data = {
"interval": "17 minutes",
"solar_power": [1, 2, 3],
}
with pytest.raises(NotImplementedError):
await provider.import_from_dict(data)
async def test_import_from_dict_skips_none_and_nan(self, provider):
data = {"solar_power": [1, None, np.nan, 4]}
await provider.import_from_dict(data)
assert len(provider._updates) == 2
assert provider._updates[0][1][1] == 1
assert provider._updates[1][1][1] == 4
async def test_import_from_dict_invalid_value_type(self, provider):
data = {"solar_power": "not a list"}
with pytest.raises(ValueError):
await provider.import_from_dict(data)
async def test_import_from_dataframe_with_datetime_index(self, provider):
index = pd.date_range("2024-01-01", periods=3, freq="h")
df = pd.DataFrame({"solar_power": [1, 2, 3]}, index=index)
await provider.import_from_dataframe(df)
assert len(provider._updates) == 3
assert provider._updates[0][1][1] == 1
async def test_import_from_dataframe_without_datetime_index(self, provider):
df = pd.DataFrame({"solar_power": [5, 6, 7]})
await provider.import_from_dataframe(
df,
start_datetime=to_datetime(datetime(2024, 1, 1)),
interval=to_duration("1 hour"),
)
assert len(provider._updates) == 3
async def test_import_from_dataframe_prefix_filter(self, provider):
df = pd.DataFrame({
"dish_washer_emr": [1, 2],
"data_value": [3, 4],
})
await provider.import_from_dataframe(df, key_prefix="dish")
assert len(provider._updates) == 2
assert all(update[1][0] == "dish_washer_emr" for update in provider._updates)
async def test_import_from_dataframe_invalid_input(self, provider):
with pytest.raises(ValueError):
await provider.import_from_dataframe("not a dataframe")
async def test_import_from_json_simple_dict(self, provider):
json_str = json.dumps({"solar_power": [1, 2, 3]})
await provider.import_from_json(json_str)
assert len(provider._updates) == 3
async def test_import_from_json_invalid(self, provider):
with pytest.raises(ValueError):
await provider.import_from_json("this is not json")
async def test_import_from_file(self, provider, tmp_path):
file_path = tmp_path / "data.json"
file_path.write_text(json.dumps({"solar_power": [1, 2]}))
await provider.import_from_file(file_path)
assert len(provider._updates) == 2

312
tests/test_dataabcrecord.py Normal file
View File

@@ -0,0 +1,312 @@
"""Pytest test for data records fro dataabc module."""
import asyncio
import json
from datetime import datetime, timezone
from typing import Any, ClassVar, List, Optional, Union
import numpy as np
import pandas as pd
import pendulum
import pytest
from pydantic import Field, ValidationError
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.dataabc import (
DataABC,
DataRecord,
)
from akkudoktoreos.core.databaseabc import DatabaseTimestamp
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
# Derived classes for testing
# ---------------------------
class DerivedConfig(SettingsBaseModel):
env_var: Optional[int] = Field(default=None, description="Test config by environment var")
instance_field: Optional[str] = Field(default=None, description="Test config by instance field")
class_constant: Optional[int] = Field(default=None, description="Test config by class constant")
class DerivedBase(DataABC):
instance_field: Optional[str] = Field(default=None, description="Field Value")
class_constant: ClassVar[int] = 30
class DerivedRecord(DataRecord):
"""Date Record derived from base class DataRecord.
The derived data record got the
- `data_value` field and the
- `dish_washer_emr`, `solar_power`, `temp` configurable field like data.
"""
data_value: Optional[float] = Field(default=None, description="Data Value")
@classmethod
def configured_data_keys(cls) -> Optional[list[str]]:
return ["dish_washer_emr", "solar_power", "temp"]
# Tests
# ----------
class TestDataABC:
@pytest.fixture
def base(self):
# Provide default values for configuration
derived = DerivedBase()
return derived
def test_get_config_value_key_error(self, base):
with pytest.raises(AttributeError):
base.config.non_existent_key
class TestDataRecord:
def create_test_record(self, date, value):
"""Helper function to create a test DataRecord."""
return DerivedRecord(date_time=date, data_value=value)
@pytest.fixture
def record(self):
"""Fixture to create a sample DerivedDataRecord with some data set."""
rec = DerivedRecord(date_time=to_datetime("1967-01-11"), data_value=10.0)
rec.configured_data = {"dish_washer_emr": 123.0, "solar_power": 456.0}
return rec
def test_getitem(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
assert record["data_value"] == 10.0
def test_setitem(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
record["data_value"] = 20.0
assert record.data_value == 20.0
def test_delitem(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
record.data_value = 20.0
del record["data_value"]
assert record.data_value is None
def test_len(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
record.date_time = None
record.data_value = 20.0
assert len(record) == 5 # 2 regular fields + 3 configured data "fields"
def test_to_dict(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
record.data_value = 20.0
record_dict = record.to_dict()
assert "data_value" in record_dict
assert record_dict["data_value"] == 20.0
record2 = DerivedRecord.from_dict(record_dict)
assert record2.model_dump() == record.model_dump()
def test_to_json(self):
record = self.create_test_record(datetime(2024, 1, 3, tzinfo=timezone.utc), 10.0)
record.data_value = 20.0
json_str = record.to_json()
assert "data_value" in json_str
assert "20.0" in json_str
record2 = DerivedRecord.from_json(json_str)
assert record2.model_dump() == record.model_dump()
def test_record_keys_includes_configured_data_keys(self, record):
"""Ensure record_keys includes all configured configured data keys."""
assert set(record.record_keys()) >= set(record.configured_data_keys())
def test_record_keys_writable_includes_configured_data_keys(self, record):
"""Ensure record_keys_writable includes all configured configured data keys."""
assert set(record.record_keys_writable()) >= set(record.configured_data_keys())
def test_getitem_existing_field(self, record):
"""Test that __getitem__ returns correct value for existing native field."""
record.date_time = "2024-01-01T00:00:00+00:00"
assert record["date_time"] is not None
def test_getitem_existing_configured_data(self, record):
"""Test that __getitem__ retrieves existing configured data values."""
assert record["dish_washer_emr"] == 123.0
assert record["solar_power"] == 456.0
def test_getitem_missing_configured_data_returns_none(self, record):
"""Test that __getitem__ returns None for missing but known configured data keys."""
assert record["temp"] is None
def test_getitem_raises_keyerror(self, record):
"""Test that __getitem__ raises KeyError for completely unknown keys."""
with pytest.raises(KeyError):
_ = record["nonexistent"]
def test_setitem_field(self, record):
"""Test setting a native field using __setitem__."""
record["date_time"] = "2025-01-01T12:00:00+00:00"
assert str(record.date_time).startswith("2025-01-01")
def test_setitem_configured_data(self, record):
"""Test setting a known configured data key using __setitem__."""
record["temp"] = 25.5
assert record.configured_data["temp"] == 25.5
def test_setitem_invalid_key_raises(self, record):
"""Test that __setitem__ raises KeyError for unknown keys."""
with pytest.raises(KeyError):
record["unknown_key"] = 123
def test_delitem_field(self, record):
"""Test deleting a native field using __delitem__."""
record["date_time"] = "2025-01-01T12:00:00+00:00"
del record["date_time"]
assert record.date_time is None
def test_delitem_configured_data(self, record):
"""Test deleting a known configured data key using __delitem__."""
del record["solar_power"]
assert "solar_power" not in record.configured_data
def test_delitem_unknown_raises(self, record):
"""Test that __delitem__ raises KeyError for unknown keys."""
with pytest.raises(KeyError):
del record["nonexistent"]
def test_attribute_get_existing_field(self, record):
"""Test accessing a native field via attribute."""
record.date_time = "2025-01-01T12:00:00+00:00"
assert record.date_time is not None
def test_attribute_get_existing_configured_data(self, record):
"""Test accessing an existing configured data via attribute."""
assert record.dish_washer_emr == 123.0
def test_attribute_get_missing_configured_data(self, record):
"""Test accessing a missing but known configured data returns None."""
assert record.temp is None
def test_attribute_get_invalid_raises(self, record):
"""Test accessing an unknown attribute raises AttributeError."""
with pytest.raises(AttributeError):
_ = record.nonexistent
def test_attribute_set_existing_field(self, record):
"""Test setting a native field via attribute."""
record.date_time = "2025-06-25T12:00:00+00:00"
assert record.date_time is not None
def test_attribute_set_existing_configured_data(self, record):
"""Test setting a known configured data key via attribute."""
record.temp = 99.9
assert record.configured_data["temp"] == 99.9
def test_attribute_set_invalid_raises(self, record):
"""Test setting an unknown attribute raises AttributeError."""
with pytest.raises(AttributeError):
record.invalid = 123
def test_delattr_field(self, record):
"""Test deleting a native field via attribute."""
record.date_time = "2025-06-25T12:00:00+00:00"
del record.date_time
assert record.date_time is None
def test_delattr_configured_data(self, record):
"""Test deleting a known configured data key via attribute."""
record.temp = 88.0
del record.temp
assert "temp" not in record.configured_data
def test_delattr_ignored_missing_configured_data_key(self, record):
"""Test deleting a known configured data key that was never set is a no-op."""
del record.temp
assert "temp" not in record.configured_data
def test_len_and_iter(self, record):
"""Test that __len__ and __iter__ behave as expected."""
keys = list(iter(record))
assert set(record.record_keys_writable()) == set(keys)
assert len(record) == len(keys)
def test_in_operator_includes_configured_data(self, record):
"""Test that 'in' operator includes configured data keys."""
assert "dish_washer_emr" in record
assert "temp" in record # known key, even if not yet set
assert "nonexistent" not in record
def test_hasattr_behavior(self, record):
"""Test that hasattr returns True for fields and known configured dataWs."""
assert hasattr(record, "date_time")
assert hasattr(record, "dish_washer_emr")
assert hasattr(record, "temp") # allowed, even if not yet set
assert not hasattr(record, "nonexistent")
def test_model_validate_roundtrip(self, record):
"""Test that MeasurementDataRecord can be serialized and revalidated."""
dumped = record.model_dump()
restored = DerivedRecord.model_validate(dumped)
assert restored.dish_washer_emr == 123.0
assert restored.solar_power == 456.0
assert restored.temp is None # not set
def test_copy_preserves_configured_data(self, record):
"""Test that copying preserves configured data values."""
record.temp = 22.2
copied = record.model_copy()
assert copied.dish_washer_emr == 123.0
assert copied.temp == 22.2
assert copied is not record
def test_equality_includes_configured_data(self, record):
"""Test that equality includes the `configured data` content."""
other = record.model_copy()
assert record == other
def test_inequality_differs_with_configured_data(self, record):
"""Test that records with different configured datas are not equal."""
other = record.model_copy(deep=True)
# Modify one configured data value in the copy
other.configured_data["dish_washer_emr"] = 999.9
assert record != other
def test_in_operator_for_configured_data_and_fields(self, record):
"""Ensure 'in' works for both fields and configured configured data keys."""
assert "dish_washer_emr" in record
assert "solar_power" in record
assert "date_time" in record # standard field
assert "temp" in record # allowed but not yet set
assert "unknown" not in record
def test_hasattr_equivalence_to_getattr(self, record):
"""hasattr should return True for all valid keys/configured datas."""
assert hasattr(record, "dish_washer_emr")
assert hasattr(record, "temp")
assert hasattr(record, "date_time")
assert not hasattr(record, "nonexistent")
def test_dir_includes_configured_data_keys(self, record):
"""`dir(record)` should include configured data keys for introspection.
It shall not include the internal 'configured datas' attribute.
"""
keys = dir(record)
assert "configured datas" not in keys
for key in record.configured_data_keys():
assert key in keys
def test_init_configured_field_like_data_applies_before_model_init(self):
"""Test that keys listed in `_configured_data_keys` are moved to `configured_data` at init time."""
record = DerivedRecord(
date_time="2024-01-03T00:00:00+00:00",
data_value=42.0,
dish_washer_emr=111.1,
solar_power=222.2,
temp=333.3 # assume `temp` is also a valid configured key
)
assert record.data_value == 42.0
assert record.configured_data == {
"dish_washer_emr": 111.1,
"solar_power": 222.2,
"temp": 333.3,
}

View File

@@ -0,0 +1,979 @@
import asyncio
import json
from datetime import datetime, timezone
from typing import Any, ClassVar, List, Optional, Union
import numpy as np
import pandas as pd
import pendulum
import pytest
import pytest_asyncio
from pydantic import Field, ValidationError
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.core.dataabc import (
DataABC,
DataContainer,
DataImportProvider,
DataProvider,
DataRecord,
DataSequence,
)
from akkudoktoreos.core.databaseabc import DatabaseTimestamp
from akkudoktoreos.utils.datetimeutil import compare_datetimes, to_datetime, to_duration
# Derived classes for testing
# ---------------------------
class DerivedConfig(SettingsBaseModel):
env_var: Optional[int] = Field(default=None, description="Test config by environment var")
instance_field: Optional[str] = Field(default=None, description="Test config by instance field")
class_constant: Optional[int] = Field(default=None, description="Test config by class constant")
class DerivedBase(DataABC):
instance_field: Optional[str] = Field(default=None, description="Field Value")
class_constant: ClassVar[int] = 30
class DerivedRecord(DataRecord):
"""Date Record derived from base class DataRecord.
The derived data record got the
- `data_value` field and the
- `dish_washer_emr`, `solar_power`, `temp` configurable field like data.
"""
data_value: Optional[float] = Field(default=None, description="Data Value")
@classmethod
def configured_data_keys(cls) -> Optional[list[str]]:
return ["dish_washer_emr", "solar_power", "temp"]
class DerivedSequence(DataSequence):
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
def db_namespace(self) -> str:
return "DerivedSequence"
class DerivedSequence2(DataSequence):
# overload
records: List[DerivedRecord] = Field(
default_factory=list, description="List of DerivedRecord records"
)
@classmethod
def record_class(cls) -> Any:
return DerivedRecord
def db_namespace(self) -> str:
return "DerivedSequence2"
# Tests
# ----------
@pytest.mark.asyncio
class TestDataSequence:
@pytest_asyncio.fixture
async def sequence(self):
sequence0 = DerivedSequence()
mem_len = len(sequence0)
db_len = await sequence0.db_count_records()
assert mem_len == 0
assert db_len == 0
return sequence0
@pytest_asyncio.fixture
async def sequence2(self):
sequence = DerivedSequence()
record1 = self.create_test_record(datetime(1970, 1, 1), 1970)
record2 = self.create_test_record(datetime(1971, 1, 1), 1971)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
mem_len = len(sequence)
db_len = await sequence.db_count_records()
assert mem_len == 2
assert db_len == 2
return sequence
def create_test_record(self, date, value):
"""Helper function to create a test DataRecord."""
return DerivedRecord(date_time=date, data_value=value)
# Test cases
@pytest.mark.parametrize("tz_name", ["UTC", "Europe/Berlin", "Atlantic/Canary"])
async def test_min_max_datetime_timezone_and_order(self, sequence, tz_name, monkeypatch, config_eos):
# Monkeypatch the read-only timezone property
monkeypatch.setattr(config_eos.general.__class__, "timezone", property(lambda self: tz_name))
# Create timezone-aware datetimes using the patched config
dt_early = to_datetime("2024-01-01T00:00:00", in_timezone=config_eos.general.timezone)
dt_late = to_datetime("2024-01-02T00:00:00", in_timezone=config_eos.general.timezone)
# Insert in reverse order to verify sorting
record1 = self.create_test_record(dt_late, 1)
record2 = self.create_test_record(dt_early, 2)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
min_dt = await sequence.min_datetime()
max_dt = await sequence.max_datetime()
# --- Basic correctness ---
assert min_dt == dt_early
assert max_dt == dt_late
# --- Must be timezone aware ---
assert min_dt.tzinfo is not None
assert max_dt.tzinfo is not None
# --- Must preserve timezone ---
assert min_dt.tzinfo.name == tz_name
assert max_dt.tzinfo.name == tz_name
async def test_get_by_datetime(self, sequence):
assert len(sequence) == 0
dt = to_datetime("2024-01-01 00:00:00")
record = self.create_test_record(dt, 0)
await sequence.insert_by_datetime(record)
item = await sequence.get_by_datetime(dt)
assert isinstance(item, DerivedRecord)
async def test_insert_by_datetime(self, sequence2):
dt = to_datetime("2024-01-03", in_timezone="UTC")
record = self.create_test_record(dt, 1)
await sequence2.insert_by_datetime(record)
assert sequence2.records[2].date_time == dt
async def test_insert_reversed_date_record(self, sequence2):
dt1 = to_datetime("2023-11-05", in_timezone="UTC")
dt2 = to_datetime("2024-01-03", in_timezone="UTC")
record1 = self.create_test_record(dt2, 0.8)
record2 = self.create_test_record(dt1, 0.9) # reversed date
await sequence2.insert_by_datetime(record1)
assert sequence2.records[2].date_time == dt2
await sequence2.insert_by_datetime(record2)
assert len(sequence2) == 4
assert sequence2.records[2] == record2
async def test_insert_duplicate_date_record(self, sequence):
dt1 = to_datetime("2023-11-05")
record1 = self.create_test_record(dt1, 0.8)
record2 = self.create_test_record(dt1, 0.9) # Duplicate date
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
assert len(sequence) == 1
retrieved_record = await sequence.get_by_datetime(dt1)
assert retrieved_record.data_value == 0.9 # Record should have merged with new value
async def test_key_to_series(self, sequence):
dt = to_datetime(datetime(2023, 11, 6))
record = self.create_test_record(dt, 0.8)
await sequence.insert_by_datetime(record)
series = await sequence.key_to_series("data_value")
assert isinstance(series, pd.Series)
retrieved_record = await sequence.get_by_datetime(dt)
assert retrieved_record is not None
assert retrieved_record.data_value == 0.8
async def test_key_from_series(self, sequence):
dt1 = to_datetime(datetime(2023, 11, 5))
dt2 = to_datetime(datetime(2023, 11, 6))
series = pd.Series(
data=[0.8, 0.9], index=pd.to_datetime([dt1, dt2])
)
await sequence.key_from_series("data_value", series)
assert len(sequence) == 2
record1 = await sequence.get_by_datetime(dt1)
assert record1 is not None
assert record1.data_value == 0.8
record2 = await sequence.get_by_datetime(dt2)
assert record2 is not None
assert record2.data_value == 0.9
async def test_key_to_array(self, sequence):
interval = to_duration("1 day")
start_datetime = to_datetime("2023-11-6")
last_datetime = to_datetime("2023-11-8")
end_datetime = to_datetime("2023-11-9")
record1 = self.create_test_record(start_datetime, float(start_datetime.day))
await sequence.insert_by_datetime(record1)
record2 = self.create_test_record(last_datetime, float(last_datetime.day))
await sequence.insert_by_datetime(record2)
retrieved_record1 = await sequence.get_by_datetime(start_datetime)
assert retrieved_record1 is not None
assert retrieved_record1.data_value == 6.0
retrieved_record2 = await sequence.get_by_datetime(last_datetime)
assert retrieved_record2 is not None
assert retrieved_record2.data_value == 8.0
series = await sequence.key_to_series(
key="data_value", start_datetime=start_datetime, end_datetime=end_datetime
)
assert len(series) == 2
assert series[to_datetime("2023-11-6")] == 6
assert series[to_datetime("2023-11-8")] == 8
array = await sequence.key_to_array(
key="data_value",
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
)
assert isinstance(array, np.ndarray)
np.testing.assert_equal(array, [6.0, 7.0, 8.0])
async def test_key_to_array_linear_interpolation(self, sequence):
"""Test key_to_array with linear interpolation for numeric data."""
interval = to_duration("1 hour")
record1 = self.create_test_record(pendulum.datetime(2023, 11, 6, 0), 0.8)
record2 = self.create_test_record(pendulum.datetime(2023, 11, 6, 2), 1.0) # Gap of 2 hours
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
array = await sequence.key_to_array(
key="data_value",
start_datetime=pendulum.datetime(2023, 11, 6),
end_datetime=pendulum.datetime(2023, 11, 6, 3),
interval=interval,
fill_method="linear",
)
assert len(array) == 3
assert array[0] == 0.8
assert array[1] == 0.9 # Interpolated value
assert array[2] == 1.0
async def test_key_to_array_linear_interpolation_out_of_grid(self, sequence):
"""Test key_to_array with linear interpolation out of grid."""
interval = to_duration("1 hour")
start_datetime= to_datetime("2023-11-06T00:30:00") # out of grid
end_datetime=to_datetime("2023-11-06T01:30:00") # out of grid
record1_datetime = to_datetime("2023-11-06T00:00:00")
record1 = self.create_test_record(record1_datetime, 1.0)
record2_datetime = to_datetime("2023-11-06T02:00:00")
record2 = self.create_test_record(record2_datetime, 2.0) # Gap of 2 hours
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
# Check test setup
record1_timestamp = DatabaseTimestamp.from_datetime(record1_datetime)
record2_timestamp = DatabaseTimestamp.from_datetime(record2_datetime)
start_timestamp = DatabaseTimestamp.from_datetime(start_datetime)
end_timestamp = DatabaseTimestamp.from_datetime(end_datetime)
start_previous_timestamp = await sequence.db_previous_timestamp(start_timestamp)
assert start_previous_timestamp == record1_timestamp
end_next_timestamp = await sequence.db_next_timestamp(end_timestamp)
assert end_next_timestamp == record2_timestamp
# Test
array = await sequence.key_to_array(
key="data_value",
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=interval,
fill_method="linear",
boundary="context",
)
np.testing.assert_equal(array, [1.5])
async def test_key_to_array_ffill(self, sequence):
"""Test key_to_array with forward filling for missing values."""
interval = to_duration("1 hour")
record1 = self.create_test_record(pendulum.datetime(2023, 11, 6, 0), 0.8)
record2 = self.create_test_record(pendulum.datetime(2023, 11, 6, 2), 1.0)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
array = await sequence.key_to_array(
key="data_value",
start_datetime=pendulum.datetime(2023, 11, 6),
end_datetime=pendulum.datetime(2023, 11, 6, 3),
interval=interval,
fill_method="ffill",
)
assert len(array) == 3
assert array[0] == 0.8
assert array[1] == 0.8 # Forward-filled value
assert array[2] == 1.0
async def test_key_to_array_ffill_one_value(self, sequence):
"""Test key_to_array with forward filling for missing values and only one value at end available."""
interval = to_duration("1 hour")
record1 = self.create_test_record(pendulum.datetime(2023, 11, 6, 2), 1.0)
await sequence.insert_by_datetime(record1)
array = await sequence.key_to_array(
key="data_value",
start_datetime=pendulum.datetime(2023, 11, 6),
end_datetime=pendulum.datetime(2023, 11, 6, 4),
interval=interval,
fill_method="ffill",
)
assert len(array) == 4
assert array[0] == 1.0 # Backward-filled value
assert array[1] == 1.0 # Backward-filled value
assert array[2] == 1.0
assert array[2] == 1.0 # Forward-filled value
async def test_key_to_array_bfill(self, sequence):
"""Test key_to_array with backward filling for missing values."""
interval = to_duration("1 hour")
record1 = self.create_test_record(pendulum.datetime(2023, 11, 6, 0), 0.8)
record2 = self.create_test_record(pendulum.datetime(2023, 11, 6, 2), 1.0)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
array = await sequence.key_to_array(
key="data_value",
start_datetime=pendulum.datetime(2023, 11, 6),
end_datetime=pendulum.datetime(2023, 11, 6, 3),
interval=interval,
fill_method="bfill",
)
assert len(array) == 3
assert array[0] == 0.8
assert array[1] == 1.0 # Backward-filled value
assert array[2] == 1.0
async def test_key_to_array_with_truncation(self, sequence):
"""Test truncation behavior in key_to_array."""
interval = to_duration("1 hour")
record1 = self.create_test_record(pendulum.datetime(2023, 11, 5, 23), 0.8)
record2 = self.create_test_record(pendulum.datetime(2023, 11, 6, 1), 1.0)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
#assert sequence is None
array = await sequence.key_to_array(
key="data_value",
start_datetime=pendulum.datetime(2023, 11, 5, 23),
end_datetime=pendulum.datetime(2023, 11, 6, 2),
interval=interval,
)
assert len(array) == 3
assert array[0] == 0.8
assert array[1] == 0.9 # Interpolated from previous day
assert array[2] == 1.0
async def test_key_to_array_with_none(self, sequence):
"""Test handling of empty series in key_to_array."""
interval = to_duration("1 hour")
array = await sequence.key_to_array(
key="data_value",
start_datetime=pendulum.datetime(2023, 11, 6),
end_datetime=pendulum.datetime(2023, 11, 6, 3),
interval=interval,
)
assert isinstance(array, np.ndarray)
assert np.all(array == None)
async def test_key_to_array_with_one(self, sequence):
"""Test handling of one element series in key_to_array."""
interval = to_duration("1 hour")
record1 = self.create_test_record(pendulum.datetime(2023, 11, 5, 23), 0.8)
await sequence.insert_by_datetime(record1)
array = await sequence.key_to_array(
key="data_value",
start_datetime=pendulum.datetime(2023, 11, 5, 23),
end_datetime=pendulum.datetime(2023, 11, 6, 2),
interval=interval,
)
assert len(array) == 3
assert array[0] == 0.8
assert array[1] == 0.8 # Interpolated from previous day
assert array[2] == 0.8 # Interpolated from previous day
async def test_key_to_array_invalid_fill_method(self, sequence):
"""Test invalid fill_method raises an error."""
interval = to_duration("1 hour")
record1 = self.create_test_record(pendulum.datetime(2023, 11, 6, 0), 0.8)
await sequence.insert_by_datetime(record1)
with pytest.raises(ValueError, match="Unsupported fill 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,
fill_method="invalid",
)
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")
# Insert values every 15 minutes within the same hour
record1 = self.create_test_record(pendulum.datetime(2023, 11, 6, 0, 0), 1.0)
record2 = self.create_test_record(pendulum.datetime(2023, 11, 6, 0, 15), 2.0)
record3 = self.create_test_record(pendulum.datetime(2023, 11, 6, 0, 30), 3.0)
record4 = self.create_test_record(pendulum.datetime(2023, 11, 6, 0, 45), 4.0)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
await sequence.insert_by_datetime(record3)
await sequence.insert_by_datetime(record4)
# Resample to hourly interval, expecting the mean of the 4 values
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,
)
assert isinstance(array, np.ndarray)
assert len(array) == 1 # one interval: 0:00-1:00
# The first interval mean = (1+2+3+4)/4 = 2.5
assert array[0] == pytest.approx(2.5)
# ------------------------------------------------------------------
# key_to_array — align_to_interval parameter
# ------------------------------------------------------------------
#
# The existing tests above use start_datetime values that already sit on
# clean hour/day boundaries, so the default alignment (origin=query_start)
# and clock alignment (origin=epoch-floor) produce identical results.
# The tests below specifically use off-boundary start times to expose
# the difference and verify the new parameter.
async def test_key_to_array_align_false_origin_is_query_start(self, sequence):
"""Without align_to_interval the first bucket sits at query_start, not a clock boundary.
With start_datetime at 10:07:00 and 15-min interval the first resampled
bucket must be at 10:07:00 (origin = query_start), NOT at 10:00:00 or 10:15:00.
"""
# Off-boundary start: 10:07
start_dt = pendulum.datetime(2024, 6, 1, 10, 7, tz="UTC")
end_dt = pendulum.datetime(2024, 6, 1, 12, 7, tz="UTC")
# Records every 15 min so the resampled mean equals the input values
for m in range(0, 120, 15):
dt = pendulum.datetime(2024, 6, 1, 10, 7, tz="UTC").add(minutes=m)
await sequence.insert_by_datetime(self.create_test_record(dt, float(m)))
array = await sequence.key_to_array(
key="data_value",
start_datetime=start_dt,
end_datetime=end_dt,
interval=to_duration("15 minutes"),
fill_method="time",
boundary="strict",
align_to_interval=False,
)
assert len(array) > 0
# Reconstruct the pandas index that key_to_array used: origin=start_dt
idx = pd.date_range(start=start_dt, periods=len(array), freq="900s")
# First bucket must be exactly at start_dt (10:07)
assert idx[0].minute == 7
assert idx[0].second == 0
async def test_key_to_array_align_true_15min_buckets_on_quarter_hours(self, sequence):
"""align_to_interval=True produces timestamps on :00/:15/:30/:45 boundaries."""
# Off-boundary start: 10:07
start_dt = pendulum.datetime(2024, 6, 1, 10, 7, tz="UTC")
end_dt = pendulum.datetime(2024, 6, 1, 12, 7, tz="UTC")
# 1-min records across the window so resampling has data to work with
for m in range(0, 121):
dt = pendulum.datetime(2024, 6, 1, 10, 7, tz="UTC").add(minutes=m)
await sequence.insert_by_datetime(self.create_test_record(dt, float(m)))
array = await sequence.key_to_array(
key="data_value",
start_datetime=start_dt,
end_datetime=end_dt,
interval=to_duration("15 minutes"),
fill_method="time",
boundary="strict",
align_to_interval=True,
)
assert len(array) > 0
# Reconstruct the epoch-aligned index that key_to_array must have used
import math
epoch = int(start_dt.timestamp())
floored_epoch = (epoch // 900) * 900 # floor to nearest 15-min boundary
idx = pd.date_range(
start=pd.Timestamp(floored_epoch, unit="s", tz="UTC"),
periods=len(array),
freq="900s",
)
# Every bucket must land on a :00/:15/:30/:45 minute mark with zero seconds
for ts in idx:
assert ts.minute % 15 == 0, (
f"Bucket at {ts} is not on a 15-min boundary (minute={ts.minute})"
)
assert ts.second == 0, (
f"Bucket at {ts} has non-zero seconds ({ts.second})"
)
async def test_key_to_array_align_true_1hour_buckets_on_the_hour(self, sequence):
"""align_to_interval=True with 1-hour interval produces on-the-hour timestamps."""
# Off-boundary start: 10:23
start_dt = pendulum.datetime(2024, 6, 1, 10, 23, tz="UTC")
end_dt = pendulum.datetime(2024, 6, 1, 15, 23, tz="UTC")
for m in range(0, 301, 15):
dt = pendulum.datetime(2024, 6, 1, 10, 23, tz="UTC").add(minutes=m)
await sequence.insert_by_datetime(self.create_test_record(dt, float(m)))
array = await sequence.key_to_array(
key="data_value",
start_datetime=start_dt,
end_datetime=end_dt,
interval=to_duration("1 hour"),
fill_method="time",
boundary="strict",
align_to_interval=True,
)
assert len(array) > 0
epoch = int(start_dt.timestamp())
floored_epoch = (epoch // 3600) * 3600 # floor to nearest hour
idx = pd.date_range(
start=pd.Timestamp(floored_epoch, unit="s", tz="UTC"),
periods=len(array),
freq="1h",
)
for ts in idx:
assert ts.minute == 0, (
f"Bucket at {ts} should be on the hour (minute={ts.minute})"
)
assert ts.second == 0, (
f"Bucket at {ts} has non-zero seconds ({ts.second})"
)
async def test_key_to_array_align_true_when_start_already_on_boundary(self, sequence):
"""align_to_interval=True is a no-op when start_datetime is exactly on a boundary.
With start at a clean 15-min mark both modes must produce identical arrays.
"""
# Exactly on boundary: 10:00:00
start_dt = pendulum.datetime(2024, 6, 1, 10, 0, tz="UTC")
end_dt = pendulum.datetime(2024, 6, 1, 12, 0, tz="UTC")
for m in range(0, 121, 15):
dt = pendulum.datetime(2024, 6, 1, 10, 0, tz="UTC").add(minutes=m)
await sequence.insert_by_datetime(self.create_test_record(dt, float(m)))
arr_aligned = await sequence.key_to_array(
key="data_value",
start_datetime=start_dt,
end_datetime=end_dt,
interval=to_duration("15 minutes"),
fill_method="time",
boundary="strict",
align_to_interval=True,
)
arr_default = await sequence.key_to_array(
key="data_value",
start_datetime=start_dt,
end_datetime=end_dt,
interval=to_duration("15 minutes"),
fill_method="time",
boundary="strict",
align_to_interval=False,
)
assert len(arr_aligned) == len(arr_default)
np.testing.assert_array_almost_equal(arr_aligned, arr_default, decimal=6)
async def test_key_to_array_align_true_without_start_datetime(self, sequence):
"""align_to_interval=True with no start_datetime must not raise.
Without a query_start there is no origin to snap; behaviour falls back
to 'start_day' (same as default). No exception is expected.
"""
for m in range(0, 121, 15):
dt = pendulum.datetime(2024, 6, 1, 10, 7, tz="UTC").add(minutes=m)
await sequence.insert_by_datetime(self.create_test_record(dt, float(m)))
array = await sequence.key_to_array(
key="data_value",
start_datetime=None,
end_datetime=pendulum.datetime(2024, 6, 1, 12, 7, tz="UTC"),
interval=to_duration("15 minutes"),
fill_method="time",
boundary="strict",
align_to_interval=True,
)
assert isinstance(array, np.ndarray)
assert len(array) > 0
async def test_key_to_array_align_true_output_within_requested_window(self, sequence):
"""align_to_interval=True truncates output to [start_datetime, end_datetime).
The epoch-floor origin may generate a bucket before start_datetime (e.g. 10:00
when start is 10:07), but key_to_array must truncate it away. The surviving
buckets are verified directly by reconstructing the index from the first
surviving timestamp (the first epoch-aligned bucket >= start_datetime).
Also checks that all surviving buckets are on 15-min clock boundaries.
"""
start_dt = pendulum.datetime(2024, 6, 1, 10, 7, tz="UTC")
end_dt = pendulum.datetime(2024, 6, 1, 13, 7, tz="UTC")
for m in range(0, 181):
dt = pendulum.datetime(2024, 6, 1, 10, 7, tz="UTC").add(minutes=m)
await sequence.insert_by_datetime(self.create_test_record(dt, float(m)))
array = await sequence.key_to_array(
key="data_value",
start_datetime=start_dt,
end_datetime=end_dt,
interval=to_duration("15 minutes"),
fill_method="time",
boundary="strict",
align_to_interval=True,
)
assert len(array) > 0
# The first surviving bucket is the first epoch-aligned timestamp >= start_dt.
# Compute it the same way key_to_array does: floor then step forward if needed.
epoch = int(start_dt.timestamp())
floored_epoch = (epoch // 900) * 900
first_bucket = pd.Timestamp(floored_epoch, unit="s", tz="UTC")
if first_bucket < pd.Timestamp(start_dt):
first_bucket += pd.Timedelta(seconds=900)
idx = pd.date_range(start=first_bucket, periods=len(array), freq="900s")
start_pd = pd.Timestamp(start_dt)
end_pd = pd.Timestamp(end_dt)
for ts in idx:
assert ts >= start_pd, f"Bucket {ts} is before start_datetime {start_pd}"
assert ts < end_pd, f"Bucket {ts} is at or after end_datetime {end_pd}"
assert ts.minute % 15 == 0, f"Bucket {ts} is not on a 15-min boundary"
assert ts.second == 0, f"Bucket {ts} has non-zero seconds"
async def test_key_to_array_align_true_preserves_mean_values(self, sequence):
"""align_to_interval=True does not corrupt resampled values.
A constant-valued series must resample to the same constant regardless
of bucket alignment.
"""
# 1-min records with constant value 42.0, starting off-boundary
start_dt = pendulum.datetime(2024, 6, 1, 10, 7, tz="UTC")
end_dt = pendulum.datetime(2024, 6, 1, 12, 7, tz="UTC")
for m in range(0, 121):
dt = pendulum.datetime(2024, 6, 1, 10, 7, tz="UTC").add(minutes=m)
await sequence.insert_by_datetime(self.create_test_record(dt, 42.0))
array = await sequence.key_to_array(
key="data_value",
start_datetime=start_dt,
end_datetime=end_dt,
interval=to_duration("15 minutes"),
fill_method="time",
boundary="strict",
align_to_interval=True,
)
assert len(array) > 0
for v in array:
if v is not None:
assert abs(v - 42.0) < 1e-6, f"Expected 42.0, got {v}"
async def test_key_to_array_align_true_compaction_call_pattern(self, sequence):
"""Verify the call pattern used by _db_compact_tier produces clock-aligned timestamps.
_db_compact_tier calls key_to_array with boundary='strict', fill_method='time',
align_to_interval=True on a window whose start has arbitrary sub-second precision.
All output buckets must land on 15-min boundaries so that compacted records are
stored at predictable, human-readable timestamps.
"""
# Non-round base time: 08:43 — chosen to expose any origin-alignment bug
base_dt = pendulum.datetime(2024, 6, 1, 8, 43, tz="UTC")
window_end = pendulum.datetime(2024, 6, 1, 11, 43, tz="UTC")
for m in range(0, 181):
dt = base_dt.add(minutes=m)
await sequence.insert_by_datetime(self.create_test_record(dt, float(m)))
array = await sequence.key_to_array(
key="data_value",
start_datetime=base_dt,
end_datetime=window_end,
interval=to_duration("15 minutes"),
fill_method="time",
boundary="strict",
align_to_interval=True,
)
assert len(array) > 0
epoch = int(base_dt.timestamp())
floored_epoch = (epoch // 900) * 900
idx = pd.date_range(
start=pd.Timestamp(floored_epoch, unit="s", tz="UTC"),
periods=len(array),
freq="900s",
)
for ts in idx:
assert ts.minute % 15 == 0, (
f"Compacted record at {ts} is not on a 15-min boundary (minute={ts.minute})"
)
assert ts.second == 0, (
f"Compacted record at {ts} has non-zero seconds ({ts.second})"
)
async def test_delete_by_datetime_range(self, sequence):
dt1 = to_datetime("2023-11-05")
dt2 = to_datetime("2023-11-06")
dt3 = to_datetime("2023-11-07")
record1 = self.create_test_record(dt1, 0.8)
record2 = self.create_test_record(dt2, 0.9)
record3 = self.create_test_record(dt3, 1.0)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
await sequence.insert_by_datetime(record3)
assert len(sequence) == 3
await sequence.delete_by_datetime(start_datetime=dt2, end_datetime=dt3)
assert len(sequence) == 2
assert sequence.records[0].date_time == dt1
assert sequence.records[1].date_time == dt3
async def test_delete_by_datetime_start(self, sequence):
dt1 = to_datetime("2023-11-05")
dt2 = to_datetime("2023-11-06")
record1 = self.create_test_record(dt1, 0.8)
record2 = self.create_test_record(dt2, 0.9)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
assert len(sequence) == 2
await sequence.delete_by_datetime(start_datetime=dt2)
assert len(sequence) == 1
assert sequence.records[0].date_time == dt1
async def test_delete_by_datetime_end(self, sequence):
dt1 = to_datetime("2023-11-05")
dt2 = to_datetime("2023-11-06")
record1 = self.create_test_record(dt1, 0.8)
record2 = self.create_test_record(dt2, 0.9)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
assert len(sequence) == 2
await sequence.delete_by_datetime(end_datetime=dt2)
assert len(sequence) == 1
assert sequence.records[0].date_time == dt2
async def test_to_dict_async(self, sequence):
dt = to_datetime("2023-11-06")
record = self.create_test_record(dt, 0.8)
await sequence.insert_by_datetime(record)
data_dict = await sequence.to_dict_async()
assert isinstance(data_dict, dict)
# We need a new class - Sequences are singletons
sequence2 = await DerivedSequence2.from_dict_async(data_dict)
assert sequence2.model_dump() == sequence.model_dump()
async def test_to_json_async(self, sequence):
dt = to_datetime("2023-11-06")
record = self.create_test_record(dt, 0.8)
await sequence.insert_by_datetime(record)
json_str = await sequence.to_json_async()
assert isinstance(json_str, str)
assert "2023-11-06" in json_str
assert ": 0.8" in json_str
async def test_from_json_async(self, sequence, sequence2):
json_str = sequence2.to_json()
sequence = await sequence.from_json_async(json_str)
assert len(sequence) == len(sequence2)
assert sequence.records[0].date_time == sequence2.records[0].date_time
assert sequence.records[0].data_value == sequence2.records[0].data_value
async def test_key_to_value_exact_match(self, sequence):
"""Test key_to_value returns exact match when datetime matches a record."""
dt = to_datetime("2023-11-05")
record = self.create_test_record(dt, 0.75)
await sequence.insert_by_datetime(record)
result = await sequence.key_to_value("data_value", dt)
assert result == 0.75
async def test_key_to_value_nearest(self, sequence):
"""Test key_to_value returns value closest in time to the given datetime."""
record1 = self.create_test_record(datetime(2023, 11, 5, 12), 0.6)
record2 = self.create_test_record(datetime(2023, 11, 6, 12), 0.9)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
dt = datetime(2023, 11, 6, 10) # closer to record2
result = await sequence.key_to_value("data_value", dt, time_window=to_duration("48 hours"))
assert result == 0.9
async def test_key_to_value_nearest_after(self, sequence):
"""Test key_to_value returns value nearest after the given datetime."""
record1 = self.create_test_record(datetime(2023, 11, 5, 10), 0.7)
record2 = self.create_test_record(datetime(2023, 11, 5, 15), 0.8)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
dt = datetime(2023, 11, 5, 14) # closer to record2
result = await sequence.key_to_value("data_value", dt, time_window=to_duration("48 hours"))
assert result == 0.8
async def test_key_to_value_empty_sequence(self, sequence):
"""Test key_to_value returns None when sequence is empty."""
result = await sequence.key_to_value("data_value", datetime(2023, 11, 5))
assert result is None
async def test_key_to_value_missing_key(self, sequence):
"""Test key_to_value returns None when key is missing in records."""
record = self.create_test_record(datetime(2023, 11, 5), None)
await sequence.insert_by_datetime(record)
result = await sequence.key_to_value("data_value", datetime(2023, 11, 5))
assert result is None
async def test_key_to_value_multiple_records_with_none(self, sequence):
"""Test key_to_value skips records with None values."""
r1 = self.create_test_record(datetime(2023, 11, 5), None)
r2 = self.create_test_record(datetime(2023, 11, 6), 1.0)
await sequence.insert_by_datetime(r1)
await sequence.insert_by_datetime(r2)
result = await sequence.key_to_value("data_value", datetime(2023, 11, 5, 12), time_window=to_duration("48 hours"))
assert result == 1.0
async def test_key_to_dict(self, sequence):
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
record2 = self.create_test_record(datetime(2023, 11, 6), 0.9)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
data_dict = await sequence.key_to_dict("data_value")
assert isinstance(data_dict, dict)
assert data_dict[to_datetime(datetime(2023, 11, 5), as_string=True)] == 0.8
assert data_dict[to_datetime(datetime(2023, 11, 6), as_string=True)] == 0.9
async def test_key_to_lists(self, sequence):
record1 = self.create_test_record(datetime(2023, 11, 5), 0.8)
record2 = self.create_test_record(datetime(2023, 11, 6), 0.9)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
dates, values = await sequence.key_to_lists("data_value")
assert dates == [to_datetime(datetime(2023, 11, 5)), to_datetime(datetime(2023, 11, 6))]
assert values == [0.8, 0.9]
async def test_to_dataframe_full_data(self, sequence):
"""Test conversion of all records to a DataFrame without filtering."""
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
record2 = self.create_test_record("2024-01-01T13:00:00Z", 20)
record3 = self.create_test_record("2024-01-01T14:00:00Z", 30)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
await sequence.insert_by_datetime(record3)
df = await sequence.to_dataframe()
# Validate DataFrame structure
assert isinstance(df, pd.DataFrame)
assert not df.empty
assert len(df) == 3 # All records should be included
assert "data_value" in df.columns
async def test_to_dataframe_with_filter(self, sequence):
"""Test filtering records by datetime range."""
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
record2 = self.create_test_record("2024-01-01T13:00:00Z", 20)
record3 = self.create_test_record("2024-01-01T14:00:00Z", 30)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
await sequence.insert_by_datetime(record3)
start = to_datetime("2024-01-01T12:30:00Z")
end = to_datetime("2024-01-01T14:00:00Z")
df = await sequence.to_dataframe(start_datetime=start, end_datetime=end)
assert isinstance(df, pd.DataFrame)
assert not df.empty
assert len(df) == 1 # Only one record should match the range
assert df.index[0] == pd.Timestamp("2024-01-01T13:00:00Z")
async def test_to_dataframe_no_matching_records(self, sequence):
"""Test when no records match the given datetime filter."""
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
record2 = self.create_test_record("2024-01-01T13:00:00Z", 20)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
start = to_datetime("2024-01-01T14:00:00Z") # Start time after all records
end = to_datetime("2024-01-01T15:00:00Z")
df = await sequence.to_dataframe(start_datetime=start, end_datetime=end)
assert isinstance(df, pd.DataFrame)
assert df.empty # No records should match
async def test_to_dataframe_empty_sequence(self, sequence):
"""Test when DataSequence has no records."""
sequence = DataSequence(records=[])
df = await sequence.to_dataframe()
assert isinstance(df, pd.DataFrame)
assert df.empty # Should return an empty DataFrame
async def test_to_dataframe_no_start_datetime(self, sequence):
"""Test when only end_datetime is given (all past records should be included)."""
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
record2 = self.create_test_record("2024-01-01T13:00:00Z", 20)
record3 = self.create_test_record("2024-01-01T14:00:00Z", 30)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
await sequence.insert_by_datetime(record3)
end = to_datetime("2024-01-01T13:00:00Z") # Include only first record
df = await sequence.to_dataframe(end_datetime=end)
assert isinstance(df, pd.DataFrame)
assert not df.empty
assert len(df) == 1
assert df.index[0] == pd.Timestamp("2024-01-01T12:00:00Z")
async def test_to_dataframe_no_end_datetime(self, sequence):
"""Test when only start_datetime is given (all future records should be included)."""
record1 = self.create_test_record("2024-01-01T12:00:00Z", 10)
record2 = self.create_test_record("2024-01-01T13:00:00Z", 20)
record3 = self.create_test_record("2024-01-01T14:00:00Z", 30)
await sequence.insert_by_datetime(record1)
await sequence.insert_by_datetime(record2)
await sequence.insert_by_datetime(record3)
start = to_datetime("2024-01-01T13:00:00Z") # Include last two records
df = await sequence.to_dataframe(start_datetime=start)
assert isinstance(df, pd.DataFrame)
assert not df.empty
assert len(df) == 2
assert df.index[0] == pd.Timestamp("2024-01-01T13:00:00Z")

View File

@@ -0,0 +1,701 @@
"""Pytest tests for async DataSequence with persistence.
Tests the async DataSequence with database persistence.
"""
from __future__ import annotations
import asyncio
import shutil
import tempfile
import time
from pathlib import Path
from typing import AsyncIterator, Optional, Type
import pytest
import pytest_asyncio
from pydantic import Field
from akkudoktoreos.core.coreabc import get_database
from akkudoktoreos.core.dataabc import DataProvider, DataRecord, DataSequence
from akkudoktoreos.core.database import Database, LMDBDatabase, SQLiteDatabase
from akkudoktoreos.core.databaseabc import (
DatabaseRecordProtocolLoadPhase,
DatabaseTimestamp,
)
from akkudoktoreos.utils.datetimeutil import (
DateTime,
Duration,
to_datetime,
to_duration,
)
# ==================== Test Fixtures ====================
@pytest.fixture
def temp_dir():
"""Create a temporary directory for test databases."""
temp_path = Path(tempfile.mkdtemp())
yield temp_path
shutil.rmtree(temp_path, ignore_errors=True)
@pytest.fixture(params=["LMDB", "SQLite"])
def database_provider(request) -> str:
"""Parametrize all database backend tests."""
return request.param
@pytest_asyncio.fixture
async def async_database_instance(
config_eos,
database_provider: str,
) -> AsyncIterator[Database]:
"""Open a database instance for testing and close it afterwards."""
config_eos.database.compression_level = 6
config_eos.database.provider = database_provider
db = get_database()
await db.open()
assert db.provider_id() == database_provider
assert db.is_open is True
yield db
await db.close()
config_eos.database.provider = None
# ==================== Helpers ====================
async def _clear_sequence_state(sequence) -> None:
"""Clear runtime DB state without re-instantiating the singleton.
Does _NOT_ initialize the DB state.
"""
await sequence.db_delete_records()
try:
sequence._db_metadata = None
await sequence.database().set_metadata(None, namespace=sequence.db_namespace())
except Exception:
# Database may not be available, just skip
pass
try:
del sequence._db_initialized
except Exception:
# May not be set
pass
async def _reset_sequence_state(sequence) -> None:
"""Reset runtime DB state without re-instantiating the singleton."""
try:
sequence.records = []
del sequence._db_initialized
except Exception:
# May not be set
pass
await sequence._db_ensure_initialized()
# Sample Data
class SampleDataRecord(DataRecord):
"""Minimal DataRecord for testing."""
temperature: float = Field(default=0.0)
humidity: float = Field(default=0.0)
pressure: float = Field(default=0.0)
class SampleDataSequence(DataSequence):
"""DataSequence subclass with database support."""
records: list[SampleDataRecord] = Field(default_factory=list)
@classmethod
def record_class(cls) -> Type[SampleDataRecord]:
return SampleDataRecord
def db_namespace(self) -> str:
return "SampleDataSequence"
class SampleDataProvider(DataProvider):
"""DataProvider subclass with database support."""
records: list[SampleDataRecord] = Field(default_factory=list)
@classmethod
def record_class(cls) -> Type[SampleDataRecord]:
return SampleDataRecord
def provider_id(self) -> str:
return "SampleDataProvider"
def enabled(self) -> bool:
return True
async def _update_data(self, force_update: Optional[bool] = False) -> None:
pass
def db_namespace(self) -> str:
return "SampleDataProvider"
# ==================== DatabaseRecordProtocolMixin Tests ====================
@pytest.mark.asyncio
class TestDataSequenceDatabaseProtocol:
"""Tests for DatabaseRecordProtocolMixin via SampleDataSequence."""
async def test_db_enabled_when_db_open(self, async_database_instance):
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
assert sequence.db_enabled is True
async def test_db_disabled_when_db_closed(self, config_eos):
config_eos.database.provider = None
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
assert sequence.db_enabled is False
async def test_insert_and_save_records(self, async_database_instance):
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(10):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=20.0 + i)
)
# All 10 are dirty/new, none persisted yet
assert len(sequence.records) == 10
assert len(sequence._db_new_timestamps) == 10
saved = await sequence.db_save_records()
assert saved == 10 # 10 inserts + 0 deletes
assert len(sequence._db_dirty_timestamps) == 0
assert len(sequence._db_new_timestamps) == 0
async def test_save_returns_insert_plus_delete_count(self, async_database_instance):
"""db_save_records() return value = saved_inserts + deleted_count."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(5):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
# Persist the 5 records
await sequence.db_save_records()
# Delete 2 of them
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=2))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=4))
deleted = await sequence.db_delete_records(start_timestamp=db_start, end_timestamp=db_end)
# Insert 3 new ones
for i in range(10, 13):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
result = await sequence.db_save_records()
# 3 inserts + 2 deletes = 5
assert result == 5
async def test_load_records_from_db(self, async_database_instance):
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(10):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=20.0 + i)
)
await sequence.db_save_records()
# Clear memory, then reload from DB
await _reset_sequence_state(sequence)
loaded = await sequence.db_load_records()
assert loaded == 10
assert len(sequence.records) == 10
for i, record in enumerate(sequence.records):
assert record.temperature == 20.0 + i
async def test_load_records_with_range(self, async_database_instance):
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(10):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=20.0 + i)
)
await sequence.db_save_records()
await _reset_sequence_state(sequence)
# Load [hours=3, hours=7) → 4 records (3, 4, 5, 6)
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=3))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=7))
loaded = await sequence.db_load_records(start_timestamp=db_start, end_timestamp=db_end)
assert loaded == 4
assert sequence.records[0].temperature == 23.0
assert sequence.records[-1].temperature == 26.0
async def test_iterate_records_triggers_lazy_load(self, async_database_instance):
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(10):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=20.0 + i)
)
await sequence.db_save_records()
await _reset_sequence_state(sequence)
# db_iterate_records calls _db_ensure_loaded internally
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=2))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=5))
records = [record async for record in sequence.db_iterate_records(start_timestamp=db_start, end_timestamp=db_end)]
assert len(records) == 3
assert all(base_time.add(hours=2) <= r.date_time < base_time.add(hours=5) for r in records)
async def test_delete_records(self, async_database_instance):
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(6):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=20.0)
)
await sequence.db_save_records()
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=2))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=5))
deleted = await sequence.db_delete_records(start_timestamp=db_start, end_timestamp=db_end)
assert deleted == 3
# Persist the deletions
await sequence.db_save_records()
await _reset_sequence_state(sequence)
await sequence.db_load_records()
assert len(sequence.records) == 3
async def test_delete_tombstone_prevents_resurrection(self, async_database_instance):
"""Deleted records must not re-appear when db_load_records is called."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(3):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
await sequence.db_save_records()
# Delete middle record
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=1))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=2))
deleted = await sequence.db_delete_records(start_timestamp=db_start, end_timestamp=db_end)
assert deleted == 1
# Do NOT persist yet — tombstone lives only in memory
# Loading should not resurrect the tombstoned record
loaded = await sequence.db_load_records()
assert all(r.date_time != base_time.add(hours=1) for r in sequence.records)
async def test_insert_after_delete_clears_tombstone(self, async_database_instance):
"""Re-inserting a deleted datetime must clear its tombstone."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
dt = base_time.add(hours=5)
await sequence.db_insert_record(SampleDataRecord(date_time=dt, temperature=10.0))
await sequence.db_save_records()
db_start = DatabaseTimestamp.from_datetime(dt)
db_end = sequence._db_timestamp_after(db_start)
deleted = await sequence.db_delete_records(start_timestamp=db_start, end_timestamp=db_end)
assert deleted == 1
await sequence.db_save_records()
# Re-insert the same datetime
await sequence.db_insert_record(SampleDataRecord(date_time=dt, temperature=99.0))
assert dt not in sequence._db_deleted_timestamps
await sequence.db_save_records()
await _reset_sequence_state(sequence)
await sequence.db_load_records()
assert any(r.date_time == dt and r.temperature == 99.0 for r in sequence.records)
async def test_db_count_records_memory_only(self):
"""When db is disabled, count reflects memory only."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
# Without a live DB, db_enabled is False
if sequence.db_enabled:
pytest.skip("DB is open; this test requires it to be closed")
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(5):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i)),
mark_dirty=False,
)
count = await sequence.db_count_records()
assert count == 5
async def test_db_count_records_combined(self, async_database_instance):
"""db_count_records = storage + new_unpersisted - pending_deletes."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
# Persist 10 records
for i in range(10):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
await sequence.db_save_records()
# Add 3 new unpersisted records
for i in range(10, 13):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
# Delete 2 persisted records (not yet saved)
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=0))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=2))
deleted = await sequence.db_delete_records(start_timestamp=db_start, end_timestamp=db_end)
assert deleted == 2
# storage=10, new=3, pending_deletes=2 → expected=11
count = await sequence.db_count_records()
assert count == 11
async def test_db_timestamp_range_empty(self, async_database_instance):
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
min_dt, max_dt = await sequence.db_timestamp_range()
assert min_dt is None
assert max_dt is None
async def test_db_timestamp_range_with_records(self, async_database_instance):
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for hours in [0, 5, 10]:
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=hours), temperature=20.0)
)
await sequence.db_save_records()
await _reset_sequence_state(sequence)
min_dt, max_dt = await sequence.db_timestamp_range()
assert min_dt == DatabaseTimestamp.from_datetime(base_time)
assert max_dt == DatabaseTimestamp.from_datetime(base_time.add(hours=10))
async def test_db_mark_dirty_triggers_save(self, async_database_instance):
"""Marking a record dirty causes it to be re-saved."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
record = SampleDataRecord(date_time=base_time, temperature=20.0)
await sequence.db_insert_record(record)
await sequence.db_save_records()
# Mutate and mark dirty
record.temperature = 99.0
await sequence.db_mark_dirty_record(record)
await sequence.db_save_records()
# Reload and verify update was persisted
await _reset_sequence_state(sequence)
await sequence.db_load_records()
assert sequence.records[0].temperature == 99.0
async def test_db_vacuum_keep_hours(self, async_database_instance):
"""db_vacuum(keep_hours=N) retains only the last N hours of records."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
# 240 hourly records = 10 days
for i in range(240):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=20.0)
)
await sequence.db_save_records()
await _reset_sequence_state(sequence)
keep_hours = 5 * 24 # keep last 5 days
deleted = await sequence.db_vacuum(keep_hours=keep_hours)
assert deleted == 240 - keep_hours
count = await sequence.db_count_records()
assert count == keep_hours
async def test_db_vacuum_keep_timestamp(self, async_database_instance):
"""db_vacuum(keep_timestamp=T) deletes everything before T (exclusive)."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(10):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
await sequence.db_save_records()
await _reset_sequence_state(sequence)
# Keep from hours=5 onward — delete [0, 5), i.e. 5 records
cutoff = base_time.add(hours=5)
db_cutoff = DatabaseTimestamp.from_datetime(cutoff)
deleted = await sequence.db_vacuum(keep_timestamp=db_cutoff)
assert deleted == 5
count = await sequence.db_count_records()
assert count == 5
# Verify the boundary record (hours=5) was NOT deleted
await _reset_sequence_state(sequence)
await sequence.db_load_records()
assert any(r.date_time == cutoff for r in sequence.records)
async def test_db_vacuum_no_argument(self, async_database_instance, config_eos):
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
record = SampleDataRecord(date_time=base_time, temperature=20.0)
await sequence.db_insert_record(record)
await sequence.db_save_records()
config_eos.database.keep_duration_h = None
deleted = await sequence.db_vacuum()
assert deleted == 0
config_eos.database.keep_duration_h = 0
deleted = await sequence.db_vacuum()
assert deleted == 1
async def test_db_vacuum_keep_hours_zero_deletes_all(self, async_database_instance):
"""keep_hours=0 should delete all records."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(5):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
await sequence.db_save_records()
await _reset_sequence_state(sequence)
deleted = await sequence.db_vacuum(keep_hours=0)
assert deleted == 5
count = await sequence.db_count_records()
assert count == 0
async def test_db_get_stats(self, async_database_instance):
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
stats = await sequence.db_get_stats()
assert stats["enabled"] is True
assert "backend" in stats
assert "path" in stats
assert "memory_records" in stats
assert "total_records" in stats
assert "compression_enabled" in stats
assert "timestamp_range" in stats
assert stats["timestamp_range"]["min"] == "None"
assert stats["timestamp_range"]["max"] == "None"
async def test_db_get_stats_disabled(self, config_eos):
config_eos.database.provider = None
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
stats = await sequence.db_get_stats()
assert stats == {"enabled": False}
async def test_lazy_load_phase_none_to_initial(self, async_database_instance):
"""Phase transitions from NONE to INITIAL when a range is loaded via ensure_loaded."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
assert sequence._db_load_phase is DatabaseRecordProtocolLoadPhase.NONE
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(10):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
await sequence.db_save_records()
await _reset_sequence_state(sequence)
# Use db_iterate_records — it calls _db_ensure_loaded which owns phase transitions
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=3))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=7))
records = [record async for record in sequence.db_iterate_records(start_timestamp=db_start, end_timestamp=db_end)]
assert sequence._db_load_phase is DatabaseRecordProtocolLoadPhase.INITIAL
async def test_lazy_load_phase_initial_to_full(self, async_database_instance):
"""Phase transitions from INITIAL to FULL when iterate is called without range."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(10):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
await sequence.db_save_records()
await _reset_sequence_state(sequence)
# Load partial range → INITIAL
# Use db_iterate_records — it calls _db_ensure_loaded which owns phase transitions
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=3))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=7))
records = [record async for record in sequence.db_iterate_records(start_timestamp=db_start, end_timestamp=db_end)]
assert sequence._db_load_phase is DatabaseRecordProtocolLoadPhase.INITIAL
# Iterate without range → escalates to FULL
records = [record async for record in sequence.db_iterate_records()]
assert sequence._db_load_phase is DatabaseRecordProtocolLoadPhase.FULL
async def test_range_covered_skips_redundant_load(self, async_database_instance):
"""_db_range_covered prevents a second DB query for the same range."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(10):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
await sequence.db_save_records()
await _reset_sequence_state(sequence)
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=2))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=8))
records = [record async for record in sequence.db_iterate_records(start_timestamp=db_start, end_timestamp=db_end)]
# Loaded range is now set
assert sequence._db_loaded_range is not None
assert sequence._db_range_covered(db_start, db_end) is True
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=0))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=20))
assert sequence._db_range_covered(db_start, db_end) is False
async def test_loaded_range_not_clobbered_by_expansion(self, async_database_instance):
"""Expanding left or right must not narrow the tracked loaded range."""
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T00:00:00Z")
for i in range(24):
await sequence.db_insert_record(
SampleDataRecord(date_time=base_time.add(hours=i), temperature=float(i))
)
await sequence.db_save_records()
await _reset_sequence_state(sequence)
# Initial window: hours 816
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=8))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=16))
records = [record async for record in sequence.db_iterate_records(start_timestamp=db_start, end_timestamp=db_end)]
assert sequence._db_loaded_range is not None
initial_start, initial_end = sequence._db_loaded_range
assert initial_start is not None
assert initial_end is not None
# Expand left: load hours 48
db_start = DatabaseTimestamp.from_datetime(base_time.add(hours=4))
db_end = DatabaseTimestamp.from_datetime(base_time.add(hours=16))
records = [record async for record in sequence.db_iterate_records(start_timestamp=db_start, end_timestamp=db_end)]
assert sequence._db_loaded_range is not None
expanded_start, expanded_end = sequence._db_loaded_range
assert expanded_start is not None
assert expanded_end is not None
# Left boundary must have moved left; right must not have shrunk
assert expanded_start <= initial_start
assert expanded_end >= initial_end
async def test_duplicate_insert_raises(self, async_database_instance):
sequence = SampleDataSequence()
await _reset_sequence_state(sequence)
dt = to_datetime("2024-01-01T00:00:00Z")
await sequence.db_insert_record(SampleDataRecord(date_time=dt, temperature=1.0))
with pytest.raises(ValueError, match="Duplicate timestamp"):
await sequence.db_insert_record(SampleDataRecord(date_time=dt, temperature=2.0))
async def test_metadata_round_trip(self, async_database_instance):
"""Metadata can be saved and loaded back correctly."""
sequence = SampleDataSequence()
await _clear_sequence_state(sequence)
assert sequence._db_metadata is None
await _reset_sequence_state(sequence)
assert sequence._db_metadata is not None
created = sequence._db_metadata["created"]
assert sequence._db_metadata["version"] == 1
await _reset_sequence_state(sequence)
assert sequence._db_metadata is not None
assert sequence._db_metadata["created"] == created
assert sequence._db_metadata["version"] == 1
async def test_initial_load_window_respected(self, async_database_instance):
"""db_initial_time_window limits the initial load from DB."""
class WindowedSequence(SampleDataSequence):
def db_namespace(self) -> str:
return "WindowedSequence"
def db_initial_time_window(self) -> Optional[Duration]:
return to_duration("2 hours")
sequence = WindowedSequence()
await _reset_sequence_state(sequence)
base_time = to_datetime("2024-01-01T12:00:00Z")
# Store 24 hourly records centred on base_time
for i in range(24):
await sequence.db_insert_record(
SampleDataRecord(
date_time=base_time.subtract(hours=12).add(hours=i),
temperature=float(i),
)
)
await sequence.db_save_records()
await _reset_sequence_state(sequence)
# Trigger initial window load centred on base_time
sequence.config.database.initial_load_window_h = 2
db_center = DatabaseTimestamp.from_datetime(base_time)
await sequence._db_load_initial_window(center_timestamp=db_center)
# Only records within ±2h of base_time should be in memory
assert len(sequence.records) <= 5 # at most 4h window = 45 records
assert sequence._db_load_phase is DatabaseRecordProtocolLoadPhase.INITIAL

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -20,16 +20,52 @@ DIR_SRC = DIR_PROJECT_ROOT / "src"
HASH_FILE = DIR_BUILD / ".sphinx_hash.json"
def find_sphinx_build() -> str:
venv = os.getenv("VIRTUAL_ENV")
paths = [Path(venv)] if venv else []
paths.append(DIR_PROJECT_ROOT / ".venv")
import os
import subprocess
from pathlib import Path
for base in paths:
cmd = base / ("Scripts" if os.name == "nt" else "bin") / ("sphinx-build.exe" if os.name == "nt" else "sphinx-build")
if cmd.exists():
return str(cmd)
return "sphinx-build"
def find_sphinx_build() -> list[str]:
"""Return command to invoke sphinx-build via virtualenv, uv, or globally."""
candidates = []
# 1⃣ Currently active virtualenv
venv = os.getenv("VIRTUAL_ENV")
if venv:
candidates.append(Path(venv))
# 2⃣ uvmanaged virtualenv
uv_venv = Path(".uv") / "venv"
if uv_venv.exists():
candidates.append(uv_venv)
# 3⃣ traditional .venv
dot_venv = Path(".venv")
if dot_venv.exists():
candidates.append(dot_venv)
# Check each candidate for the sphinxbuild binary
for base in candidates:
sphinx_build_path = base / ("Scripts" if os.name == "nt" else "bin") / (
"sphinx-build.exe" if os.name == "nt" else "sphinx-build"
)
if sphinx_build_path.exists():
return [str(sphinx_build_path)]
# 4⃣ fallback to uv run sphinxbuild
try:
subprocess.run(
["uv", "run", "sphinx-build", "--version"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return ["uv", "run", "sphinx-build"]
except (subprocess.CalledProcessError, FileNotFoundError):
pass
# 5⃣ final fallback to system sphinxbuild
return ["sphinx-build"]
@pytest.fixture(scope="session")
@@ -61,8 +97,7 @@ class TestSphinxDocumentation:
Ensures no major warnings are emitted.
"""
SPHINX_CMD = [
find_sphinx_build(),
SPHINX_CMD = find_sphinx_build() + [
"-M",
"html",
str(DIR_DOCS),
@@ -105,8 +140,6 @@ class TestSphinxDocumentation:
env["EOS_CONFIG_DIR"] = eos_dir
try:
# Run sphinx-build
project_dir = Path(__file__).parent.parent
process = subprocess.run(
self.SPHINX_CMD,
check=True,
@@ -114,13 +147,15 @@ class TestSphinxDocumentation:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=project_dir,
cwd=DIR_PROJECT_ROOT, # use the existing constant
)
# Combine output
output = process.stdout + "\n" + process.stderr
returncode = process.returncode
except:
output = f"ERROR: Could not start sphinx-build - {self.SPHINX_CMD}"
except subprocess.CalledProcessError as e:
output = e.stdout + "\n" + e.stderr if e.stdout else ""
returncode = e.returncode
except Exception as e:
output = f"Failed to execute command: {e}"
returncode = -1
# Remove temporary EOS_DIR

View File

@@ -1,3 +1,4 @@
import asyncio
import json
from pathlib import Path
from unittest.mock import Mock, patch
@@ -47,177 +48,181 @@ def cache_store():
return CacheFileStore()
# ------------------------------------------------
# General forecast
# ------------------------------------------------
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_singleton_instance(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()
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
# ------------------------------------------------
# ------------------------------------------------
# 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("akkudoktoreos.prediction.elecpriceakkudoktor.logger.error")
def test_validate_data_invalid_format(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()
@patch("requests.get")
def test_request_forecast(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
@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
# Test function
akkudoktor_data = provider._request_forecast()
cache_store.clear(clear_all=True)
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()
# 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 prioce 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,
)
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())
@patch("requests.get")
def test_update_data(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
@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)
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.ems_start_datetime,
end_datetime=provider.end_datetime,
@pytest.mark.parametrize(
"status_code, exception",
[(400, requests.exceptions.HTTPError), (500, requests.exceptions.HTTPError), (200, None)],
)
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())
@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 = {"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):
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_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
if exception:
with pytest.raises(exception):
@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
if exception:
with pytest.raises(exception):
provider._request_forecast()
else:
provider._request_forecast()
else:
provider._request_forecast()
@patch("requests.get")
@patch("akkudoktoreos.core.cache.CacheFileStore")
def test_cache_integration(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
@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
# Mock cache object
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()
await 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.ems_start_datetime,
end_datetime=provider.end_datetime,
)
assert isinstance(array, np.ndarray)
assert len(array) == provider.total_hours
@pytest.mark.asyncio
async def test_key_to_array_resampling(self, provider):
"""Test resampling of forecast data to NumPy array."""
await provider.update_data(force_update=True)
array = await provider.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=provider.ems_start_datetime,
end_datetime=provider.end_datetime,
)
assert isinstance(array, np.ndarray)
assert len(array) == provider.total_hours
# ------------------------------------------------
# Development Akkudoktor
# ------------------------------------------------
# ------------------------------------------------
# Development Akkudoktor
# ------------------------------------------------
@pytest.mark.skip(reason="For development only")
def test_akkudoktor_development_forecast_data(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")
@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()
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)
with FILE_TESTDATA_ELECPRICEAKKUDOKTOR_1_JSON.open(
"w", encoding="utf-8", newline="\n"
) as f_out:
json.dump(akkudoktor_data, f_out, indent=4)

View File

@@ -1,3 +1,4 @@
import asyncio
import json
from pathlib import Path
from unittest.mock import Mock, patch
@@ -51,204 +52,205 @@ def cache_store():
return CacheFileStore()
# ------------------------------------------------
# General forecast
# ------------------------------------------------
@pytest.mark.asyncio
class TestElecPriceEnergyCharts:
# ------------------------------------------------
# General forecast
# ------------------------------------------------
def test_singleton_instance(self, provider):
"""Test that ElecPriceForecast behaves as a singleton."""
another_instance = ElecPriceEnergyCharts()
assert provider is another_instance
def test_singleton_instance(provider):
"""Test that ElecPriceForecast behaves as a singleton."""
another_instance = ElecPriceEnergyCharts()
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()
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
# ------------------------------------------------
# ------------------------------------------------
# Akkudoktor
# ------------------------------------------------
@patch("akkudoktoreos.prediction.elecpriceenergycharts.logger.error")
def test_validate_data_invalid_format(self, 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("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(self, 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_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
@patch("requests.get")
async def test_update_data(self, 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
# Test function
energy_charts_data = provider._request_forecast()
cache_store.clear(clear_all=True)
assert isinstance(energy_charts_data, EnergyChartsElecPrice)
assert energy_charts_data.unix_seconds[0] == 1733785200
assert energy_charts_data.price[0] == 92.85
# 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 prioce 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,
)
assert len(np_price_array) == provider.total_hours
@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
@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 = {"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):
await provider._update_data(force_update=True)
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.ems_start_datetime,
end_datetime=provider.end_datetime,
@pytest.mark.parametrize(
"status_code, exception",
[(400, requests.exceptions.HTTPError), (500, requests.exceptions.HTTPError), (200, None)],
)
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):
@patch("requests.get")
def test_request_forecast_status_codes(
self, 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()
else:
provider._request_forecast()
@patch("requests.get")
@patch("akkudoktoreos.core.cache.CacheFileStore")
def test_cache_integration(mock_cache, mock_get, provider, sample_energycharts_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_energycharts_json)
mock_get.return_value = mock_response
@patch("requests.get")
@patch("akkudoktoreos.core.cache.CacheFileStore")
async def test_cache_integration(self, mock_cache, mock_get, provider, sample_energycharts_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_energycharts_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
# Mock cache object
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()
await 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.ems_start_datetime,
end_datetime=provider.end_datetime,
)
assert isinstance(array, np.ndarray)
assert len(array) == provider.total_hours
async def test_key_to_array_resampling(self, provider):
"""Test resampling of forecast data to NumPy array."""
await provider.update_data(force_update=True)
array = await provider.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=provider.ems_start_datetime,
end_datetime=provider.end_datetime,
)
assert isinstance(array, np.ndarray)
assert len(array) == provider.total_hours
@patch("requests.get")
def test_request_forecast_url_bidding_zone_is_value(mock_get, provider, sample_energycharts_json):
"""Test that the bidding zone in the API URL uses the enum *value* (e.g. 'DE-LU'),
not the enum repr (e.g. 'EnergyChartsBiddingZones.DE_LU').
@patch("requests.get")
def test_request_forecast_url_bidding_zone_is_value(self, mock_get, provider, sample_energycharts_json):
"""Test that the bidding zone in the API URL uses the enum *value* (e.g. 'DE-LU'),
not the enum repr (e.g. 'EnergyChartsBiddingZones.DE_LU').
Regression test for: bzn=EnergyChartsBiddingZones.DE_LU appearing in the URL
instead of bzn=DE-LU, which caused a 400 Bad Request from the Energy-Charts API.
"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_energycharts_json)
mock_get.return_value = mock_response
Regression test for: bzn=EnergyChartsBiddingZones.DE_LU appearing in the URL
instead of bzn=DE-LU, which caused a 400 Bad Request from the Energy-Charts API.
"""
mock_response = Mock()
mock_response.status_code = 200
mock_response.content = json.dumps(sample_energycharts_json)
mock_get.return_value = mock_response
provider._request_forecast(force_update=True)
provider._request_forecast(force_update=True)
assert mock_get.called, "requests.get was never called"
actual_url: str = mock_get.call_args[0][0]
assert mock_get.called, "requests.get was never called"
actual_url: str = mock_get.call_args[0][0]
# Extract the bzn= query parameter value from the URL
from urllib.parse import parse_qs, urlparse
parsed = urlparse(actual_url)
query_params = parse_qs(parsed.query)
# Extract the bzn= query parameter value from the URL
from urllib.parse import parse_qs, urlparse
parsed = urlparse(actual_url)
query_params = parse_qs(parsed.query)
assert "bzn" in query_params, f"'bzn' parameter missing from URL: {actual_url}"
bzn_value = query_params["bzn"][0]
assert "bzn" in query_params, f"'bzn' parameter missing from URL: {actual_url}"
bzn_value = query_params["bzn"][0]
# Must be the raw enum value, never contain a class name or dot notation
assert "." not in bzn_value, (
f"Bidding zone in URL looks like an enum repr: '{bzn_value}'. "
f"Use .value when building the URL, not str(enum)."
)
assert bzn_value == provider.config.elecprice.energycharts.bidding_zone.value, (
f"Expected bzn='{provider.config.elecprice.energycharts.bidding_zone.value}' "
f"but got bzn='{bzn_value}' in URL: {actual_url}"
)
# Must be the raw enum value, never contain a class name or dot notation
assert "." not in bzn_value, (
f"Bidding zone in URL looks like an enum repr: '{bzn_value}'. "
f"Use .value when building the URL, not str(enum)."
)
assert bzn_value == provider.config.elecprice.energycharts.bidding_zone.value, (
f"Expected bzn='{provider.config.elecprice.energycharts.bidding_zone.value}' "
f"but got bzn='{bzn_value}' in URL: {actual_url}"
)
# ------------------------------------------------
# Development Energy Charts
# ------------------------------------------------
# ------------------------------------------------
# Development Energy Charts
# ------------------------------------------------
@pytest.mark.skip(reason="For development only")
def test_energycharts_development_forecast_data(provider):
"""Fetch data from real Energy-Charts server."""
# Preset, as this is usually done by update_data()
provider.ems_start_datetime = to_datetime("2024-10-26 00:00:00")
@pytest.mark.skip(reason="For development only")
def test_energycharts_development_forecast_data(self, provider):
"""Fetch data from real Energy-Charts server."""
# Preset, as this is usually done by update_data()
provider.ems_start_datetime = to_datetime("2024-10-26 00:00:00")
energy_charts_data = provider._request_forecast()
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)
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)

View File

@@ -1,5 +1,6 @@
"""Tests for fixed electricity price prediction module."""
import asyncio
import json
from pathlib import Path
from unittest.mock import Mock, patch
@@ -91,6 +92,7 @@ def cache_store():
return CacheFileStore()
@pytest.mark.asyncio
class TestElecPriceFixed:
"""Tests for ElecPriceFixed provider."""
@@ -109,7 +111,7 @@ class TestElecPriceFixed:
provider.config.reset_settings()
assert not provider.enabled()
def test_update_data_hourly_intervals(self, provider, config_eos):
async def test_update_data_hourly_intervals(self, provider, config_eos):
"""Test updating data with hourly intervals (3600s)."""
# Set start datetime
ems_eos = get_ems()
@@ -121,7 +123,7 @@ class TestElecPriceFixed:
config_eos.prediction.hours = 24
# Update data
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
# Verify data was generated
assert len(provider) == 24 # 24 hours * 1 interval per hour
@@ -140,7 +142,7 @@ class TestElecPriceFixed:
for i in range(8, 24):
assert abs(records[i].elecprice_marketprice_wh - 0.00034) < 1e-6
def test_update_data_15min_intervals(self, provider, config_eos):
async def test_update_data_15min_intervals(self, provider, config_eos):
"""Test updating data with 15-minute intervals (900s)."""
ems_eos = get_ems()
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
@@ -149,7 +151,7 @@ class TestElecPriceFixed:
config_eos.optimization.interval = 900
config_eos.prediction.hours = 10 # spans both windows: 00:0010:00 = 40 intervals
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
# 10 hours * 4 intervals per hour = 40 intervals
assert len(provider) == 40
@@ -173,7 +175,7 @@ class TestElecPriceFixed:
f"Expected day rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
)
def test_update_data_30min_intervals(self, provider, config_eos):
async def test_update_data_30min_intervals(self, provider, config_eos):
"""Test updating data with 30-minute intervals (1800s)."""
ems_eos = get_ems()
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
@@ -182,7 +184,7 @@ class TestElecPriceFixed:
config_eos.optimization.interval = 1800
config_eos.prediction.hours = 10 # spans both windows: 00:0010:00 = 20 intervals
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
# 10 hours * 2 intervals per hour = 20 intervals
assert len(provider) == 20
@@ -206,24 +208,24 @@ class TestElecPriceFixed:
f"Expected day rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
)
def test_update_data_without_config(self, provider, config_eos):
async def test_update_data_without_config(self, provider, config_eos):
"""Test update_data fails without configuration."""
# Remove elecpricefixed settings
config_eos.elecprice.elecpricefixed = {}
with pytest.raises(ValueError, match="No time windows configured"):
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
def test_update_data_without_time_windows(self, provider, config_eos):
async def test_update_data_without_time_windows(self, provider, config_eos):
"""Test update_data fails without time windows."""
# Set empty time windows
empty_settings = ElecPriceFixedCommonSettings(time_windows=ValueTimeWindowSequence(windows=[]))
config_eos.elecprice.elecpricefixed = empty_settings
with pytest.raises(ValueError, match="No time windows configured"):
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
def test_key_to_array_resampling(self, provider, config_eos):
async def test_key_to_array_resampling(self, provider, config_eos):
"""Test that key_to_array can resample to different intervals."""
# Setup provider with hourly data
ems_eos = get_ems()
@@ -233,10 +235,10 @@ class TestElecPriceFixed:
config_eos.optimization.interval = 3600
config_eos.prediction.hours = 24
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
# Get data as hourly array (original)
hourly_array = provider.key_to_array(
hourly_array = await provider.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24)
@@ -247,7 +249,7 @@ class TestElecPriceFixed:
assert abs(hourly_array[8] - 0.00034) < 1e-6 # Day rate
# Resample to 15-minute intervals
quarter_hour_array = provider.key_to_array(
quarter_hour_array = await provider.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
@@ -260,7 +262,7 @@ class TestElecPriceFixed:
assert abs(quarter_hour_array[i] - 0.000288) < 1e-6
# Resample to 30-minute intervals
half_hour_array = provider.key_to_array(
half_hour_array = await provider.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
@@ -277,7 +279,7 @@ class TestElecPriceFixedIntegration:
"""Integration tests for ElecPriceFixed."""
@pytest.mark.skip(reason="For development only")
def test_fixed_price_development(self, config_eos):
async def test_fixed_price_development(self, config_eos):
"""Test fixed price provider with real configuration."""
# Create provider with config
provider = ElecPriceFixed()
@@ -308,7 +310,7 @@ class TestElecPriceFixedIntegration:
config_eos.optimization.interval = 900 # 15 minutes
# Update data
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
# Verify data
expected_intervals = 168 * 4 # 7 days * 24h * 4 intervals

View File

@@ -1,3 +1,4 @@
import asyncio
import json
from pathlib import Path
@@ -39,76 +40,78 @@ def sample_import_1_json():
return input_data
# ------------------------------------------------
# General forecast
# ------------------------------------------------
@pytest.mark.asyncio
class TestElecPriceImport:
# ------------------------------------------------
# General forecast
# ------------------------------------------------
def test_singleton_instance(provider):
"""Test that ElecPriceForecast behaves as a singleton."""
another_instance = ElecPriceImport()
assert provider is another_instance
def test_singleton_instance(self, provider):
"""Test that ElecPriceForecast behaves as a singleton."""
another_instance = ElecPriceImport()
assert provider is another_instance
def test_invalid_provider(provider, config_eos):
"""Test requesting an unsupported provider."""
settings = {
"elecprice": {
"provider": "<invalid>",
"elecpriceimport": {
"import_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
},
def test_invalid_provider(self, provider, config_eos):
"""Test requesting an unsupported provider."""
settings = {
"elecprice": {
"provider": "<invalid>",
"elecpriceimport": {
"import_file_path": str(FILE_TESTDATA_ELECPRICEIMPORT_1_JSON),
},
}
}
}
with pytest.raises(ValueError, match="not a valid electricity price provider"):
config_eos.merge_settings_from_dict(settings)
with pytest.raises(ValueError, match="not a valid electricity price provider"):
config_eos.merge_settings_from_dict(settings)
# ------------------------------------------------
# Import
# ------------------------------------------------
# ------------------------------------------------
# Import
# ------------------------------------------------
@pytest.mark.parametrize(
"start_datetime, from_file",
[
("2024-11-10 00:00:00", True), # No DST in Germany
("2024-08-10 00:00:00", True), # DST in Germany
("2024-03-31 00:00:00", True), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", True), # DST change in Germany (25 hours/ day)
("2024-11-10 00:00:00", False), # No DST in Germany
("2024-08-10 00:00:00", False), # DST in Germany
("2024-03-31 00:00:00", False), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
],
)
def test_import(provider, sample_import_1_json, start_datetime, from_file, config_eos):
"""Test fetching forecast from Import."""
key = "elecprice_marketprice_wh"
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file:
config_eos.elecprice.elecpriceimport.import_json = None
assert config_eos.elecprice.elecpriceimport.import_json is None
else:
config_eos.elecprice.elecpriceimport.import_file_path = None
assert config_eos.elecprice.elecpriceimport.import_file_path is None
provider.delete_by_datetime(start_datetime=None, end_datetime=None)
# Call the method
provider.update_data()
# Assert: Verify the result is as expected
assert provider.ems_start_datetime is not None
assert provider.total_hours is not None
assert compare_datetimes(provider.ems_start_datetime, ems_eos.start_datetime).equal
expected_values = sample_import_1_json[key]
result_values = provider.key_to_array(
key=key,
start_datetime=provider.ems_start_datetime,
end_datetime=provider.ems_start_datetime + to_duration(f"{len(expected_values)} hours"),
interval=to_duration("1 hour"),
@pytest.mark.parametrize(
"start_datetime, from_file",
[
("2024-11-10 00:00:00", True), # No DST in Germany
("2024-08-10 00:00:00", True), # DST in Germany
("2024-03-31 00:00:00", True), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", True), # DST change in Germany (25 hours/ day)
("2024-11-10 00:00:00", False), # No DST in Germany
("2024-08-10 00:00:00", False), # DST in Germany
("2024-03-31 00:00:00", False), # DST change in Germany (23 hours/ day)
("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
],
)
# Allow for some difference due to value calculation on DST change
npt.assert_allclose(result_values, expected_values, rtol=0.001)
async def test_import(self, provider, sample_import_1_json, start_datetime, from_file, config_eos):
"""Test fetching forecast from Import."""
key = "elecprice_marketprice_wh"
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start_datetime, in_timezone="Europe/Berlin"))
if from_file:
config_eos.elecprice.elecpriceimport.import_json = None
assert config_eos.elecprice.elecpriceimport.import_json is None
else:
config_eos.elecprice.elecpriceimport.import_file_path = None
assert config_eos.elecprice.elecpriceimport.import_file_path is None
await provider.delete_by_datetime(start_datetime=None, end_datetime=None)
# Call the method
await provider.update_data()
# Assert: Verify the result is as expected
assert provider.ems_start_datetime is not None
assert provider.total_hours is not None
assert compare_datetimes(provider.ems_start_datetime, ems_eos.start_datetime).equal
expected_values = sample_import_1_json[key]
result_values = await provider.key_to_array(
key=key,
start_datetime=provider.ems_start_datetime,
end_datetime=provider.ems_start_datetime + to_duration(f"{len(expected_values)} hours"),
interval=to_duration("1 hour"),
)
# Allow for some difference due to value calculation on DST change
npt.assert_allclose(result_values, expected_values, rtol=0.001)

View File

@@ -37,6 +37,7 @@ def compare_dict(actual: dict[str, Any], expected: dict[str, Any]):
assert actual[key] == pytest.approx(value)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"fn_in, fn_out, ngen, break_even",
[

View File

@@ -257,8 +257,8 @@ class TestAcChargingInSimulation:
battery=akku,
)
sim = GeneticSimulation()
sim.prepare(
simulation = GeneticSimulation()
simulation.prepare(
GeneticEnergyManagementParameters(
pv_prognose_wh=[0.0] * prediction_hours, # No PV
strompreis_euro_pro_wh=[0.0003] * prediction_hours, # ~30ct/kWh
@@ -272,7 +272,7 @@ class TestAcChargingInSimulation:
ev=None,
home_appliance=None,
)
return sim, akku, inverter
return simulation, akku, inverter
return _build

View File

@@ -1,8 +1,10 @@
import asyncio
from unittest.mock import patch
import numpy as np
import pendulum
import pytest
import pytest_asyncio
from akkudoktoreos.core.coreabc import get_ems, get_measurement
from akkudoktoreos.measurement.measurement import MeasurementDataRecord
@@ -49,8 +51,8 @@ def loadakkudoktoradjusted(config_eos):
assert config_eos.load.loadakkudoktor.loadakkudoktor_year_energy_kwh == 1000
return LoadAkkudoktorAdjusted()
@pytest.fixture
def measurement_eos():
@pytest_asyncio.fixture
async def measurement_eos():
"""Fixture to initialise the Measurement instance."""
# Load meter readings are in kWh
measurement = get_measurement()
@@ -59,7 +61,7 @@ def measurement_eos():
dt = to_datetime("2024-01-01T00:00:00")
interval = to_duration("1 hour")
for i in range(25):
measurement.insert_by_datetime(
await measurement.insert_by_datetime(
MeasurementDataRecord(
date_time=dt,
load0_mr=load0_mr,
@@ -70,8 +72,10 @@ def measurement_eos():
# 0.05 kWh = 50 Wh
load0_mr += 0.05
load1_mr += 0.05
assert compare_datetimes(measurement.min_datetime, to_datetime("2024-01-01T00:00:00")).equal
assert compare_datetimes(measurement.max_datetime, to_datetime("2024-01-02T00:00:00")).equal
min_dt = await measurement.min_datetime()
max_dt = await measurement.max_datetime()
assert compare_datetimes(min_dt, to_datetime("2024-01-01T00:00:00")).equal
assert compare_datetimes(max_dt, to_datetime("2024-01-02T00:00:00")).equal
return measurement
@@ -87,163 +91,166 @@ def mock_load_profiles_file(tmp_path):
return load_profiles_path
def test_loadakkudoktor_settings_validator():
"""Test the field validator for `loadakkudoktor_year_energy_kwh`."""
settings = LoadAkkudoktorCommonSettings(loadakkudoktor_year_energy_kwh=1234)
assert isinstance(settings.loadakkudoktor_year_energy_kwh, float)
assert settings.loadakkudoktor_year_energy_kwh == 1234.0
@pytest.mark.asyncio
class TestLoadAkkudoktor:
settings = LoadAkkudoktorCommonSettings(loadakkudoktor_year_energy_kwh=1234.56)
assert isinstance(settings.loadakkudoktor_year_energy_kwh, float)
assert settings.loadakkudoktor_year_energy_kwh == 1234.56
async def test_loadakkudoktor_settings_validator(self):
"""Test the field validator for `loadakkudoktor_year_energy_kwh`."""
settings = LoadAkkudoktorCommonSettings(loadakkudoktor_year_energy_kwh=1234)
assert isinstance(settings.loadakkudoktor_year_energy_kwh, float)
assert settings.loadakkudoktor_year_energy_kwh == 1234.0
settings = LoadAkkudoktorCommonSettings(loadakkudoktor_year_energy_kwh=1234.56)
assert isinstance(settings.loadakkudoktor_year_energy_kwh, float)
assert settings.loadakkudoktor_year_energy_kwh == 1234.56
async def test_loadakkudoktor_provider_id(self, loadakkudoktor):
"""Test the `provider_id` class method."""
assert loadakkudoktor.provider_id() == "LoadAkkudoktor"
@patch("akkudoktoreos.prediction.loadakkudoktor.np.load")
async def test_load_data_from_mock(self, mock_np_load, mock_load_profiles_file, loadakkudoktor):
"""Test the `load_data` method."""
# Mock numpy load to return data similar to what would be in the file
mock_np_load.return_value = {
"yearly_profiles": np.ones((365, 24)),
"yearly_profiles_std": np.zeros((365, 24)),
}
# Test data loading
data_year_energy = loadakkudoktor.load_data()
assert data_year_energy is not None
assert data_year_energy.shape == (365, 2, 24)
async def test_load_data_from_file(self, loadakkudoktor):
"""Test `load_data` loads data from the profiles file."""
data_year_energy = loadakkudoktor.load_data()
assert data_year_energy is not None
@patch("akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktor.load_data")
async def test_update_data(self, mock_load_data, loadakkudoktor):
"""Test the `_update` method."""
mock_load_data.return_value = np.random.rand(365, 2, 24)
# Mock methods for updating values
ems_eos = get_ems()
ems_eos.set_start_datetime(pendulum.datetime(2024, 1, 1))
# Assure there are no prediction records
await loadakkudoktor.delete_by_datetime(start_datetime=None, end_datetime=None)
assert len(loadakkudoktor) == 0
# Execute the method
await loadakkudoktor._update_data()
# Validate that update_value is called
assert len(loadakkudoktor) > 0
def test_loadakkudoktor_provider_id(loadakkudoktor):
"""Test the `provider_id` class method."""
assert loadakkudoktor.provider_id() == "LoadAkkudoktor"
@pytest.mark.asyncio
class TestLoadAkkudoktorAdjusted:
async def test_calculate_adjustment(self, loadakkudoktoradjusted, measurement_eos):
"""Test `_calculate_adjustment` for various scenarios."""
data_year_energy = np.random.rand(365, 2, 24)
@patch("akkudoktoreos.prediction.loadakkudoktor.np.load")
def test_load_data_from_mock(mock_np_load, mock_load_profiles_file, loadakkudoktor):
"""Test the `load_data` method."""
# Mock numpy load to return data similar to what would be in the file
mock_np_load.return_value = {
"yearly_profiles": np.ones((365, 24)),
"yearly_profiles_std": np.zeros((365, 24)),
}
# Check the test setup
assert loadakkudoktoradjusted.measurement is measurement_eos
min_dt = await measurement_eos.min_datetime()
assert min_dt == to_datetime("2024-01-01T00:00:00")
max_dt = await measurement_eos.max_datetime()
assert max_dt == to_datetime("2024-01-02T00:00:00")
# Use same calculation as in _calculate_adjustment
compare_start = max_dt - to_duration("7 days")
if compare_datetimes(compare_start, min_dt).lt:
# Not enough measurements for 7 days - use what is available
compare_start = min_dt
compare_end = max_dt
compare_interval = to_duration("1 hour")
load_total_kwh_array = await measurement_eos.load_total_kwh(
start_datetime=compare_start,
end_datetime=compare_end,
interval=compare_interval,
)
np.testing.assert_allclose(load_total_kwh_array, [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1])
# Test data loading
data_year_energy = loadakkudoktor.load_data()
assert data_year_energy is not None
assert data_year_energy.shape == (365, 2, 24)
# Call the method and validate results
weekday_adjust, weekend_adjust = await loadakkudoktoradjusted._calculate_adjustment(data_year_energy)
assert weekday_adjust.shape == (24,)
assert weekend_adjust.shape == (24,)
data_year_energy = np.zeros((365, 2, 24))
weekday_adjust, weekend_adjust = await loadakkudoktoradjusted._calculate_adjustment(data_year_energy)
def test_load_data_from_file(loadakkudoktor):
"""Test `load_data` loads data from the profiles file."""
data_year_energy = loadakkudoktor.load_data()
assert data_year_energy is not None
assert weekday_adjust.shape == (24,)
expected = np.array(
[
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
]
)
np.testing.assert_allclose(weekday_adjust, expected)
assert weekend_adjust.shape == (24,)
expected = np.array(
[
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
]
)
np.testing.assert_array_equal(weekend_adjust, expected)
@patch("akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktor.load_data")
def test_update_data(mock_load_data, loadakkudoktor):
"""Test the `_update` method."""
mock_load_data.return_value = np.random.rand(365, 2, 24)
async def test_provider_adjustments_with_mock_data(self, loadakkudoktoradjusted):
"""Test full integration of adjustments with mock data."""
with patch(
"akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktorAdjusted._calculate_adjustment"
) as mock_adjust:
mock_adjust.return_value = (np.zeros(24), np.zeros(24))
# Mock methods for updating values
ems_eos = get_ems()
ems_eos.set_start_datetime(pendulum.datetime(2024, 1, 1))
# Assure there are no prediction records
loadakkudoktor.delete_by_datetime(start_datetime=None, end_datetime=None)
assert len(loadakkudoktor) == 0
# Execute the method
loadakkudoktor._update_data()
# Validate that update_value is called
assert len(loadakkudoktor) > 0
def test_calculate_adjustment(loadakkudoktoradjusted, measurement_eos):
"""Test `_calculate_adjustment` for various scenarios."""
data_year_energy = np.random.rand(365, 2, 24)
# Check the test setup
assert loadakkudoktoradjusted.measurement is measurement_eos
assert measurement_eos.min_datetime == to_datetime("2024-01-01T00:00:00")
assert measurement_eos.max_datetime == to_datetime("2024-01-02T00:00:00")
# Use same calculation as in _calculate_adjustment
compare_start = measurement_eos.max_datetime - to_duration("7 days")
if compare_datetimes(compare_start, measurement_eos.min_datetime).lt:
# Not enough measurements for 7 days - use what is available
compare_start = measurement_eos.min_datetime
compare_end = measurement_eos.max_datetime
compare_interval = to_duration("1 hour")
load_total_kwh_array = measurement_eos.load_total_kwh(
start_datetime=compare_start,
end_datetime=compare_end,
interval=compare_interval,
)
np.testing.assert_allclose(load_total_kwh_array, [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1])
# Call the method and validate results
weekday_adjust, weekend_adjust = loadakkudoktoradjusted._calculate_adjustment(data_year_energy)
assert weekday_adjust.shape == (24,)
assert weekend_adjust.shape == (24,)
data_year_energy = np.zeros((365, 2, 24))
weekday_adjust, weekend_adjust = loadakkudoktoradjusted._calculate_adjustment(data_year_energy)
assert weekday_adjust.shape == (24,)
expected = np.array(
[
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
100.0,
]
)
np.testing.assert_allclose(weekday_adjust, expected)
assert weekend_adjust.shape == (24,)
expected = np.array(
[
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
]
)
np.testing.assert_array_equal(weekend_adjust, expected)
def test_provider_adjustments_with_mock_data(loadakkudoktoradjusted):
"""Test full integration of adjustments with mock data."""
with patch(
"akkudoktoreos.prediction.loadakkudoktor.LoadAkkudoktorAdjusted._calculate_adjustment"
) as mock_adjust:
mock_adjust.return_value = (np.zeros(24), np.zeros(24))
# Test execution
loadakkudoktoradjusted._update_data()
assert mock_adjust.called
# Test execution
await loadakkudoktoradjusted._update_data()
assert mock_adjust.called

View File

@@ -1,3 +1,4 @@
import asyncio
import json
from unittest.mock import call, patch
@@ -48,69 +49,67 @@ def mock_forecast_response():
totals={}
)
@pytest.mark.asyncio
class TestLoadVRM:
def test_update_data_calls_update_value(load_vrm_instance):
with patch.object(load_vrm_instance, "_request_forecast", return_value=mock_forecast_response()), \
patch.object(LoadVrm, "update_value") as mock_update:
async def test_update_data_calls_update_value(self, load_vrm_instance):
with patch.object(load_vrm_instance, "_request_forecast", return_value=mock_forecast_response()), \
patch.object(LoadVrm, "update_value") as mock_update:
load_vrm_instance._update_data()
await load_vrm_instance._update_data()
assert mock_update.call_count == 2
assert mock_update.call_count == 2
expected_calls = [
call(
pendulum.datetime(2025, 1, 1, 0, 0, 0, tz='Europe/Berlin'),
{"loadforecast_power_w": 100.5,}
),
call(
pendulum.datetime(2025, 1, 1, 1, 0, 0, tz='Europe/Berlin'),
{"loadforecast_power_w": 101.2,}
),
]
expected_calls = [
call(
pendulum.datetime(2025, 1, 1, 0, 0, 0, tz='Europe/Berlin'),
{"loadforecast_power_w": 100.5,}
),
call(
pendulum.datetime(2025, 1, 1, 1, 0, 0, tz='Europe/Berlin'),
{"loadforecast_power_w": 101.2,}
),
]
mock_update.assert_has_calls(expected_calls, any_order=False)
mock_update.assert_has_calls(expected_calls, any_order=False)
def test_validate_data_accepts_valid_json(self):
"""Test that _validate_data doesn't raise with valid input."""
response = mock_forecast_response()
json_data = response.model_dump_json()
def test_validate_data_accepts_valid_json():
"""Test that _validate_data doesn't raise with valid input."""
response = mock_forecast_response()
json_data = response.model_dump_json()
validated = LoadVrm._validate_data(json_data)
assert validated.success
assert len(validated.records.vrm_consumption_fc) == 2
validated = LoadVrm._validate_data(json_data)
assert validated.success
assert len(validated.records.vrm_consumption_fc) == 2
def test_validate_data_raises_on_invalid_json(self):
"""_validate_data should raise ValueError on schema mismatch."""
invalid_json = json.dumps({"success": True}) # missing 'records'
with pytest.raises(ValueError) as exc_info:
LoadVrm._validate_data(invalid_json)
def test_validate_data_raises_on_invalid_json():
"""_validate_data should raise ValueError on schema mismatch."""
invalid_json = json.dumps({"success": True}) # missing 'records'
assert "Field:" in str(exc_info.value)
assert "records" in str(exc_info.value)
with pytest.raises(ValueError) as exc_info:
LoadVrm._validate_data(invalid_json)
def test_request_forecast_raises_on_http_error(self, load_vrm_instance):
with patch("requests.get", side_effect=requests.Timeout("Request timed out")) as mock_get:
with pytest.raises(RuntimeError) as exc_info:
load_vrm_instance._request_forecast(0, 1)
assert "Field:" in str(exc_info.value)
assert "records" in str(exc_info.value)
assert "Failed to fetch load forecast" in str(exc_info.value)
mock_get.assert_called_once()
async def test_update_data_does_nothing_on_empty_forecast(self, load_vrm_instance):
empty_response = VrmForecastResponse(
success=True,
records=VrmForecastRecords(vrm_consumption_fc=[], solar_yield_forecast=[]),
totals={}
)
def test_request_forecast_raises_on_http_error(load_vrm_instance):
with patch("requests.get", side_effect=requests.Timeout("Request timed out")) as mock_get:
with pytest.raises(RuntimeError) as exc_info:
load_vrm_instance._request_forecast(0, 1)
with patch.object(load_vrm_instance, "_request_forecast", return_value=empty_response), \
patch.object(LoadVrm, "update_value") as mock_update:
assert "Failed to fetch load forecast" in str(exc_info.value)
mock_get.assert_called_once()
await load_vrm_instance._update_data()
def test_update_data_does_nothing_on_empty_forecast(load_vrm_instance):
empty_response = VrmForecastResponse(
success=True,
records=VrmForecastRecords(vrm_consumption_fc=[], solar_yield_forecast=[]),
totals={}
)
with patch.object(load_vrm_instance, "_request_forecast", return_value=empty_response), \
patch.object(LoadVrm, "update_value") as mock_update:
load_vrm_instance._update_data()
mock_update.assert_not_called()
mock_update.assert_not_called()

View File

@@ -1,5 +1,6 @@
import numpy as np
import pytest
import pytest_asyncio
from pendulum import datetime, duration
from akkudoktoreos.config.config import SettingsEOS
@@ -219,16 +220,17 @@ class TestMeasurementDataRecord:
assert key in keys
@pytest.mark.asyncio
class TestMeasurement:
"""Test suite for the Measuremen class."""
@pytest.fixture
def measurement_eos(self, config_eos):
@pytest_asyncio.fixture
async def measurement_eos(self, config_eos):
"""Fixture to create a Measurement instance."""
# Load meter readings are in kWh
config_eos.measurement.load_emr_keys = ["load0_mr", "load1_mr", "load2_mr", "load3_mr"]
measurement = get_measurement()
measurement.delete_by_datetime(None, None)
await measurement.delete_by_datetime(None, None)
record0 = MeasurementDataRecord(
date_time=datetime(2023, 1, 1, hour=0),
load0_mr=100,
@@ -269,10 +271,10 @@ class TestMeasurement:
),
]
for record in records:
measurement.insert_by_datetime(record)
await measurement.insert_by_datetime(record)
return measurement
def test_interval_count(self, measurement_eos):
async def test_interval_count(self, measurement_eos):
"""Test interval count calculation."""
start = to_datetime("2023-01-01T00:00:00")
end = to_datetime("2023-01-01T03:00:00")
@@ -280,7 +282,7 @@ class TestMeasurement:
assert measurement_eos._interval_count(start, end, interval) == 3
def test_interval_count_invalid_end_before_start(self, measurement_eos):
async def test_interval_count_invalid_end_before_start(self, measurement_eos):
"""Test interval count raises ValueError when end_datetime is before start_datetime."""
start = to_datetime("2023-01-01T03:00:00")
end = to_datetime("2023-01-01T00:00:00")
@@ -289,7 +291,7 @@ class TestMeasurement:
with pytest.raises(ValueError, match="end_datetime must be after start_datetime"):
measurement_eos._interval_count(start, end, interval)
def test_interval_count_invalid_non_positive_interval(self, measurement_eos):
async def test_interval_count_invalid_non_positive_interval(self, measurement_eos):
"""Test interval count raises ValueError when interval is non-positive."""
start = to_datetime("2023-01-01T00:00:00")
end = to_datetime("2023-01-01T03:00:00")
@@ -297,21 +299,21 @@ class TestMeasurement:
with pytest.raises(ValueError, match="interval must be positive"):
measurement_eos._interval_count(start, end, duration(hours=0))
def test_energy_from_meter_readings_valid_input(self, measurement_eos):
async def test_energy_from_meter_readings_valid_input(self, measurement_eos):
"""Test _energy_from_meter_readings with valid inputs and proper alignment of load data."""
key = "load0_mr"
start_datetime = to_datetime("2023-01-01T00:00:00")
end_datetime = to_datetime("2023-01-01T05:00:00")
interval = duration(hours=1)
load_array = measurement_eos._energy_from_meter_readings(
load_array = await measurement_eos._energy_from_meter_readings(
key, start_datetime, end_datetime, interval
)
expected_load_array = np.array([50, 50, 50, 50, 50]) # Differences between consecutive readings
np.testing.assert_array_equal(load_array, expected_load_array)
def test_energy_from_meter_readings_empty_array(self, measurement_eos):
async def test_energy_from_meter_readings_empty_array(self, measurement_eos):
"""Test _energy_from_meter_readings with no data (empty array)."""
key = "load0_mr"
start_datetime = to_datetime("2023-01-01T00:00:00")
@@ -319,9 +321,9 @@ class TestMeasurement:
interval = duration(hours=1)
# Use empyt records array
measurement_eos.delete_by_datetime(start_datetime, end_datetime)
await measurement_eos.delete_by_datetime(start_datetime, end_datetime)
load_array = measurement_eos._energy_from_meter_readings(
load_array = await measurement_eos._energy_from_meter_readings(
key, start_datetime, end_datetime, interval
)
@@ -332,7 +334,7 @@ class TestMeasurement:
expected_load_array = np.zeros(expected_size)
np.testing.assert_array_equal(load_array, expected_load_array)
def test_energy_from_meter_readings_misaligned_array(self, measurement_eos):
async def test_energy_from_meter_readings_misaligned_array(self, measurement_eos):
"""Test _energy_from_meter_readings with misaligned array size."""
key = "load1_mr"
interval = duration(hours=1)
@@ -342,15 +344,15 @@ class TestMeasurement:
# Use misaligned array, latest interval set to 2 hours (instead of 1 hour)
latest_record_datetime = to_datetime("2023-01-01T05:00:00")
new_record_datetime = to_datetime("2023-01-01T06:00:00")
record = measurement_eos.get_by_datetime(latest_record_datetime)
record = await measurement_eos.get_by_datetime(latest_record_datetime)
assert record is not None
measurement_eos.delete_by_datetime(start_datetime = latest_record_datetime,
end_datetime = new_record_datetime)
await measurement_eos.delete_by_datetime(start_datetime = latest_record_datetime,
end_datetime = new_record_datetime)
record.date_time = new_record_datetime
measurement_eos.insert_by_datetime(record)
await measurement_eos.insert_by_datetime(record)
# Check test setup
dates, values = measurement_eos.key_to_lists(key, start_datetime, None)
dates, values = await measurement_eos.key_to_lists(key, start_datetime, None)
assert dates == [
to_datetime("2023-01-01T00:00:00"),
to_datetime("2023-01-01T01:00:00"),
@@ -360,17 +362,17 @@ class TestMeasurement:
to_datetime("2023-01-01T06:00:00"),
]
assert values == [200, 250, 300, 350, 400, 450]
array = measurement_eos.key_to_array(key, start_datetime, end_datetime + interval, interval=interval)
array = await measurement_eos.key_to_array(key, start_datetime, end_datetime + interval, interval=interval)
np.testing.assert_array_equal(array, [200, 250, 300, 350, 400, 425])
load_array = measurement_eos._energy_from_meter_readings(
load_array = await measurement_eos._energy_from_meter_readings(
key, start_datetime, end_datetime, interval
)
expected_load_array = np.array([50., 50., 50., 50., 25.]) # Differences between consecutive readings
np.testing.assert_array_equal(load_array, expected_load_array)
def test_energy_from_meter_readings_partial_data(self, measurement_eos, caplog):
async def test_energy_from_meter_readings_partial_data(self, measurement_eos, caplog):
"""Test _energy_from_meter_readings with partial data (misaligned but empty array)."""
key = "load2_mr"
start_datetime = to_datetime("2023-01-01T00:00:00")
@@ -378,7 +380,7 @@ class TestMeasurement:
interval = duration(hours=1)
with caplog.at_level("DEBUG"):
load_array = measurement_eos._energy_from_meter_readings(
load_array = await measurement_eos._energy_from_meter_readings(
key, start_datetime, end_datetime, interval
)
@@ -388,7 +390,7 @@ class TestMeasurement:
expected_load_array = np.zeros(expected_size)
np.testing.assert_array_equal(load_array, expected_load_array)
def test_energy_from_meter_readings_negative_interval(self, measurement_eos):
async def test_energy_from_meter_readings_negative_interval(self, measurement_eos):
"""Test _energy_from_meter_readings with a negative interval."""
key = "load3_mr"
start_datetime = to_datetime("2023-01-01T00:00:00")
@@ -396,37 +398,37 @@ class TestMeasurement:
interval = duration(hours=-1)
with pytest.raises(ValueError, match="interval must be positive"):
measurement_eos._energy_from_meter_readings(key, start_datetime, end_datetime, interval)
await measurement_eos._energy_from_meter_readings(key, start_datetime, end_datetime, interval)
def test_load_total_kwh(self, measurement_eos):
async def test_load_total_kwh(self, measurement_eos):
"""Test total load calculation."""
start_datetime = to_datetime("2023-01-01T03:00:00")
end_datetime = to_datetime("2023-01-01T05:00:00")
interval = duration(hours=1)
result = measurement_eos.load_total_kwh(start_datetime=start_datetime, end_datetime=end_datetime, interval=interval)
result = await measurement_eos.load_total_kwh(start_datetime=start_datetime, end_datetime=end_datetime, interval=interval)
# Expected total load per interval
expected = np.array([100, 100]) # Differences between consecutive meter readings
np.testing.assert_array_equal(result, expected)
def test_load_total_kwh_no_data(self, measurement_eos):
async def test_load_total_kwh_no_data(self, measurement_eos):
"""Test total load calculation with no data."""
measurement_eos.records = []
start_datetime = to_datetime("2023-01-01T00:00:00")
end_datetime = to_datetime("2023-01-01T03:00:00")
interval = duration(hours=1)
result = measurement_eos.load_total_kwh(start_datetime=start_datetime, end_datetime=end_datetime, interval=interval)
result = await measurement_eos.load_total_kwh(start_datetime=start_datetime, end_datetime=end_datetime, interval=interval)
expected = np.zeros(3) # No data, so all intervals are zero
np.testing.assert_array_equal(result, expected)
def test_load_total_kwh_partial_intervals(self, measurement_eos):
async def test_load_total_kwh_partial_intervals(self, measurement_eos):
"""Test total load calculation with partial intervals."""
start_datetime = to_datetime("2023-01-01T00:30:00") # Start in the middle of an interval
end_datetime = to_datetime("2023-01-01T01:30:00") # End in the middle of another interval
interval = duration(hours=1)
result = measurement_eos.load_total_kwh(start_datetime=start_datetime, end_datetime=end_datetime, interval=interval)
result = await measurement_eos.load_total_kwh(start_datetime=start_datetime, end_datetime=end_datetime, interval=interval)
expected = np.array([100]) # Only one complete interval covered
np.testing.assert_array_equal(result, expected)

View File

@@ -133,13 +133,14 @@ def test_prediction_repr(prediction):
assert "WeatherImport" in result
def test_empty_providers(prediction, forecast_providers):
@pytest.mark.asyncio
async def test_empty_providers(prediction, forecast_providers):
"""Test behavior when Prediction does not have providers."""
# Clear all prediction providers from prediction
providers_bkup = prediction.providers.copy()
prediction.providers.clear()
assert prediction.providers == []
prediction.update_data() # Should not raise an error even with no providers
await prediction.update_data() # Should not raise an error even with no providers
# Cleanup after Test
prediction.providers = providers_bkup

View File

@@ -5,6 +5,7 @@ from typing import Any, ClassVar, List, Optional, Union
import pandas as pd
import pendulum
import pytest
import pytest_asyncio
from pydantic import Field
from akkudoktoreos.core.coreabc import get_ems
@@ -69,7 +70,7 @@ class DerivedPredictionProvider(PredictionProvider):
def enabled(self) -> bool:
return self.provider_enabled
def _update_data(self, force_update: Optional[bool] = False) -> None:
async def _update_data(self, force_update: Optional[bool] = False) -> None:
# Simulate update logic
DerivedPredictionProvider.provider_updated = True
@@ -127,6 +128,7 @@ class TestPredictionABC:
# --------------------------------------------------------
@pytest.mark.asyncio
class TestPredictionProvider:
# Fixtures and helper functions
@pytest.fixture
@@ -147,7 +149,7 @@ class TestPredictionProvider:
# Tests
def test_singleton_behavior(self, provider):
async def test_singleton_behavior(self, provider):
"""Test that PredictionProvider enforces singleton behavior."""
instance1 = provider
instance2 = DerivedPredictionProvider()
@@ -155,7 +157,7 @@ class TestPredictionProvider:
"Singleton pattern is not enforced; instances are not the same."
)
def test_update_computed_fields(self, provider, sample_start_datetime):
async def test_update_computed_fields(self, provider, sample_start_datetime):
"""Test that computed fields `end_datetime` and `keep_datetime` are correctly calculated."""
ems_eos = get_ems()
ems_eos.set_start_datetime(sample_start_datetime)
@@ -176,7 +178,7 @@ class TestPredictionProvider:
"Keep datetime is not calculated correctly."
)
def test_update_method_with_defaults(
async def test_update_method_with_defaults(
self, provider, sample_start_datetime, config_eos, monkeypatch
):
"""Test the `update` method with default parameters."""
@@ -188,7 +190,7 @@ class TestPredictionProvider:
provider.config.reset_settings()
ems_eos.set_start_datetime(sample_start_datetime)
provider.update_data()
await provider.update_data()
assert provider.config.prediction.hours == config_eos.prediction.hours
assert provider.config.prediction.historic_hours == 2
@@ -198,7 +200,7 @@ class TestPredictionProvider:
)
assert provider.keep_datetime == sample_start_datetime - to_duration("2 hours")
def test_update_method_force_enable(self, provider, monkeypatch):
async def test_update_method_force_enable(self, provider, monkeypatch):
"""Test that `update` executes when `force_enable` is True, even if `enabled` is False."""
# Preset values that are needed by update
monkeypatch.setenv("EOS_GENERAL__LATITUDE", "37.7749")
@@ -207,13 +209,13 @@ class TestPredictionProvider:
# Override enabled to return False for this test
DerivedPredictionProvider.provider_enabled = False
DerivedPredictionProvider.provider_updated = False
provider.update_data(force_enable=True)
await provider.update_data(force_enable=True)
assert provider.enabled() is False, "Provider should be disabled, but enabled() is True."
assert DerivedPredictionProvider.provider_updated is True, (
"Provider should have been executed, but was not."
)
def test_delete_by_datetime(self, provider, sample_start_datetime):
async def test_delete_by_datetime(self, provider, sample_start_datetime):
"""Test `delete_by_datetime` method for removing records by datetime range."""
# Add records to the provider for deletion testing
records = [
@@ -222,9 +224,9 @@ class TestPredictionProvider:
self.create_test_record(sample_start_datetime + to_duration("1 hour"), 3),
]
for record in records:
provider.insert_by_datetime(record)
await provider.insert_by_datetime(record)
provider.delete_by_datetime(
await provider.delete_by_datetime(
start_datetime=sample_start_datetime - to_duration("2 hours"),
end_datetime=sample_start_datetime + to_duration("2 hours"),
)
@@ -236,6 +238,7 @@ class TestPredictionProvider:
)
@pytest.mark.asyncio
class TestPredictionContainer:
# Fixture and helpers
@pytest.fixture
@@ -243,8 +246,8 @@ class TestPredictionContainer:
container = DerivedPredictionContainer()
return container
@pytest.fixture
def container_with_providers(self):
@pytest_asyncio.fixture
async def container_with_providers(self):
records = [
# Test records - include 'prediction_value' key
self.create_test_record(datetime(2023, 11, 5), 1),
@@ -252,10 +255,10 @@ class TestPredictionContainer:
self.create_test_record(datetime(2023, 11, 7), 3),
]
provider = DerivedPredictionProvider()
provider.delete_by_datetime(start_datetime=None, end_datetime=None)
await provider.delete_by_datetime(start_datetime=None, end_datetime=None)
assert len(provider) == 0
for record in records:
provider.insert_by_datetime(record)
await provider.insert_by_datetime(record)
assert len(provider) == 3
container = DerivedPredictionContainer()
container.providers.clear()
@@ -282,7 +285,7 @@ class TestPredictionContainer:
("2024-10-27 00:00:00", 48, "2024-10-29 00:00:00"), # DST change (49 hours/ day)
],
)
def test_end_datetime(self, container, start, hours, end):
async def test_end_datetime(self, container, start, hours, end):
"""Test end datetime calculation from start datetime."""
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
@@ -312,7 +315,7 @@ class TestPredictionContainer:
),
],
)
def test_keep_datetime(self, container, start, historic_hours, expected_keep):
async def test_keep_datetime(self, container, start, historic_hours, expected_keep):
"""Test the `keep_datetime` property."""
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
@@ -334,7 +337,7 @@ class TestPredictionContainer:
("2024-10-27 00:00:00", 24, 25), # DST change in Germany (25 hours/ day)
],
)
def test_total_hours(self, container, start, hours, expected_hours):
async def test_total_hours(self, container, start, hours, expected_hours):
"""Test the `total_hours` property."""
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
@@ -355,7 +358,7 @@ class TestPredictionContainer:
("2024-10-28 00:00:00", 24, 24), # DST change on 2024-10-27 in Germany (25 hours/ day)
],
)
def test_keep_hours(self, container, start, historic_hours, expected_hours):
async def test_keep_hours(self, container, start, historic_hours, expected_hours):
"""Test the `keep_hours` property."""
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime(start, in_timezone="Europe/Berlin"))
@@ -367,80 +370,37 @@ class TestPredictionContainer:
container.config.merge_settings_from_dict(settings)
assert container.keep_hours == expected_hours
def test_append_provider(self, container):
async def test_append_provider(self, container):
assert len(container.providers) == 0
container.providers.append(DerivedPredictionProvider())
assert len(container.providers) == 1
assert isinstance(container.providers[0], DerivedPredictionProvider)
@pytest.mark.skip(reason="type check not implemented")
def test_append_provider_invalid_type(self, container):
async def test_append_provider_invalid_type(self, container):
with pytest.raises(ValueError, match="must be an instance of PredictionProvider"):
container.providers.append("not_a_provider")
def test_getitem_existing_key(self, container_with_providers):
assert len(container_with_providers.providers) == 1
# check all keys are available (don't care for position)
for key in ["prediction_value", "date_time"]:
assert key in container_with_providers.record_keys
for key in ["prediction_value", "date_time"]:
assert key in container_with_providers.keys()
series = container_with_providers["prediction_value"]
assert isinstance(series, pd.Series)
assert series.name == "prediction_value"
assert series.tolist() == [1.0, 2.0, 3.0]
def test_getitem_non_existing_key(self, container_with_providers):
with pytest.raises(KeyError, match="No data found for key 'non_existent_key'"):
container_with_providers["non_existent_key"]
def test_setitem_existing_key(self, container_with_providers):
new_series = container_with_providers["prediction_value"]
new_series[:] = [4, 5, 6]
container_with_providers["prediction_value"] = new_series
series = container_with_providers["prediction_value"]
assert series.name == "prediction_value"
assert series.tolist() == [4, 5, 6]
def test_setitem_invalid_value(self, container_with_providers):
with pytest.raises(ValueError, match="Value must be an instance of pd.Series"):
container_with_providers["test_key"] = "not_a_series"
def test_setitem_non_existing_key(self, container_with_providers):
new_series = pd.Series([4, 5, 6], name="non_existent_key")
with pytest.raises(KeyError, match="Key 'non_existent_key' not found"):
container_with_providers["non_existent_key"] = new_series
def test_delitem_existing_key(self, container_with_providers):
del container_with_providers["prediction_value"]
series = container_with_providers["prediction_value"]
assert series.name == "prediction_value"
assert series.tolist() == []
def test_delitem_non_existing_key(self, container_with_providers):
with pytest.raises(KeyError, match="Key 'non_existent_key' not found"):
del container_with_providers["non_existent_key"]
def test_len(self, container_with_providers):
async def test_len(self, container_with_providers):
assert len(container_with_providers) == 2
def test_repr(self, container_with_providers):
async def test_repr(self, container_with_providers):
representation = repr(container_with_providers)
assert representation.startswith("DerivedPredictionContainer(")
assert "DerivedPredictionProvider" in representation
def test_to_json(self, container_with_providers):
async def test_to_json(self, container_with_providers):
json_str = container_with_providers.to_json()
container_other = DerivedPredictionContainer.from_json(json_str)
assert container_other == container_with_providers
def test_from_json(self, container_with_providers):
async def test_from_json(self, container_with_providers):
json_str = container_with_providers.to_json()
container = DerivedPredictionContainer.from_json(json_str)
assert isinstance(container, DerivedPredictionContainer)
assert len(container.providers) == 1
assert container.providers[0] == container_with_providers.providers[0]
def test_provider_by_id(self, container_with_providers):
async def test_provider_by_id(self, container_with_providers):
provider = container_with_providers.provider_by_id("DerivedPredictionProvider")
assert isinstance(provider, DerivedPredictionProvider)

View File

@@ -3,6 +3,7 @@ from pathlib import Path
from unittest.mock import Mock, patch
import pytest
import pytest_asyncio
from loguru import logger
from akkudoktoreos.core.coreabc import get_ems, get_prediction
@@ -132,11 +133,11 @@ def provider():
return provider
@pytest.fixture
def provider_empty_instance():
@pytest_asyncio.fixture
async def provider_empty_instance():
"""Fixture that returns an empty instance of PVForecast."""
empty_instance = PVForecastAkkudoktor()
empty_instance.delete_by_datetime(start_datetime=None, end_datetime=None)
await empty_instance.delete_by_datetime(start_datetime=None, end_datetime=None)
assert len(empty_instance) == 0
return empty_instance
@@ -234,7 +235,8 @@ def test_pvforecast_akkudoktor_data_record():
) # Assuming AC power measured is preferred
def test_pvforecast_akkudoktor_validate_data(provider_empty_instance, sample_forecast_data_raw):
@pytest.mark.asyncio
async def test_pvforecast_akkudoktor_validate_data(provider_empty_instance, sample_forecast_data_raw):
"""Test validation of PV forecast data on sample data."""
logger.info("The following errors are intentional and part of the test.")
with pytest.raises(
@@ -246,7 +248,8 @@ def test_pvforecast_akkudoktor_validate_data(provider_empty_instance, sample_for
# everything worked
def test_pvforecast_akkudoktor_validate_data_single_plane(
@pytest.mark.asyncio
async def test_pvforecast_akkudoktor_validate_data_single_plane(
provider_empty_instance, sample_forecast_data_single_plane_raw
):
"""Test validation of PV forecast data on sample data with a single plane."""
@@ -260,8 +263,9 @@ def test_pvforecast_akkudoktor_validate_data_single_plane(
# everything worked
@pytest.mark.asyncio
@patch("requests.get")
def test_pvforecast_akkudoktor_update_with_sample_forecast(
async def test_pvforecast_akkudoktor_update_with_sample_forecast(
mock_get, sample_settings, sample_forecast_data_raw, sample_forecast_start, provider
):
"""Test data processing using sample forecast data."""
@@ -274,13 +278,14 @@ def test_pvforecast_akkudoktor_update_with_sample_forecast(
# Test that update properly inserts data records
ems_eos = get_ems()
ems_eos.set_start_datetime(sample_forecast_start)
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
assert compare_datetimes(provider.ems_start_datetime, sample_forecast_start).equal
assert compare_datetimes(provider.records[0].date_time, to_datetime(sample_forecast_start)).equal
# Report Generation Test
def test_report_ac_power_and_measurement(provider, config_eos):
@pytest.mark.asyncio
async def test_report_ac_power_and_measurement(provider, config_eos):
# Set the configuration
config_eos.merge_settings_from_dict(sample_config_data)
@@ -289,7 +294,7 @@ def test_report_ac_power_and_measurement(provider, config_eos):
pvforecast_dc_power=450.0,
pvforecast_ac_power=400.0,
)
provider.insert_by_datetime(record)
await provider.insert_by_datetime(record)
report = provider.report_ac_power_and_measurement()
assert "DC: 450.0" in report
@@ -300,8 +305,9 @@ def test_report_ac_power_and_measurement(provider, config_eos):
@pytest.mark.skipif(
sys.platform.startswith("win"), reason="'other_timezone' fixture not supported on Windows"
)
@pytest.mark.asyncio
@patch("requests.get")
def test_timezone_behaviour(
async def test_timezone_behaviour(
mock_get,
sample_settings,
sample_forecast_data_raw,
@@ -322,17 +328,17 @@ def test_timezone_behaviour(
expected_datetime = to_datetime("2024-10-06T00:00:00+0200", in_timezone=other_timezone)
assert compare_datetimes(other_start_datetime, expected_datetime).equal
provider.delete_by_datetime(start_datetime=None, end_datetime=None)
await provider.delete_by_datetime(start_datetime=None, end_datetime=None)
assert len(provider) == 0
ems_eos = get_ems()
ems_eos.set_start_datetime(other_start_datetime)
provider.update_data(force_update=True)
await provider.update_data(force_update=True)
assert compare_datetimes(provider.ems_start_datetime, other_start_datetime).equal
# Check wether first record starts at requested sample start time
assert compare_datetimes(provider.records[0].date_time, sample_forecast_start).equal
# Test updating AC power measurement for a specific date.
provider.update_value(sample_forecast_start, "pvforecastakkudoktor_ac_power_measured", 1000)
await provider.update_value(sample_forecast_start, "pvforecastakkudoktor_ac_power_measured", 1000)
# Check wether first record was filled with ac power measurement
assert provider.records[0].pvforecastakkudoktor_ac_power_measured == 1000
@@ -340,7 +346,7 @@ def test_timezone_behaviour(
other_end_datetime = other_start_datetime + to_duration("24 hours")
expected_end_datetime = to_datetime("2024-10-07T00:00:00+0200", in_timezone=other_timezone)
assert compare_datetimes(other_end_datetime, expected_end_datetime).equal
forecast_temps = provider.key_to_series(
forecast_temps = await provider.key_to_series(
"pvforecastakkudoktor_temp_air", other_start_datetime, other_end_datetime
)
assert len(forecast_temps) == 23 # 24-1, first temperature is null
@@ -349,7 +355,7 @@ def test_timezone_behaviour(
# Test fetching AC power forecast
other_end_datetime = other_start_datetime + to_duration("48 hours")
forecast_measured = provider.key_to_series(
forecast_measured = await provider.key_to_series(
"pvforecastakkudoktor_ac_power_measured", other_start_datetime, other_end_datetime
)
assert len(forecast_measured) == 1

View File

@@ -72,7 +72,7 @@ def test_invalid_provider(provider, config_eos):
# Import
# ------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
"start_datetime, from_file",
[
@@ -86,7 +86,7 @@ def test_invalid_provider(provider, config_eos):
("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
],
)
def test_import(provider, sample_import_1_json, start_datetime, from_file, config_eos):
async def test_import(provider, sample_import_1_json, start_datetime, from_file, config_eos):
"""Test fetching forecast from import."""
key = "pvforecast_ac_power"
ems_eos = get_ems()
@@ -97,10 +97,10 @@ def test_import(provider, sample_import_1_json, start_datetime, from_file, confi
else:
config_eos.pvforecast.provider_settings.PVForecastImport.import_file_path = None
assert config_eos.pvforecast.provider_settings.PVForecastImport.import_file_path is None
provider.delete_by_datetime(start_datetime=None, end_datetime=None)
await provider.delete_by_datetime(start_datetime=None, end_datetime=None)
# Call the method
provider.update_data()
await provider.update_data()
# Assert: Verify the result is as expected
assert provider.ems_start_datetime is not None
@@ -108,7 +108,7 @@ def test_import(provider, sample_import_1_json, start_datetime, from_file, confi
assert compare_datetimes(provider.ems_start_datetime, ems_eos.start_datetime).equal
expected_values = sample_import_1_json[key]
result_values = provider.key_to_array(
result_values = await provider.key_to_array(
key=key,
start_datetime=provider.ems_start_datetime,
end_datetime=provider.ems_start_datetime + to_duration(f"{len(expected_values)} hours"),

View File

@@ -51,11 +51,12 @@ def mock_forecast_response():
)
def test_update_data_updates_dc_and_ac_power(pvforecast_instance):
@pytest.mark.asyncio
async def test_update_data_updates_dc_and_ac_power(pvforecast_instance):
with patch.object(pvforecast_instance, "_request_forecast", return_value=mock_forecast_response()), \
patch.object(PVForecastVrm, "update_value") as mock_update:
pvforecast_instance._update_data()
await pvforecast_instance._update_data()
# Check that update_value was called correctly
assert mock_update.call_count == 2
@@ -103,7 +104,8 @@ def test_request_forecast_raises_on_http_error(pvforecast_instance):
mock_get.assert_called_once()
def test_update_data_skips_on_empty_forecast(pvforecast_instance):
@pytest.mark.asyncio
async def test_update_data_skips_on_empty_forecast(pvforecast_instance):
"""Ensure no update_value calls are made if no forecast data is present."""
empty_response = VrmForecastResponse(
success=True,
@@ -114,5 +116,5 @@ def test_update_data_skips_on_empty_forecast(pvforecast_instance):
with patch.object(pvforecast_instance, "_request_forecast", return_value=empty_response), \
patch.object(PVForecastVrm, "update_value") as mock_update:
pvforecast_instance._update_data()
await pvforecast_instance._update_data()
mock_update.assert_not_called()

View File

@@ -1,5 +1,6 @@
import json
import os
import random
import signal
import time
from http import HTTPStatus
@@ -8,12 +9,15 @@ from pathlib import Path
import pytest
import requests
from akkudoktoreos.utils.datetimeutil import to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_EOSSERVER_CONFIG_1 = DIR_TESTDATA.joinpath("eosserver_config_1.json")
class TestSystem:
def test_prediction_brightsky(self, server_setup_for_class, is_system_test):
"""Test weather prediction by BrightSky."""
server = server_setup_for_class["server"]
@@ -412,6 +416,283 @@ class TestSystem:
f"Expected 400 for invalid datetime, got {result.status_code}"
)
def test_measurement_high_frequency_and_duplicates(self, server_setup_for_class):
"""Simulate production-like high-frequency measurement updates."""
server = server_setup_for_class["server"]
# ----------------------------------------------------------------------
# 1. Configure measurement keys
# ----------------------------------------------------------------------
config = {
"database": {
"provider": "LMDB",
},
"measurement": {
"pv_production_emr_keys": [
"pv1_emr_kwh",
"pv2_emr_kwh",
],
"load_emr_keys": [
"load1_emr_kwh",
"load2_emr_kwh",
],
}
}
result = requests.put(f"{server}/v1/config", json=config)
assert result.status_code == HTTPStatus.OK
result = requests.delete(f"{server}/v1/measurement/range", params={"key": "pv1_emr_kwh"})
assert result.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
result = requests.delete(f"{server}/v1/measurement/range", params={"key": "pv2_emr_kwh"})
assert result.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
result = requests.delete(f"{server}/v1/measurement/range", params={"key": "load1_emr_kwh"})
assert result.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
result = requests.delete(f"{server}/v1/measurement/range", params={"key": "load2_emr_kwh"})
assert result.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
# ----------------------------------------------------------------------
# 2. Simulate high-frequency writes (1 Hz, mixed keys)
# ----------------------------------------------------------------------
base_time = "2026-04-10T00:00:00"
timestamps = [
"2026-04-10T00:00:16",
"2026-04-10T00:00:46",
"2026-04-10T00:01:46",
"2026-04-10T00:02:32",
"2026-04-10T00:02:32", # duplicate
"2026-04-10T00:03:32",
"2026-04-10T00:03:32", # duplicate
]
test_cases = [
# valid
("pv1_emr_kwh", -687),
("pv2_emr_kwh", 0.76),
("load1_emr_kwh", 500),
("load2_emr_kwh", 0.0),
# invalid
("invalid-key-1", 123),
("invalid-key-2", 456),
]
results = []
for ts in timestamps:
for key, value in test_cases:
response = requests.put(
f"{server}/v1/measurement/value",
params={
"datetime": ts, # NOTE: naive datetime like production
"key": key,
"value": str(value),
},
)
results.append((ts, key, response.status_code))
result = requests.post(f"{server}/v1/admin/database/save")
assert result.status_code == HTTPStatus.OK
# ----------------------------------------------------------------------
# 3. Assertions: system must behave robustly
# ----------------------------------------------------------------------
# A. Invalid keys must consistently return 404
for ts, key, status in results:
if key.startswith("invalid"):
assert status == HTTPStatus.NOT_FOUND
else:
assert status != HTTPStatus.NOT_FOUND
# B. Valid keys must NEVER produce 500
for ts, key, status in results:
if key in ("pv1_emr_kwh", "pv2_emr_kwh"):
assert status != HTTPStatus.INTERNAL_SERVER_ERROR, (
f"500 error for key={key}, ts={ts}"
)
# C. Duplicates must be handled gracefully (200 or 409 or similar, but not 500)
duplicate_failures = [
(ts, key, status)
for ts, key, status in results
if ts in ("2026-04-10T00:02:32", "2026-04-10T00:03:32")
and key == "pv1_emr_kwh"
and status == HTTPStatus.INTERNAL_SERVER_ERROR
]
assert not duplicate_failures, f"Duplicate timestamp caused 500: {duplicate_failures}"
# ----------------------------------------------------------------------
# 4. Verify data integrity (no explosion / corruption)
# ----------------------------------------------------------------------
result = requests.get(
f"{server}/v1/measurement/series",
params={"key": "pv1_emr_kwh"},
)
assert result.status_code == HTTPStatus.OK
data = result.json()["data"]
# Should not contain excessive duplicates
assert len(data) <= len(set(timestamps)), "Duplicate timestamps not handled properly"
@pytest.mark.parametrize("db_provider", ["LMDB", "SQLite", None])
def test_measurement_realtime_stream(self, db_provider, server_setup_for_class):
"""Simulate real production stream: 1 Hz updates with jitter, duplicates, and out-of-order timestamps."""
server = server_setup_for_class["server"]
# ----------------------------------------------------------------------
# 1. Configure measurement keys and measurement database
# ----------------------------------------------------------------------
config = {
"database": {
"provider": db_provider,
},
"measurement": {
"pv_production_emr_keys": ["pv1_emr_kwh"],
"load_emr_keys": ["load1_emr_kwh"],
}
}
result = requests.put(f"{server}/v1/config", json=config)
assert result.status_code == HTTPStatus.OK, f"Failed: {result.status_code} {result}"
result = requests.delete(f"{server}/v1/measurement/range", params={"key": "pv1_emr_kwh"})
assert result.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
result = requests.delete(f"{server}/v1/measurement/range", params={"key": "load1_emr_kwh"})
assert result.status_code in (HTTPStatus.OK, HTTPStatus.NOT_FOUND)
# ----------------------------------------------------------------------
# 2. Real-time simulation
# ----------------------------------------------------------------------
start = to_datetime().replace(microsecond=0)
sent_timestamps = []
errors = []
for i in range(20): # run ~20 seconds
now = to_datetime().replace(microsecond=0)
# --- main timestamp ---
ts = str(now)
# --- simulate normal write ---
response = requests.put(
f"{server}/v1/measurement/value",
params={
"datetime": ts,
"key": "pv1_emr_kwh",
"value": str(random.uniform(0, 1000)),
},
)
if response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
errors.append(("main", ts, response.text))
sent_timestamps.append(ts)
# ------------------------------------------------------------------
# Inject real-world problems
# ------------------------------------------------------------------
# 0. Other key
if random.random() < 0.5:
now_same_second = now.add(microseconds=430)
ts_same_second = str(now_same_second)
response = requests.put(
f"{server}/v1/measurement/value",
params={
"datetime": ts_same_second,
"key": "load1_emr_kwh",
"value": str(random.uniform(0, 1000)),
},
)
if response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
errors.append(("other", ts, response.text))
# 1. Duplicate timestamp (same second, slightly later)
if random.random() < 0.5:
time.sleep(0.05) # slight delay
response = requests.put(
f"{server}/v1/measurement/value",
params={
"datetime": ts,
"key": "pv1_emr_kwh",
"value": str(random.uniform(0, 1000)),
},
)
if response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
errors.append(("duplicate", ts, response.text))
# 2. Out-of-order timestamp (older data arrives late)
if len(sent_timestamps) > 2 and random.random() < 0.5:
old_ts = random.choice(sent_timestamps[:-1])
response = requests.put(
f"{server}/v1/measurement/value",
params={
"datetime": old_ts,
"key": "pv1_emr_kwh",
"value": str(random.uniform(0, 1000)),
},
)
if response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
errors.append(("out_of_order", old_ts, response.text))
# 3. Same second burst (multiple writes in same second)
if random.random() < 0.5:
for _ in range(random.randint(2, 4)):
response = requests.put(
f"{server}/v1/measurement/value",
params={
"datetime": ts,
"key": "pv1_emr_kwh",
"value": str(random.uniform(0, 1000)),
},
)
if response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR:
errors.append(("burst", ts, response.text))
# 4. Assure database in memory data is saved to database
if i in (0, 4, 9, 14, 19):
response = requests.post(f"{server}/v1/admin/database/save")
assert response.status_code == HTTPStatus.OK
# small delay to simulate real system (~1 Hz)
time.sleep(1)
# ----------------------------------------------------------------------
# 3. Assertions
# ----------------------------------------------------------------------
assert not errors, f"500 errors occurred: {errors}"
# ----------------------------------------------------------------------
# 4. Verify resulting data
# ----------------------------------------------------------------------
result = requests.get(
f"{server}/v1/measurement/series",
params={"key": "pv1_emr_kwh"},
)
assert result.status_code == HTTPStatus.OK
data = result.json()["data"]
# sanity: should not explode in size
assert len(data) == len(set(sent_timestamps))
parsed = [to_datetime(ts) for ts in data.keys()]
assert all(parsed[i] <= parsed[i+1] for i in range(len(parsed)-1)), \
"Timestamps are not sorted"
unique_keys = set(data.keys())
assert len(unique_keys) == len(data), \
"Duplicate timestamps detected in API output"
def test_admin_cache(self, server_setup_for_class, is_system_test):
"""Test whether cache is reconstructed from cached files."""
server = server_setup_for_class["server"]

View File

@@ -9,17 +9,13 @@ filename = "example_report.pdf"
DIR_TESTDATA = Path(__file__).parent / "testdata"
reference_file = DIR_TESTDATA / "test_example_report.pdf"
output_file = DIR_TESTDATA / "test_example_report_new.pdf"
def test_generate_pdf_example(config_eos):
"""Test generation of example visualization report."""
output_dir = config_eos.general.data_output_path
assert output_dir is not None
output_file = output_dir / filename
assert not output_file.exists()
# Generate PDF
generate_example_report()
generate_example_report(filename=str(output_file))
# Check if the file exists
assert output_file.exists()

View File

@@ -144,8 +144,9 @@ def test_request_forecast(mock_get, provider, sample_brightsky_1_json):
}
@pytest.mark.asyncio
@patch("requests.get")
def test_update_data(mock_get, provider, sample_brightsky_1_json, cache_store):
async def test_update_data(mock_get, provider, sample_brightsky_1_json, cache_store):
"""Test fetching forecast from BrightSky."""
# Mock response object
mock_response = Mock()
@@ -158,7 +159,7 @@ def test_update_data(mock_get, provider, sample_brightsky_1_json, cache_store):
# Call the method
ems_eos = get_ems()
ems_eos.set_start_datetime(to_datetime("2024-10-26 00:00:00", in_timezone="Europe/Berlin"))
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
# Assert: Verify the result is as expected
mock_get.assert_called_once()
@@ -170,7 +171,8 @@ def test_update_data(mock_get, provider, sample_brightsky_1_json, cache_store):
# ------------------------------------------------
def test_brightsky_development_forecast_data(provider, config_eos, is_system_test):
@pytest.mark.asyncio
async def test_brightsky_development_forecast_data(provider, config_eos, is_system_test):
"""Fetch data from real BrightSky server."""
if not is_system_test:
return
@@ -186,7 +188,7 @@ def test_brightsky_development_forecast_data(provider, config_eos, is_system_tes
with FILE_TESTDATA_WEATHERBRIGHTSKY_1_JSON.open("w", encoding="utf-8", newline="\n") as f_out:
json.dump(brightsky_data, f_out, indent=4)
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
with FILE_TESTDATA_WEATHERBRIGHTSKY_2_JSON.open("w", encoding="utf-8", newline="\n") as f_out:
f_out.write(provider.model_dump_json(indent=4))

View File

@@ -142,8 +142,9 @@ def test_request_forecast(mock_get, provider, sample_clearout_1_html, config_eos
assert response.content == sample_clearout_1_html
@pytest.mark.asyncio
@patch("requests.get")
def test_update_data(mock_get, provider, sample_clearout_1_html, sample_clearout_1_data):
async def test_update_data(mock_get, provider, sample_clearout_1_html, sample_clearout_1_data):
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
@@ -157,7 +158,7 @@ def test_update_data(mock_get, provider, sample_clearout_1_html, sample_clearout
# Call the method
ems_eos = get_ems()
ems_eos.set_start_datetime(expected_start)
provider.update_data()
await provider.update_data()
# Check for correct prediction time window
assert provider.config.prediction.hours == 48
@@ -177,9 +178,10 @@ def test_update_data(mock_get, provider, sample_clearout_1_html, sample_clearout
# # Check additional weather attributes as necessary
@pytest.mark.asyncio
@pytest.mark.skip(reason="Test fixture to be improved")
@patch("requests.get")
def test_cache_forecast(mock_get, provider, sample_clearout_1_html, cache_store):
async def test_cache_forecast(mock_get, provider, sample_clearout_1_html, cache_store):
"""Test that ClearOutside forecast data is cached with TTL.
This can not be tested with mock_get. Mock objects are not pickable and therefor can not be
@@ -193,11 +195,11 @@ def test_cache_forecast(mock_get, provider, sample_clearout_1_html, cache_store)
cache_store.clear(clear_all=True)
provider.update_data()
await provider.update_data()
mock_get.assert_called_once()
forecast_data_first = provider.to_json()
provider.update_data()
await provider.update_data()
forecast_data_second = provider.to_json()
# Verify that cache returns the same object without calling the method again
assert forecast_data_first == forecast_data_second
@@ -210,9 +212,10 @@ def test_cache_forecast(mock_get, provider, sample_clearout_1_html, cache_store)
# ------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.skip(reason="For development only")
@patch("requests.get")
def test_development_forecast_data(mock_get, provider, sample_clearout_1_html):
async def test_development_forecast_data(mock_get, provider, sample_clearout_1_html):
# Mock response object
mock_response = Mock()
mock_response.status_code = 200
@@ -220,7 +223,7 @@ def test_development_forecast_data(mock_get, provider, sample_clearout_1_html):
mock_get.return_value = mock_response
# Fill the instance
provider.update_data(force_enable=True)
await provider.update_data(force_enable=True)
with FILE_TESTDATA_WEATHERCLEAROUTSIDE_1_DATA.open(
"w", encoding="utf-8", newline="\n"

View File

@@ -73,6 +73,7 @@ def test_invalid_provider(provider, config_eos, monkeypatch):
# ------------------------------------------------
@pytest.mark.asyncio
@pytest.mark.parametrize(
"start_datetime, from_file",
[
@@ -86,7 +87,7 @@ def test_invalid_provider(provider, config_eos, monkeypatch):
("2024-10-27 00:00:00", False), # DST change in Germany (25 hours/ day)
],
)
def test_import(provider, sample_import_1_json, start_datetime, from_file, config_eos):
async def test_import(provider, sample_import_1_json, start_datetime, from_file, config_eos):
"""Test fetching forecast from Import."""
key = "weather_temp_air"
ems_eos = get_ems()
@@ -97,10 +98,10 @@ def test_import(provider, sample_import_1_json, start_datetime, from_file, confi
else:
config_eos.weather.provider_settings.WeatherImport.import_file_path = None
assert config_eos.weather.provider_settings.WeatherImport.import_file_path is None
provider.delete_by_datetime(start_datetime=None, end_datetime=None)
await provider.delete_by_datetime(start_datetime=None, end_datetime=None)
# Call the method
provider.update_data()
await provider.update_data()
# Assert: Verify the result is as expected
assert provider.ems_start_datetime is not None
@@ -108,7 +109,7 @@ def test_import(provider, sample_import_1_json, start_datetime, from_file, confi
assert compare_datetimes(provider.ems_start_datetime, ems_eos.start_datetime).equal
expected_values = sample_import_1_json[key]
result_values = provider.key_to_array(
result_values = await provider.key_to_array(
key=key,
start_datetime=provider.ems_start_datetime,
end_datetime=provider.ems_start_datetime + to_duration(f"{len(expected_values)} hours"),

View File

@@ -135,8 +135,9 @@ def test_request_forecast(mock_get, provider, sample_openmeteo_1_json):
assert "diffuse_radiation" in openmeteo_data["hourly"] # DHI
@pytest.mark.asyncio
@patch("requests.get")
def test_update_data(mock_get, provider, sample_openmeteo_1_json, cache_store):
async def test_update_data(mock_get, provider, sample_openmeteo_1_json, cache_store):
"""Test fetching and processing forecast from Open-Meteo."""
# Mock response object
mock_response = Mock()
@@ -151,7 +152,7 @@ def test_update_data(mock_get, provider, sample_openmeteo_1_json, cache_store):
ems_eos = get_ems()
start_datetime = to_datetime("2026-03-02 09:00:00+01:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start_datetime)
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
# Assert: Verify the result is as expected
mock_get.assert_called_once()
@@ -160,9 +161,12 @@ def test_update_data(mock_get, provider, sample_openmeteo_1_json, cache_store):
# Verify that direct radiation values were properly mapped
# Get the first record and check for irradiance values
value_datetime = to_datetime("2026-03-04 09:00:00+01:00", in_timezone="Europe/Berlin")
assert provider.key_to_value("weather_ghi", target_datetime=start_datetime) == 21.8
assert provider.key_to_value("weather_dni", target_datetime=start_datetime) == 1.2
assert provider.key_to_value("weather_dhi", target_datetime=start_datetime) == 20.5
weather_ghi = await provider.key_to_value("weather_ghi", target_datetime=start_datetime)
weather_dni = await provider.key_to_value("weather_dni", target_datetime=start_datetime)
weather_dhi = await provider.key_to_value("weather_dhi", target_datetime=start_datetime)
assert weather_ghi == 21.8
assert weather_dni == 1.2
assert weather_dhi == 20.5
# ------------------------------------------------
@@ -279,7 +283,8 @@ def test_openmeteo_request_mode_selection(
# ------------------------------------------------
def test_openmeteo_development_forecast_data(provider, config_eos, is_system_test):
@pytest.mark.asyncio
async def test_openmeteo_development_forecast_data(provider, config_eos, is_system_test):
"""Fetch data from real Open-Meteo server for development purposes."""
if not is_system_test:
return
@@ -304,7 +309,7 @@ def test_openmeteo_development_forecast_data(provider, config_eos, is_system_tes
json.dump(openmeteo_data, f_out, indent=4)
# Update and process data
provider.update_data(force_enable=True, force_update=True)
await provider.update_data(force_enable=True, force_update=True)
# Save processed data
with FILE_TESTDATA_WEATHEROPENMETEO_2_JSON.open("w", encoding="utf-8", newline="\n") as f_out:
@@ -312,7 +317,7 @@ def test_openmeteo_development_forecast_data(provider, config_eos, is_system_tes
# Verify radiation values
if len(provider) > 0:
records = list(provider.data_records.values())
records = list(provider.records)
# Check fo radiation values available
has_ghi = any(hasattr(r, 'ghi') and r.ghi is not None for r in records)

View File

@@ -0,0 +1,25 @@
```{toctree}
:maxdepth: 1
:caption: Configuration Table
../_generated/configadapter.md
../_generated/configcache.md
../_generated/configdatabase.md
../_generated/configdevices.md
../_generated/configelecprice.md
../_generated/configems.md
../_generated/configfeedintariff.md
../_generated/configgeneral.md
../_generated/configload.md
../_generated/configlogging.md
../_generated/configmeasurement.md
../_generated/configoptimization.md
../_generated/configprediction.md
../_generated/configpvforecast.md
../_generated/configserver.md
../_generated/configutils.md
../_generated/configweather.md
../_generated/configexample.md
```
Auto generated from source code.

View File

@@ -0,0 +1,238 @@
## Adapter Configuration
<!-- pyml disable line-length -->
:::{table} adapter
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| homeassistant | `EOS_ADAPTER__HOMEASSISTANT` | `HomeAssistantAdapterCommonSettings` | `rw` | `required` | Home Assistant adapter settings. |
| nodered | `EOS_ADAPTER__NODERED` | `NodeREDAdapterCommonSettings` | `rw` | `required` | NodeRED adapter settings. |
| provider | `EOS_ADAPTER__PROVIDER` | `Optional[list[str]]` | `rw` | `None` | List of adapter provider id(s) of provider(s) to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available adapter provider ids. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"adapter": {
"provider": [
"HomeAssistant"
],
"homeassistant": {
"config_entity_ids": null,
"load_emr_entity_ids": null,
"grid_export_emr_entity_ids": null,
"grid_import_emr_entity_ids": null,
"pv_production_emr_entity_ids": null,
"device_measurement_entity_ids": null,
"device_instruction_entity_ids": null,
"solution_entity_ids": null
},
"nodered": {
"host": "127.0.0.1",
"port": 1880
}
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"adapter": {
"provider": [
"HomeAssistant"
],
"homeassistant": {
"config_entity_ids": null,
"load_emr_entity_ids": null,
"grid_export_emr_entity_ids": null,
"grid_import_emr_entity_ids": null,
"pv_production_emr_entity_ids": null,
"device_measurement_entity_ids": null,
"device_instruction_entity_ids": null,
"solution_entity_ids": null,
"homeassistant_entity_ids": [],
"eos_solution_entity_ids": [],
"eos_device_instruction_entity_ids": []
},
"nodered": {
"host": "127.0.0.1",
"port": 1880
},
"providers": [
"HomeAssistant",
"NodeRED"
]
}
}
```
<!-- pyml enable line-length -->
### Common settings for the NodeRED adapter
The Node-RED adapter sends to HTTP IN nodes.
This is the example flow:
[HTTP In \\<URL\\>] -> [Function (parse payload)] -> [Debug] -> [HTTP Response]
There are two URLs that are used:
- GET /eos/data_aquisition
The GET is issued before the optimization.
- POST /eos/control_dispatch
The POST is issued after the optimization.
<!-- pyml disable line-length -->
:::{table} adapter::nodered
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| host | `Optional[str]` | `rw` | `127.0.0.1` | Node-RED server IP address. Defaults to 127.0.0.1. |
| port | `Optional[int]` | `rw` | `1880` | Node-RED server IP port number. Defaults to 1880. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"adapter": {
"nodered": {
"host": "127.0.0.1",
"port": 1880
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for the home assistant adapter
<!-- pyml disable line-length -->
:::{table} adapter::homeassistant
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| config_entity_ids | `Optional[dict[str, str]]` | `rw` | `None` | Mapping of EOS config keys to Home Assistant entity IDs.
The config key has to be given by a /-separated path
e.g. devices/batteries/0/capacity_wh |
| device_instruction_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity IDs for device (resource) instructions to be updated by EOS.
The device ids (resource ids) have to be prepended by 'sensor.eos_' to build the entity_id.
E.g. The instruction for device id 'battery1' becomes the entity_id 'sensor.eos_battery1'. |
| device_measurement_entity_ids | `Optional[dict[str, str]]` | `rw` | `None` | Mapping of EOS measurement keys used by device (resource) simulations to Home Assistant entity IDs. |
| eos_device_instruction_entity_ids | `list[str]` | `ro` | `N/A` | Entity IDs for energy management instructions available at EOS. |
| eos_solution_entity_ids | `list[str]` | `ro` | `N/A` | Entity IDs for optimization solution available at EOS. |
| grid_export_emr_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity ID(s) of export to grid energy meter readings [kWh] |
| grid_import_emr_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity ID(s) of import from grid energy meter readings [kWh] |
| homeassistant_entity_ids | `list[str]` | `ro` | `N/A` | Entity IDs available at Home Assistant. |
| load_emr_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity ID(s) of load energy meter readings [kWh] |
| pv_production_emr_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity ID(s) of PV production energy meter readings [kWh] |
| solution_entity_ids | `Optional[list[str]]` | `rw` | `None` | Entity IDs for optimization solution keys to be updated by EOS.
The solution keys have to be prepended by 'sensor.eos_' to build the entity_id.
E.g. solution key 'battery1_idle_op_mode' becomes the entity_id 'sensor.eos_battery1_idle_op_mode'. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"adapter": {
"homeassistant": {
"config_entity_ids": {
"devices/batteries/0/capacity_wh": "sensor.battery1_capacity"
},
"load_emr_entity_ids": [
"sensor.load_energy_total_kwh"
],
"grid_export_emr_entity_ids": [
"sensor.grid_export_energy_total_kwh"
],
"grid_import_emr_entity_ids": [
"sensor.grid_import_energy_total_kwh"
],
"pv_production_emr_entity_ids": [
"sensor.pv_energy_total_kwh"
],
"device_measurement_entity_ids": {
"ev11_soc_factor": "sensor.ev11_soc_factor",
"battery1_soc_factor": "sensor.battery1_soc_factor"
},
"device_instruction_entity_ids": [
"sensor.eos_battery1"
],
"solution_entity_ids": [
"sensor.eos_battery1_idle_mode_mode"
]
}
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"adapter": {
"homeassistant": {
"config_entity_ids": {
"devices/batteries/0/capacity_wh": "sensor.battery1_capacity"
},
"load_emr_entity_ids": [
"sensor.load_energy_total_kwh"
],
"grid_export_emr_entity_ids": [
"sensor.grid_export_energy_total_kwh"
],
"grid_import_emr_entity_ids": [
"sensor.grid_import_energy_total_kwh"
],
"pv_production_emr_entity_ids": [
"sensor.pv_energy_total_kwh"
],
"device_measurement_entity_ids": {
"ev11_soc_factor": "sensor.ev11_soc_factor",
"battery1_soc_factor": "sensor.battery1_soc_factor"
},
"device_instruction_entity_ids": [
"sensor.eos_battery1"
],
"solution_entity_ids": [
"sensor.eos_battery1_idle_mode_mode"
],
"homeassistant_entity_ids": [],
"eos_solution_entity_ids": [],
"eos_device_instruction_entity_ids": []
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,28 @@
## Cache Configuration
<!-- pyml disable line-length -->
:::{table} cache
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| cleanup_interval | `EOS_CACHE__CLEANUP_INTERVAL` | `float` | `rw` | `300.0` | Intervall in seconds for EOS file cache cleanup. |
| subpath | `EOS_CACHE__SUBPATH` | `Optional[pathlib.Path]` | `rw` | `cache` | Sub-path for the EOS cache data directory. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"cache": {
"subpath": "cache",
"cleanup_interval": 300.0
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,73 @@
## Configuration model for database settings
Attributes:
provider: Optional provider identifier (e.g. "LMDB").
max_records_in_memory: Maximum records kept in memory before auto-save.
auto_save: Whether to auto-save when threshold exceeded.
batch_size: Batch size for batch operations.
<!-- pyml disable line-length -->
:::{table} database
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| autosave_interval_sec | `EOS_DATABASE__AUTOSAVE_INTERVAL_SEC` | `Optional[int]` | `rw` | `10` | Automatic saving interval [seconds].
Set to None to disable automatic saving. |
| batch_size | `EOS_DATABASE__BATCH_SIZE` | `int` | `rw` | `100` | Number of records to process in batch operations. |
| compaction_interval_sec | `EOS_DATABASE__COMPACTION_INTERVAL_SEC` | `Optional[int]` | `rw` | `604800` | Interval in between automatic tiered compaction runs [seconds].
Compaction downsamples old records to reduce storage while retaining coverage. Set to None to disable automatic compaction. |
| compression_level | `EOS_DATABASE__COMPRESSION_LEVEL` | `int` | `rw` | `9` | Compression level for database record data. |
| initial_load_window_h | `EOS_DATABASE__INITIAL_LOAD_WINDOW_H` | `Optional[int]` | `rw` | `None` | Specifies the default duration of the initial load window when loading records from the database, in hours. If set to None, the full available range is loaded. The window is centered around the current time by default, unless a different center time is specified. Different database namespaces may define their own default windows. |
| keep_duration_h | `EOS_DATABASE__KEEP_DURATION_H` | `Optional[int]` | `rw` | `None` | Default maximum duration records shall be kept in database [hours, none].
None indicates forever. Database namespaces may have diverging definitions. |
| provider | `EOS_DATABASE__PROVIDER` | `Optional[str]` | `rw` | `None` | Database provider id of provider to be used. |
| providers | | `List[str]` | `ro` | `N/A` | Return available database provider ids. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"database": {
"provider": "LMDB",
"compression_level": 0,
"initial_load_window_h": 48,
"keep_duration_h": 48,
"autosave_interval_sec": 5,
"compaction_interval_sec": 604800,
"batch_size": 100
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"database": {
"provider": "LMDB",
"compression_level": 0,
"initial_load_window_h": 48,
"keep_duration_h": 48,
"autosave_interval_sec": 5,
"compaction_interval_sec": 604800,
"batch_size": 100,
"providers": [
"LMDB",
"SQLite",
"NoDB"
]
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,549 @@
## Base configuration for devices simulation settings
<!-- pyml disable line-length -->
:::{table} devices
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| batteries | `EOS_DEVICES__BATTERIES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of battery devices |
| electric_vehicles | `EOS_DEVICES__ELECTRIC_VEHICLES` | `Optional[list[akkudoktoreos.devices.devices.BatteriesCommonSettings]]` | `rw` | `None` | List of electric vehicle devices |
| home_appliances | `EOS_DEVICES__HOME_APPLIANCES` | `Optional[list[akkudoktoreos.devices.devices.HomeApplianceCommonSettings]]` | `rw` | `None` | List of home appliances |
| inverters | `EOS_DEVICES__INVERTERS` | `Optional[list[akkudoktoreos.devices.devices.InverterCommonSettings]]` | `rw` | `None` | List of inverters |
| max_batteries | `EOS_DEVICES__MAX_BATTERIES` | `Optional[int]` | `rw` | `None` | Maximum number of batteries that can be set |
| max_electric_vehicles | `EOS_DEVICES__MAX_ELECTRIC_VEHICLES` | `Optional[int]` | `rw` | `None` | Maximum number of electric vehicles that can be set |
| max_home_appliances | `EOS_DEVICES__MAX_HOME_APPLIANCES` | `Optional[int]` | `rw` | `None` | Maximum number of home_appliances that can be set |
| max_inverters | `EOS_DEVICES__MAX_INVERTERS` | `Optional[int]` | `rw` | `None` | Maximum number of inverters that can be set |
| measurement_keys | | `Optional[list[str]]` | `ro` | `N/A` | Return the measurement keys for the resource/ device stati that are measurements. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": [
0.0,
0.1,
0.2,
0.3,
0.4,
0.5,
0.6,
0.7,
0.8,
0.9,
1.0
],
"min_soc_percentage": 0,
"max_soc_percentage": 100
}
],
"max_batteries": 1,
"electric_vehicles": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": [
0.0,
0.1,
0.2,
0.3,
0.4,
0.5,
0.6,
0.7,
0.8,
0.9,
1.0
],
"min_soc_percentage": 0,
"max_soc_percentage": 100
}
],
"max_electric_vehicles": 1,
"inverters": [],
"max_inverters": 1,
"home_appliances": [],
"max_home_appliances": 1
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": [
0.0,
0.1,
0.2,
0.3,
0.4,
0.5,
0.6,
0.7,
0.8,
0.9,
1.0
],
"min_soc_percentage": 0,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
],
"max_batteries": 1,
"electric_vehicles": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": [
0.0,
0.1,
0.2,
0.3,
0.4,
0.5,
0.6,
0.7,
0.8,
0.9,
1.0
],
"min_soc_percentage": 0,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
],
"max_electric_vehicles": 1,
"inverters": [],
"max_inverters": 1,
"home_appliances": [],
"max_home_appliances": 1,
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w",
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
}
```
<!-- pyml enable line-length -->
### Inverter devices base settings
<!-- pyml disable line-length -->
:::{table} devices::inverters::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| ac_to_dc_efficiency | `float` | `rw` | `1.0` | Efficiency of AC to DC conversion for grid-to-battery AC charging (0-1). Set to 0 to disable AC charging. Default 1.0 (no additional inverter loss). |
| battery_id | `Optional[str]` | `rw` | `None` | ID of battery controlled by this inverter. |
| dc_to_ac_efficiency | `float` | `rw` | `1.0` | Efficiency of DC to AC conversion for battery discharging to AC load/grid (0-1). Default 1.0 (no additional inverter loss). |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| max_ac_charge_power_w | `Optional[float]` | `rw` | `None` | Maximum AC charging power in watts. null means no additional limit. Set to 0 to disable AC charging. |
| max_power_w | `Optional[float]` | `rw` | `None` | Maximum power [W]. |
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the inverter stati that are measurements. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"inverters": [
{
"device_id": "battery1",
"max_power_w": 10000.0,
"battery_id": null,
"ac_to_dc_efficiency": 0.95,
"dc_to_ac_efficiency": 0.95,
"max_ac_charge_power_w": null
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"inverters": [
{
"device_id": "battery1",
"max_power_w": 10000.0,
"battery_id": null,
"ac_to_dc_efficiency": 0.95,
"dc_to_ac_efficiency": 0.95,
"max_ac_charge_power_w": null,
"measurement_keys": []
}
]
}
}
```
<!-- pyml enable line-length -->
### Model defining a daily or date time window with optional localization support
Represents a time interval starting at `start_time` and lasting for `duration`.
Can restrict applicability to a specific day of the week or a specific calendar date.
Supports day names in multiple languages via locale-aware parsing.
Timezone contract:
``start_time`` is always **naive** (no ``tzinfo``). It is interpreted as a
local wall-clock time in whatever timezone the caller's ``date_time`` or
``reference_date`` carries. When those arguments are timezone-aware the
window boundaries are evaluated in that timezone; when they are naive,
arithmetic is performed as-is (no timezone conversion occurs).
``date``, being a calendar ``Date`` object, is inherently timezone-free.
This design avoids the ambiguity that arises when a stored ``start_time``
carries its own timezone that differs from the caller's timezone, and keeps
the model serialisable without timezone state.
<!-- pyml disable line-length -->
:::{table} devices::home_appliances::list::time_windows::windows::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| date | `Optional[pydantic_extra_types.pendulum_dt.Date]` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
| day_of_week | `Union[int, str, NoneType]` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
| locale | `Optional[str]` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"time_windows": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
}
}
]
}
}
```
<!-- pyml enable line-length -->
### Model representing a sequence of time windows with collective operations
Manages multiple TimeWindow objects and provides methods to work with them
as a cohesive unit for scheduling and availability checking.
<!-- pyml disable line-length -->
:::{table} devices::home_appliances::list::time_windows
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| windows | `list[akkudoktoreos.config.configabc.TimeWindow]` | `rw` | `required` | List of TimeWindow objects that make up this sequence. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"time_windows": {
"windows": []
}
}
]
}
}
```
<!-- pyml enable line-length -->
### Home Appliance devices base settings
<!-- pyml disable line-length -->
:::{table} devices::home_appliances::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| consumption_wh | `int` | `rw` | `required` | Energy consumption [Wh]. |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| duration_h | `int` | `rw` | `required` | Usage duration in hours [0 ... 24]. |
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the home appliance stati that are measurements. |
| time_windows | `Optional[akkudoktoreos.config.configabc.TimeWindowSequence]` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"device_id": "battery1",
"consumption_wh": 2000,
"duration_h": 1,
"time_windows": {
"windows": [
{
"start_time": "10:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
}
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"home_appliances": [
{
"device_id": "battery1",
"consumption_wh": 2000,
"duration_h": 1,
"time_windows": {
"windows": [
{
"start_time": "10:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
},
"measurement_keys": []
}
]
}
}
```
<!-- pyml enable line-length -->
### Battery devices base settings
<!-- pyml disable line-length -->
:::{table} devices::batteries::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| capacity_wh | `int` | `rw` | `8000` | Capacity [Wh]. |
| charge_rates | `Optional[list[float]]` | `rw` | `[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]` | Charge rates as factor of maximum charging power [0.00 ... 1.00]. None triggers fallback to default charge-rates. |
| charging_efficiency | `float` | `rw` | `0.88` | Charging efficiency [0.01 ... 1.00]. |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [€/kWh]. |
| max_charge_power_w | `Optional[float]` | `rw` | `5000` | Maximum charging power [W]. |
| max_soc_percentage | `int` | `rw` | `100` | Maximum state of charge (SOC) as percentage of capacity [%]. |
| measurement_key_power_3_phase_sym_w | `str` | `ro` | `N/A` | Measurement key for the symmetric 3 phase power the battery is charged or discharged with [W]. |
| measurement_key_power_l1_w | `str` | `ro` | `N/A` | Measurement key for the L1 power the battery is charged or discharged with [W]. |
| measurement_key_power_l2_w | `str` | `ro` | `N/A` | Measurement key for the L2 power the battery is charged or discharged with [W]. |
| measurement_key_power_l3_w | `str` | `ro` | `N/A` | Measurement key for the L3 power the battery is charged or discharged with [W]. |
| measurement_key_soc_factor | `str` | `ro` | `N/A` | Measurement key for the battery state of charge (SoC) as factor of total capacity [0.0 ... 1.0]. |
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the battery stati that are measurements. |
| min_charge_power_w | `Optional[float]` | `rw` | `50` | Minimum charging power [W]. |
| min_soc_percentage | `int` | `rw` | `0` | Minimum state of charge (SOC) as percentage of capacity [%]. This is the target SoC for charging |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.12,
"max_charge_power_w": 5000.0,
"min_charge_power_w": 50.0,
"charge_rates": [
0.0,
0.25,
0.5,
0.75,
1.0
],
"min_soc_percentage": 10,
"max_soc_percentage": 100
}
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.12,
"max_charge_power_w": 5000.0,
"min_charge_power_w": 50.0,
"charge_rates": [
0.0,
0.25,
0.5,
0.75,
1.0
],
"min_soc_percentage": 10,
"max_soc_percentage": 100,
"measurement_key_soc_factor": "battery1-soc-factor",
"measurement_key_power_l1_w": "battery1-power-l1-w",
"measurement_key_power_l2_w": "battery1-power-l2-w",
"measurement_key_power_l3_w": "battery1-power-l3-w",
"measurement_key_power_3_phase_sym_w": "battery1-power-3-phase-sym-w",
"measurement_keys": [
"battery1-soc-factor",
"battery1-power-l1-w",
"battery1-power-l2-w",
"battery1-power-l3-w",
"battery1-power-3-phase-sym-w"
]
}
]
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,275 @@
## Electricity Price Prediction Configuration
<!-- pyml disable line-length -->
:::{table} elecprice
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| charges_kwh | `EOS_ELECPRICE__CHARGES_KWH` | `Optional[float]` | `rw` | `None` | Electricity price charges [€/kWh]. Will be added to variable market price. |
| elecpricefixed | `EOS_ELECPRICE__ELECPRICEFIXED` | `ElecPriceFixedCommonSettings` | `rw` | `required` | Fixed electricity price provider settings. |
| elecpriceimport | `EOS_ELECPRICE__ELECPRICEIMPORT` | `ElecPriceImportCommonSettings` | `rw` | `required` | Import provider settings. |
| energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. |
| provider | `EOS_ELECPRICE__PROVIDER` | `Optional[str]` | `rw` | `None` | Electricity price provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available electricity price provider ids. |
| vat_rate | `EOS_ELECPRICE__VAT_RATE` | `Optional[float]` | `rw` | `1.19` | VAT rate factor applied to electricity price when charges are used. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"elecpricefixed": {
"time_windows": {
"windows": []
}
},
"elecpriceimport": {
"import_file_path": null,
"import_json": null
},
"energycharts": {
"bidding_zone": "DE-LU"
}
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"elecpricefixed": {
"time_windows": {
"windows": []
}
},
"elecpriceimport": {
"import_file_path": null,
"import_json": null
},
"energycharts": {
"bidding_zone": "DE-LU"
},
"providers": [
"ElecPriceAkkudoktor",
"ElecPriceEnergyCharts",
"ElecPriceFixed",
"ElecPriceImport"
]
}
}
```
<!-- pyml enable line-length -->
### Common settings for Energy Charts electricity price provider
<!-- pyml disable line-length -->
:::{table} elecprice::energycharts
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| bidding_zone | `<enum 'EnergyChartsBiddingZones'>` | `rw` | `DE-LU` | Bidding Zone: 'AT', 'BE', 'CH', 'CZ', 'DE-LU', 'DE-AT-LU', 'DK1', 'DK2', 'FR', 'HU', 'IT-NORTH', 'NL', 'NO2', 'PL', 'SE4' or 'SI' |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"energycharts": {
"bidding_zone": "AT"
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for elecprice data import from file or JSON String
<!-- pyml disable line-length -->
:::{table} elecprice::elecpriceimport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import elecprice data from. |
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of electricity price forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"elecpriceimport": {
"import_file_path": null,
"import_json": "{\"elecprice_marketprice_wh\": [0.0003384, 0.0003318, 0.0003284]}"
}
}
}
```
<!-- pyml enable line-length -->
### Value applicable during a specific time window
This model extends `TimeWindow` by associating a value with the defined time interval.
<!-- pyml disable line-length -->
:::{table} elecprice::elecpricefixed::time_windows::windows::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| date | `Optional[pydantic_extra_types.pendulum_dt.Date]` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
| day_of_week | `Union[int, str, NoneType]` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
| locale | `Optional[str]` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
| value | `Optional[float]` | `rw` | `None` | Value applicable during this time window. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "2 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.288
}
]
}
}
}
}
```
<!-- pyml enable line-length -->
### Sequence of value time windows
This model specializes `TimeWindowSequence` to ensure that all
contained windows are instances of `ValueTimeWindow`.
It provides the full set of sequence operations (containment checks,
availability, start time calculations) for value windows.
<!-- pyml disable line-length -->
:::{table} elecprice::elecpricefixed::time_windows
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| windows | `list[akkudoktoreos.config.configabc.ValueTimeWindow]` | `rw` | `required` | Ordered list of value time windows. Each window defines a time interval and an associated value. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"windows": []
}
}
}
}
```
<!-- pyml enable line-length -->
### Common configuration settings for fixed electricity pricing
This model defines a fixed electricity price schedule using a sequence
of time windows. Each window specifies a time interval and the electricity
price applicable during that interval.
<!-- pyml disable line-length -->
:::{table} elecprice::elecpricefixed
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| time_windows | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"windows": [
{
"start_time": "00:00:00.000000",
"duration": "8 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.288
},
{
"start_time": "08:00:00.000000",
"duration": "16 hours",
"day_of_week": null,
"date": null,
"locale": null,
"value": 0.34
}
]
}
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,30 @@
## Energy Management Configuration
<!-- pyml disable line-length -->
:::{table} ems
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| interval | `EOS_EMS__INTERVAL` | `float` | `rw` | `300.0` | Intervall between EOS energy management runs [seconds]. |
| mode | `EOS_EMS__MODE` | `<enum 'EnergyManagementMode'>` | `rw` | `required` | Energy management mode [DISABLED | OPTIMIZATION | PREDICTION]. |
| startup_delay | `EOS_EMS__STARTUP_DELAY` | `float` | `rw` | `5` | Startup delay in seconds for EOS energy management runs. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"ems": {
"startup_delay": 5.0,
"interval": 300.0,
"mode": "OPTIMIZATION"
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,260 @@
## Full example Config
<!-- pyml disable line-length -->
```json
{
"adapter": {
"provider": [
"HomeAssistant"
],
"homeassistant": {
"config_entity_ids": null,
"load_emr_entity_ids": null,
"grid_export_emr_entity_ids": null,
"grid_import_emr_entity_ids": null,
"pv_production_emr_entity_ids": null,
"device_measurement_entity_ids": null,
"device_instruction_entity_ids": null,
"solution_entity_ids": null
},
"nodered": {
"host": "127.0.0.1",
"port": 1880
}
},
"cache": {
"subpath": "cache",
"cleanup_interval": 300.0
},
"database": {
"provider": "LMDB",
"compression_level": 0,
"initial_load_window_h": 48,
"keep_duration_h": 48,
"autosave_interval_sec": 5,
"compaction_interval_sec": 604800,
"batch_size": 100
},
"devices": {
"batteries": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": [
0.0,
0.1,
0.2,
0.3,
0.4,
0.5,
0.6,
0.7,
0.8,
0.9,
1.0
],
"min_soc_percentage": 0,
"max_soc_percentage": 100
}
],
"max_batteries": 1,
"electric_vehicles": [
{
"device_id": "battery1",
"capacity_wh": 8000,
"charging_efficiency": 0.88,
"discharging_efficiency": 0.88,
"levelized_cost_of_storage_kwh": 0.0,
"max_charge_power_w": 5000,
"min_charge_power_w": 50,
"charge_rates": [
0.0,
0.1,
0.2,
0.3,
0.4,
0.5,
0.6,
0.7,
0.8,
0.9,
1.0
],
"min_soc_percentage": 0,
"max_soc_percentage": 100
}
],
"max_electric_vehicles": 1,
"inverters": [],
"max_inverters": 1,
"home_appliances": [],
"max_home_appliances": 1
},
"elecprice": {
"provider": "ElecPriceAkkudoktor",
"charges_kwh": 0.21,
"vat_rate": 1.19,
"elecpricefixed": {
"time_windows": {
"windows": []
}
},
"elecpriceimport": {
"import_file_path": null,
"import_json": null
},
"energycharts": {
"bidding_zone": "DE-LU"
}
},
"ems": {
"startup_delay": 5.0,
"interval": 300.0,
"mode": "OPTIMIZATION"
},
"feedintariff": {
"provider": "FeedInTariffFixed",
"provider_settings": {
"FeedInTariffFixed": null,
"FeedInTariffImport": null
}
},
"general": {
"config_save_mode": "AUTOMATIC",
"config_save_interval_sec": 60,
"version": "0.0.0",
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
"data_output_subpath": "output",
"latitude": 52.52,
"longitude": 13.405
},
"load": {
"provider": "LoadAkkudoktor",
"loadakkudoktor": {
"loadakkudoktor_year_energy_kwh": null
},
"loadvrm": {
"load_vrm_token": "your-token",
"load_vrm_idsite": 12345
},
"loadimport": {
"import_file_path": null,
"import_json": null
}
},
"logging": {
"console_level": "TRACE",
"file_level": "TRACE"
},
"measurement": {
"historic_hours": 17520,
"load_emr_keys": [
"load0_emr"
],
"grid_export_emr_keys": [
"grid_export_emr"
],
"grid_import_emr_keys": [
"grid_import_emr"
],
"pv_production_emr_keys": [
"pv1_emr"
]
},
"optimization": {
"horizon_hours": 24,
"interval": 3600,
"algorithm": "GENETIC",
"genetic": {
"individuals": 400,
"generations": 400,
"seed": null,
"penalties": {
"ev_soc_miss": 10
}
}
},
"prediction": {
"hours": 48,
"historic_hours": 48
},
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"provider_settings": {
"PVForecastImport": null,
"PVForecastVrm": null
},
"planes": [
{
"surface_tilt": 10.0,
"surface_azimuth": 180.0,
"userhorizon": [
10.0,
20.0,
30.0
],
"peakpower": 5.0,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"loss": 14.0,
"trackingtype": 0,
"optimal_surface_tilt": false,
"optimalangles": false,
"albedo": null,
"module_model": null,
"inverter_model": null,
"inverter_paco": 6000,
"modules_per_string": 20,
"strings_per_inverter": 2
},
{
"surface_tilt": 20.0,
"surface_azimuth": 90.0,
"userhorizon": [
5.0,
15.0,
25.0
],
"peakpower": 3.5,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"loss": 14.0,
"trackingtype": 1,
"optimal_surface_tilt": false,
"optimalangles": false,
"albedo": null,
"module_model": null,
"inverter_model": null,
"inverter_paco": 4000,
"modules_per_string": 20,
"strings_per_inverter": 2
}
],
"max_planes": 1
},
"server": {
"host": "127.0.0.1",
"port": 8503,
"verbose": false,
"startup_eosdash": true,
"eosdash_host": "127.0.0.1",
"eosdash_port": 8504,
"eosdash_supervise_interval_sec": 10,
"run_as_user": null,
"reload": true
},
"utils": {},
"weather": {
"provider": "WeatherImport",
"provider_settings": {
"WeatherImport": null
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,149 @@
## Feed In Tariff Prediction Configuration
<!-- pyml disable line-length -->
:::{table} feedintariff
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| provider | `EOS_FEEDINTARIFF__PROVIDER` | `Optional[str]` | `rw` | `None` | Feed in tariff provider id of provider to be used. |
| provider_settings | `EOS_FEEDINTARIFF__PROVIDER_SETTINGS` | `FeedInTariffCommonProviderSettings` | `rw` | `required` | Provider settings |
| providers | | `list[str]` | `ro` | `N/A` | Available feed in tariff provider ids. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"feedintariff": {
"provider": "FeedInTariffFixed",
"provider_settings": {
"FeedInTariffFixed": null,
"FeedInTariffImport": null
}
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"feedintariff": {
"provider": "FeedInTariffFixed",
"provider_settings": {
"FeedInTariffFixed": null,
"FeedInTariffImport": null
},
"providers": [
"FeedInTariffFixed",
"FeedInTariffImport"
]
}
}
```
<!-- pyml enable line-length -->
### Common settings for feed in tariff data import from file or JSON string
<!-- pyml disable line-length -->
:::{table} feedintariff::provider_settings::FeedInTariffImport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import feed in tariff data from. |
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of feed in tariff forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"feedintariff": {
"provider_settings": {
"FeedInTariffImport": {
"import_file_path": null,
"import_json": "{\"fead_in_tariff_wh\": [0.000078, 0.000078, 0.000023]}"
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for elecprice fixed price
<!-- pyml disable line-length -->
:::{table} feedintariff::provider_settings::FeedInTariffFixed
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| feed_in_tariff_kwh | `Optional[float]` | `rw` | `None` | Electricity price feed in tariff [€/kWH]. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"feedintariff": {
"provider_settings": {
"FeedInTariffFixed": {
"feed_in_tariff_kwh": 0.078
}
}
}
}
```
<!-- pyml enable line-length -->
### Feed In Tariff Prediction Provider Configuration
<!-- pyml disable line-length -->
:::{table} feedintariff::provider_settings
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| FeedInTariffFixed | `Optional[akkudoktoreos.prediction.feedintarifffixed.FeedInTariffFixedCommonSettings]` | `rw` | `None` | FeedInTariffFixed settings |
| FeedInTariffImport | `Optional[akkudoktoreos.prediction.feedintariffimport.FeedInTariffImportCommonSettings]` | `rw` | `None` | FeedInTariffImport settings |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"feedintariff": {
"provider_settings": {
"FeedInTariffFixed": null,
"FeedInTariffImport": null
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,67 @@
## General settings
<!-- pyml disable line-length -->
:::{table} general
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| config_file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Path to EOS configuration file. |
| config_folder_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Path to EOS configuration directory. |
| config_save_interval_sec | `EOS_GENERAL__CONFIG_SAVE_INTERVAL_SEC` | `int` | `rw` | `60` | Automatic configuration file saving interval [seconds]. |
| config_save_mode | `EOS_GENERAL__CONFIG_SAVE_MODE` | `<enum 'ConfigSaveMode'>` | `rw` | `AUTOMATIC` | Configuration file save mode for configuration changes ['MANUAL', 'AUTOMATIC']. Defaults to 'AUTOMATIC'. |
| data_folder_path | `EOS_GENERAL__DATA_FOLDER_PATH` | `Path` | `rw` | `required` | Path to EOS data folder. |
| data_output_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Computed data_output_path based on data_folder_path. |
| data_output_subpath | `EOS_GENERAL__DATA_OUTPUT_SUBPATH` | `Optional[pathlib.Path]` | `rw` | `output` | Sub-path for the EOS output data folder. |
| home_assistant_addon | `EOS_GENERAL__HOME_ASSISTANT_ADDON` | `bool` | `rw` | `required` | EOS is running as home assistant add-on. |
| latitude | `EOS_GENERAL__LATITUDE` | `Optional[float]` | `rw` | `52.52` | Latitude in decimal degrees between -90 and 90. North is positive (ISO 19115) (°) |
| longitude | `EOS_GENERAL__LONGITUDE` | `Optional[float]` | `rw` | `13.405` | Longitude in decimal degrees within -180 to 180 (°) |
| timezone | | `Optional[str]` | `ro` | `N/A` | Computed timezone based on latitude and longitude. |
| version | `EOS_GENERAL__VERSION` | `Optional[str]` | `rw` | `None` | Configuration file version. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"general": {
"config_save_mode": "AUTOMATIC",
"config_save_interval_sec": 60,
"version": "0.0.0",
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
"data_output_subpath": "output",
"latitude": 52.52,
"longitude": 13.405
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"general": {
"config_save_mode": "AUTOMATIC",
"config_save_interval_sec": 60,
"version": "0.0.0",
"data_folder_path": "/home/user/.local/share/net.akkudoktoreos.net",
"data_output_subpath": "output",
"latitude": 52.52,
"longitude": 13.405,
"timezone": "Europe/Berlin",
"data_output_path": "/home/user/.local/share/net.akkudoktoreos.net/output",
"config_folder_path": "/home/user/.config/net.akkudoktoreos.net",
"config_file_path": "/home/user/.config/net.akkudoktoreos.net/EOS.config.json"
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,163 @@
## Load Prediction Configuration
<!-- pyml disable line-length -->
:::{table} load
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| loadakkudoktor | `EOS_LOAD__LOADAKKUDOKTOR` | `LoadAkkudoktorCommonSettings` | `rw` | `required` | LoadAkkudoktor provider settings. |
| loadimport | `EOS_LOAD__LOADIMPORT` | `LoadImportCommonSettings` | `rw` | `required` | LoadImport provider settings. |
| loadvrm | `EOS_LOAD__LOADVRM` | `LoadVrmCommonSettings` | `rw` | `required` | LoadVrm provider settings. |
| provider | `EOS_LOAD__PROVIDER` | `Optional[str]` | `rw` | `None` | Load provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available load provider ids. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"load": {
"provider": "LoadAkkudoktor",
"loadakkudoktor": {
"loadakkudoktor_year_energy_kwh": null
},
"loadvrm": {
"load_vrm_token": "your-token",
"load_vrm_idsite": 12345
},
"loadimport": {
"import_file_path": null,
"import_json": null
}
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"load": {
"provider": "LoadAkkudoktor",
"loadakkudoktor": {
"loadakkudoktor_year_energy_kwh": null
},
"loadvrm": {
"load_vrm_token": "your-token",
"load_vrm_idsite": 12345
},
"loadimport": {
"import_file_path": null,
"import_json": null
},
"providers": [
"LoadAkkudoktor",
"LoadAkkudoktorAdjusted",
"LoadVrm",
"LoadImport"
]
}
}
```
<!-- pyml enable line-length -->
### Common settings for load forecast VRM API
<!-- pyml disable line-length -->
:::{table} load::loadvrm
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| load_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID |
| load_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"load": {
"loadvrm": {
"load_vrm_token": "your-token",
"load_vrm_idsite": 12345
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for load data import from file or JSON string
<!-- pyml disable line-length -->
:::{table} load::loadimport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import load data from. |
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of load forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"load": {
"loadimport": {
"import_file_path": null,
"import_json": "{\"loadforecast_power_w\": [676.71, 876.19, 527.13]}"
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for load data import from file
<!-- pyml disable line-length -->
:::{table} load::loadakkudoktor
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| loadakkudoktor_year_energy_kwh | `Optional[float]` | `rw` | `None` | Yearly energy consumption (kWh). |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"load": {
"loadakkudoktor": {
"loadakkudoktor_year_energy_kwh": 40421.0
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,45 @@
## Logging Configuration
<!-- pyml disable line-length -->
:::{table} logging
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| console_level | `EOS_LOGGING__CONSOLE_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level when logging to console. |
| file_level | `EOS_LOGGING__FILE_LEVEL` | `Optional[str]` | `rw` | `None` | Logging level when logging to file. |
| file_path | | `Optional[pathlib.Path]` | `ro` | `N/A` | Computed log file path based on data output path. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"logging": {
"console_level": "TRACE",
"file_level": "TRACE"
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"logging": {
"console_level": "TRACE",
"file_level": "TRACE",
"file_path": "/home/user/.local/share/net.akkudoktor.eos/output/eos.log"
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,75 @@
## Measurement Configuration
<!-- pyml disable line-length -->
:::{table} measurement
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| grid_export_emr_keys | `EOS_MEASUREMENT__GRID_EXPORT_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy export to grid [kWh]. |
| grid_import_emr_keys | `EOS_MEASUREMENT__GRID_IMPORT_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of energy import from grid [kWh]. |
| historic_hours | `EOS_MEASUREMENT__HISTORIC_HOURS` | `Optional[int]` | `rw` | `17520` | Number of hours into the past for measurement data |
| keys | | `list[str]` | `ro` | `N/A` | The keys of the measurements that can be stored. |
| load_emr_keys | `EOS_MEASUREMENT__LOAD_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are energy meter readings of a load [kWh]. |
| pv_production_emr_keys | `EOS_MEASUREMENT__PV_PRODUCTION_EMR_KEYS` | `Optional[list[str]]` | `rw` | `None` | The keys of the measurements that are PV production energy meter readings [kWh]. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"measurement": {
"historic_hours": 17520,
"load_emr_keys": [
"load0_emr"
],
"grid_export_emr_keys": [
"grid_export_emr"
],
"grid_import_emr_keys": [
"grid_import_emr"
],
"pv_production_emr_keys": [
"pv1_emr"
]
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"measurement": {
"historic_hours": 17520,
"load_emr_keys": [
"load0_emr"
],
"grid_export_emr_keys": [
"grid_export_emr"
],
"grid_import_emr_keys": [
"grid_import_emr"
],
"pv_production_emr_keys": [
"pv1_emr"
],
"keys": [
"grid_export_emr",
"grid_import_emr",
"load0_emr",
"pv1_emr"
]
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,104 @@
## General Optimization Configuration
<!-- pyml disable line-length -->
:::{table} optimization
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| algorithm | `EOS_OPTIMIZATION__ALGORITHM` | `str` | `rw` | `GENETIC` | The optimization algorithm. Defaults to GENETIC |
| genetic | `EOS_OPTIMIZATION__GENETIC` | `GeneticCommonSettings` | `rw` | `required` | Genetic optimization algorithm configuration. |
| horizon | | `int` | `ro` | `N/A` | Number of optimization steps. |
| horizon_hours | `EOS_OPTIMIZATION__HORIZON_HOURS` | `int` | `rw` | `24` | The general time window within which the energy optimization goal shall be achieved [h]. Defaults to 24 hours. |
| interval | `EOS_OPTIMIZATION__INTERVAL` | `int` | `rw` | `3600` | The optimization interval [sec]. Defaults to 3600 seconds (1 hour) |
| keys | | `list[str]` | `ro` | `N/A` | The keys of the solution. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"optimization": {
"horizon_hours": 24,
"interval": 3600,
"algorithm": "GENETIC",
"genetic": {
"individuals": 400,
"generations": 400,
"seed": null,
"penalties": {
"ev_soc_miss": 10
}
}
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"optimization": {
"horizon_hours": 24,
"interval": 3600,
"algorithm": "GENETIC",
"genetic": {
"individuals": 400,
"generations": 400,
"seed": null,
"penalties": {
"ev_soc_miss": 10
}
},
"keys": [],
"horizon": 24
}
}
```
<!-- pyml enable line-length -->
### General Genetic Optimization Algorithm Configuration
<!-- pyml disable line-length -->
:::{table} optimization::genetic
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| generations | `Optional[int]` | `rw` | `400` | Number of generations to evolve [>= 10]. Defaults to 400. |
| individuals | `Optional[int]` | `rw` | `300` | Number of individuals (solutions) in the population [>= 10]. Defaults to 300. |
| penalties | `dict[str, Union[float, int, str]]` | `rw` | `required` | Penalty parameters used in fitness evaluation. |
| seed | `Optional[int]` | `rw` | `None` | Random seed for reproducibility. None = random. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"optimization": {
"genetic": {
"individuals": 300,
"generations": 400,
"seed": null,
"penalties": {
"ev_soc_miss": 10
}
}
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,28 @@
## General Prediction Configuration
<!-- pyml disable line-length -->
:::{table} prediction
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| historic_hours | `EOS_PREDICTION__HISTORIC_HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the past for historical predictions data |
| hours | `EOS_PREDICTION__HOURS` | `Optional[int]` | `rw` | `48` | Number of hours into the future for predictions |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"prediction": {
"hours": 48,
"historic_hours": 48
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,346 @@
## PV Forecast Configuration
<!-- pyml disable line-length -->
:::{table} pvforecast
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| max_planes | `EOS_PVFORECAST__MAX_PLANES` | `Optional[int]` | `rw` | `0` | Maximum number of planes that can be set |
| planes | `EOS_PVFORECAST__PLANES` | `Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]` | `rw` | `None` | Plane configuration. |
| planes_azimuth | | `List[float]` | `ro` | `N/A` | Compute a list of the azimuths per active planes. |
| planes_inverter_paco | | `Any` | `ro` | `N/A` | Compute a list of the maximum power rating of the inverter per active planes. |
| planes_peakpower | | `List[float]` | `ro` | `N/A` | Compute a list of the peak power per active planes. |
| planes_tilt | | `List[float]` | `ro` | `N/A` | Compute a list of the tilts per active planes. |
| planes_userhorizon | | `Any` | `ro` | `N/A` | Compute a list of the user horizon per active planes. |
| provider | `EOS_PVFORECAST__PROVIDER` | `Optional[str]` | `rw` | `None` | PVForecast provider id of provider to be used. |
| provider_settings | `EOS_PVFORECAST__PROVIDER_SETTINGS` | `PVForecastCommonProviderSettings` | `rw` | `required` | Provider settings |
| providers | | `list[str]` | `ro` | `N/A` | Available PVForecast provider ids. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"provider_settings": {
"PVForecastImport": null,
"PVForecastVrm": null
},
"planes": [
{
"surface_tilt": 10.0,
"surface_azimuth": 180.0,
"userhorizon": [
10.0,
20.0,
30.0
],
"peakpower": 5.0,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"loss": 14.0,
"trackingtype": 0,
"optimal_surface_tilt": false,
"optimalangles": false,
"albedo": null,
"module_model": null,
"inverter_model": null,
"inverter_paco": 6000,
"modules_per_string": 20,
"strings_per_inverter": 2
},
{
"surface_tilt": 20.0,
"surface_azimuth": 90.0,
"userhorizon": [
5.0,
15.0,
25.0
],
"peakpower": 3.5,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"loss": 14.0,
"trackingtype": 1,
"optimal_surface_tilt": false,
"optimalangles": false,
"albedo": null,
"module_model": null,
"inverter_model": null,
"inverter_paco": 4000,
"modules_per_string": 20,
"strings_per_inverter": 2
}
],
"max_planes": 1
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"provider_settings": {
"PVForecastImport": null,
"PVForecastVrm": null
},
"planes": [
{
"surface_tilt": 10.0,
"surface_azimuth": 180.0,
"userhorizon": [
10.0,
20.0,
30.0
],
"peakpower": 5.0,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"loss": 14.0,
"trackingtype": 0,
"optimal_surface_tilt": false,
"optimalangles": false,
"albedo": null,
"module_model": null,
"inverter_model": null,
"inverter_paco": 6000,
"modules_per_string": 20,
"strings_per_inverter": 2
},
{
"surface_tilt": 20.0,
"surface_azimuth": 90.0,
"userhorizon": [
5.0,
15.0,
25.0
],
"peakpower": 3.5,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"loss": 14.0,
"trackingtype": 1,
"optimal_surface_tilt": false,
"optimalangles": false,
"albedo": null,
"module_model": null,
"inverter_model": null,
"inverter_paco": 4000,
"modules_per_string": 20,
"strings_per_inverter": 2
}
],
"max_planes": 1,
"providers": [
"PVForecastAkkudoktor",
"PVForecastVrm",
"PVForecastImport"
],
"planes_peakpower": [
5.0,
3.5
],
"planes_azimuth": [
180.0,
90.0
],
"planes_tilt": [
10.0,
20.0
],
"planes_userhorizon": [
[
10.0,
20.0,
30.0
],
[
5.0,
15.0,
25.0
]
],
"planes_inverter_paco": [
6000.0,
4000.0
]
}
}
```
<!-- pyml enable line-length -->
### Common settings for PV forecast VRM API
<!-- pyml disable line-length -->
:::{table} pvforecast::provider_settings::PVForecastVrm
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| pvforecast_vrm_idsite | `int` | `rw` | `12345` | VRM-Installation-ID |
| pvforecast_vrm_token | `str` | `rw` | `your-token` | Token for Connecting VRM API |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider_settings": {
"PVForecastVrm": {
"pvforecast_vrm_token": "your-token",
"pvforecast_vrm_idsite": 12345
}
}
}
}
```
<!-- pyml enable line-length -->
### Common settings for pvforecast data import from file or JSON string
<!-- pyml disable line-length -->
:::{table} pvforecast::provider_settings::PVForecastImport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import PV forecast data from. |
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of PV forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider_settings": {
"PVForecastImport": {
"import_file_path": null,
"import_json": "{\"pvforecast_ac_power\": [0, 8.05, 352.91]}"
}
}
}
}
```
<!-- pyml enable line-length -->
### PV Forecast Provider Configuration
<!-- pyml disable line-length -->
:::{table} pvforecast::provider_settings
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| PVForecastImport | `Optional[akkudoktoreos.prediction.pvforecastimport.PVForecastImportCommonSettings]` | `rw` | `None` | PVForecastImport settings |
| PVForecastVrm | `Optional[akkudoktoreos.prediction.pvforecastvrm.PVForecastVrmCommonSettings]` | `rw` | `None` | PVForecastVrm settings |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"provider_settings": {
"PVForecastImport": null,
"PVForecastVrm": null
}
}
}
```
<!-- pyml enable line-length -->
### PV Forecast Plane Configuration
<!-- pyml disable line-length -->
:::{table} pvforecast::planes::list
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| albedo | `Optional[float]` | `rw` | `None` | Proportion of the light hitting the ground that it reflects back. |
| inverter_model | `Optional[str]` | `rw` | `None` | Model of the inverter of this plane. |
| inverter_paco | `Optional[int]` | `rw` | `None` | AC power rating of the inverter [W]. |
| loss | `Optional[float]` | `rw` | `14.0` | Sum of PV system losses in percent |
| module_model | `Optional[str]` | `rw` | `None` | Model of the PV modules of this plane. |
| modules_per_string | `Optional[int]` | `rw` | `None` | Number of the PV modules of the strings of this plane. |
| mountingplace | `Optional[str]` | `rw` | `free` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
| optimal_surface_tilt | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt angle. Ignored for two-axis tracking. |
| optimalangles | `Optional[bool]` | `rw` | `False` | Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. |
| peakpower | `Optional[float]` | `rw` | `None` | Nominal power of PV system in kW. |
| pvtechchoice | `Optional[str]` | `rw` | `crystSi` | PV technology. One of 'crystSi', 'CIS', 'CdTe', 'Unknown'. |
| strings_per_inverter | `Optional[int]` | `rw` | `None` | Number of the strings of the inverter of this plane. |
| surface_azimuth | `Optional[float]` | `rw` | `180.0` | Orientation (azimuth angle) of the (fixed) plane. Clockwise from north (north=0, east=90, south=180, west=270). |
| surface_tilt | `Optional[float]` | `rw` | `30.0` | Tilt angle from horizontal plane. Ignored for two-axis tracking. |
| trackingtype | `Optional[int]` | `rw` | `None` | Type of suntracking. 0=fixed, 1=single horizontal axis aligned north-south, 2=two-axis tracking, 3=vertical axis tracking, 4=single horizontal axis aligned east-west, 5=single inclined axis aligned north-south. |
| userhorizon | `Optional[List[float]]` | `rw` | `None` | Elevation of horizon in degrees, at equally spaced azimuth clockwise from north. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"planes": [
{
"surface_tilt": 10.0,
"surface_azimuth": 180.0,
"userhorizon": [
10.0,
20.0,
30.0
],
"peakpower": 5.0,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"loss": 14.0,
"trackingtype": 0,
"optimal_surface_tilt": false,
"optimalangles": false,
"albedo": null,
"module_model": null,
"inverter_model": null,
"inverter_paco": 6000,
"modules_per_string": 20,
"strings_per_inverter": 2
}
]
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,42 @@
## Server Configuration
<!-- pyml disable line-length -->
:::{table} server
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| eosdash_host | `EOS_SERVER__EOSDASH_HOST` | `Optional[str]` | `rw` | `None` | EOSdash server IP address. Defaults to EOS server IP address. |
| eosdash_port | `EOS_SERVER__EOSDASH_PORT` | `Optional[int]` | `rw` | `None` | EOSdash server IP port number. Defaults to EOS server IP port number + 1. |
| eosdash_supervise_interval_sec | `EOS_SERVER__EOSDASH_SUPERVISE_INTERVAL_SEC` | `int` | `rw` | `10` | Supervision interval for EOS server to supervise EOSdash [seconds]. |
| host | `EOS_SERVER__HOST` | `Optional[str]` | `rw` | `127.0.0.1` | EOS server IP address. Defaults to 127.0.0.1. |
| port | `EOS_SERVER__PORT` | `Optional[int]` | `rw` | `8503` | EOS server IP port number. Defaults to 8503. |
| reload | `EOS_SERVER__RELOAD` | `Optional[bool]` | `rw` | `False` | Enable server auto-reload for debugging or development. Default is False. Monitors the package directory for changes and reloads the server. |
| run_as_user | `EOS_SERVER__RUN_AS_USER` | `Optional[str]` | `rw` | `None` | The name of the target user to switch to. If ``None`` (default), the current effective user is used and no privilege change is attempted. |
| startup_eosdash | `EOS_SERVER__STARTUP_EOSDASH` | `Optional[bool]` | `rw` | `True` | EOS server to start EOSdash server. Defaults to True. |
| verbose | `EOS_SERVER__VERBOSE` | `Optional[bool]` | `rw` | `False` | Enable debug output |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"server": {
"host": "127.0.0.1",
"port": 8503,
"verbose": false,
"startup_eosdash": true,
"eosdash_host": "127.0.0.1",
"eosdash_port": 8504,
"eosdash_supervise_interval_sec": 10,
"run_as_user": null,
"reload": true
}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,23 @@
## Utils Configuration
<!-- pyml disable line-length -->
:::{table} utils
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"utils": {}
}
```
<!-- pyml enable line-length -->

View File

@@ -0,0 +1,116 @@
## Weather Forecast Configuration
<!-- pyml disable line-length -->
:::{table} weather
:widths: 10 20 10 5 5 30
:align: left
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| provider | `EOS_WEATHER__PROVIDER` | `Optional[str]` | `rw` | `None` | Weather provider id of provider to be used. |
| provider_settings | `EOS_WEATHER__PROVIDER_SETTINGS` | `WeatherCommonProviderSettings` | `rw` | `required` | Provider settings |
| providers | | `list[str]` | `ro` | `N/A` | Available weather provider ids. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"weather": {
"provider": "WeatherImport",
"provider_settings": {
"WeatherImport": null
}
}
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"weather": {
"provider": "WeatherImport",
"provider_settings": {
"WeatherImport": null
},
"providers": [
"BrightSky",
"ClearOutside",
"OpenMeteo",
"WeatherImport"
]
}
}
```
<!-- pyml enable line-length -->
### Common settings for weather data import from file or JSON string
<!-- pyml disable line-length -->
:::{table} weather::provider_settings::WeatherImport
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| import_file_path | `Union[str, pathlib.Path, NoneType]` | `rw` | `None` | Path to the file to import weather data from. |
| import_json | `Optional[str]` | `rw` | `None` | JSON string, dictionary of weather forecast value lists. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"weather": {
"provider_settings": {
"WeatherImport": {
"import_file_path": null,
"import_json": "{\"weather_temp_air\": [18.3, 17.8, 16.9]}"
}
}
}
}
```
<!-- pyml enable line-length -->
### Weather Forecast Provider Configuration
<!-- pyml disable line-length -->
:::{table} weather::provider_settings
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| WeatherImport | `Optional[akkudoktoreos.prediction.weatherimport.WeatherImportCommonSettings]` | `rw` | `None` | WeatherImport settings |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"weather": {
"provider_settings": {
"WeatherImport": null
}
}
}
```
<!-- pyml enable line-length -->

Binary file not shown.