feat: add fixed electricity prediction with time window support (#930)
Some checks are pending
Bump Version / Bump Version Workflow (push) Waiting to run
docker-build / platform-excludes (push) Waiting to run
docker-build / build (push) Blocked by required conditions
docker-build / merge (push) Blocked by required conditions
pre-commit / pre-commit (push) Waiting to run
Run Pytest on Pull Request / test (push) Waiting to run

Add a fixed electricity prediction that supports prices per time window.
The time windows may flexible be defined by day or date.

The prediction documentation is updated to also cover the ElecPriceFixed
provider.

The feature includes several changes that are not directly related to the
electricity price prediction implementation but are necessary to keep
EOS running properly and to test and document the changes.

* feat: add value time windows

    Add time windows with an associated float value.

* feat: harden eos measurements endpoints error detection and reporting

    Cover more errors that may be raised during endpoint access. Report the
    errors including trace information to ease debugging.

* feat: extend server configuration to cover all arguments

    Make the argument controlled options also available in server configuration.

* fix: eos config configuration by cli arguments

    Move the command line argument handling to config eos so that it is
    excuted whenever eos config is rebuild or reset.

* chore: extend measurement endpoint system test

* chore: refactor time windows

    Move time windows to configabc as they are only used in configurations.
    Also move all tests to test_configabc.

* chore: provide config update errors in eosdash with summarized error text

    If there is an update error provide the error text as a summary. On click
    provide the full error text.

* chore: force eosdash ip address and port in makefile dev run

    Ensure eosdash ip address and port are correctly set for development runs.

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2026-03-11 17:18:45 +01:00
committed by GitHub
parent 850d6b7c74
commit cf477d91a3
35 changed files with 3778 additions and 1491 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -23,8 +23,6 @@ from akkudoktoreos.utils.datetimeutil import (
DateTime,
Duration,
Time,
TimeWindow,
TimeWindowSequence,
_parse_time_string,
compare_datetimes,
hours_in_day,
@@ -839,459 +837,6 @@ class TestPendulumTypes:
assert model.run_duration.total_minutes() == 180
# -----------------------------
# TimeWindow
# -----------------------------
class TestTimeWindow:
"""Tests for the TimeWindow model."""
def test_datetime_within_and_outside_window(self):
"""Test datetime containment logic inside and outside the time window."""
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=3))
assert window.contains(DateTime(2025, 7, 12, 7, 30)) is True # Inside
assert window.contains(DateTime(2025, 7, 12, 9, 30)) is False # Outside
def test_contains_with_duration(self):
"""Test datetime with duration that does and doesn't fit in the window."""
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=3))
assert window.contains(DateTime(2025, 7, 12, 6, 30), duration=Duration(minutes=60)) is True
assert window.contains(DateTime(2025, 7, 12, 6, 30), duration=Duration(hours=3)) is False
def test_day_of_week_filter(self):
"""Test time window restricted by day of week."""
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), day_of_week=5) # Saturday
assert window.contains(DateTime(2025, 7, 12, 6, 30)) is True # Saturday
assert window.contains(DateTime(2025, 7, 11, 6, 30)) is False # Friday
def test_day_of_week_as_english_name(self):
"""Test time window with English weekday name."""
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), day_of_week="monday")
assert window.contains(DateTime(2025, 7, 7, 6, 30)) is True # Monday
assert window.contains(DateTime(2025, 7, 5, 6, 30)) is False # Saturday
def test_specific_date_filter(self):
"""Test time window restricted by exact date."""
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), date=Date(2025, 7, 12))
assert window.contains(DateTime(2025, 7, 12, 6, 30)) is True
assert window.contains(DateTime(2025, 7, 13, 6, 30)) is False
def test_invalid_field_types_raise_validation(self):
"""Test invalid types raise a Pydantic validation error."""
with pytest.raises(ValidationError):
TimeWindow(start_time="not_a_time", duration="3h")
@pytest.mark.parametrize("locale, weekday_name, expected_dow", [
("de", "Montag", 0),
("de", "Samstag", 5),
("es", "lunes", 0),
("es", "sábado", 5),
("fr", "lundi", 0),
("fr", "samedi", 5),
])
def test_localized_day_names(self, locale, weekday_name, expected_dow):
"""Test that localized weekday names are resolved to correct weekday index."""
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), day_of_week=weekday_name, locale=locale)
assert window.day_of_week == expected_dow
# ------------------
# TimeWindowSequence
# ------------------
class TestTimeWindowSequence:
"""Test suite for TimeWindowSequence model."""
@pytest.fixture
def sample_time_window_1(self):
"""Morning window: 9:00 AM - 12:00 PM."""
return TimeWindow(
start_time=Time(9, 0, 0),
duration=Duration(hours=3)
)
@pytest.fixture
def sample_time_window_2(self):
"""Afternoon window: 2:00 PM - 5:00 PM."""
return TimeWindow(
start_time=Time(14, 0, 0),
duration=Duration(hours=3)
)
@pytest.fixture
def monday_window(self):
"""Monday only window: 10:00 AM - 11:00 AM."""
return TimeWindow(
start_time=Time(10, 0, 0),
duration=Duration(hours=1),
day_of_week=0 # Monday
)
@pytest.fixture
def specific_date_window(self):
"""Specific date window: 1:00 PM - 3:00 PM on 2025-01-15."""
return TimeWindow(
start_time=Time(13, 0, 0),
duration=Duration(hours=2),
date=Date(2025, 1, 15)
)
@pytest.fixture
def sample_sequence(self, sample_time_window_1, sample_time_window_2):
"""Sequence with morning and afternoon windows."""
return TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2])
@pytest.fixture
def sample_sequence_json(self, sample_time_window_1, sample_time_window_2):
"""Sequence with morning and afternoon windows."""
seq_json = TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2]).model_dump()
return seq_json
@pytest.fixture
def sample_sequence_json_str(self, sample_time_window_1, sample_time_window_2):
"""Sequence with morning and afternoon windows."""
seq_json_str = TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2]).model_dumps(indent=2)
return seq_json_str
@pytest.fixture
def reference_date(self):
"""Reference date for testing: 2025-01-15 (Wednesday)."""
return pendulum.parse("2025-01-15T08:00:00")
def test_init_with_none_windows(self):
"""Test initialization with None windows creates empty list."""
sequence = TimeWindowSequence()
assert sequence.windows == []
assert len(sequence) == 0
def test_init_with_explicit_none(self):
"""Test initialization with explicit None windows."""
sequence = TimeWindowSequence(windows=None)
assert sequence.windows == []
assert len(sequence) == 0
def test_init_with_empty_list(self):
"""Test initialization with empty list."""
sequence = TimeWindowSequence(windows=[])
assert sequence.windows == []
assert len(sequence) == 0
def test_init_with_windows(self, sample_time_window_1, sample_time_window_2):
"""Test initialization with windows."""
sequence = TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2])
assert len(sequence) == 2
assert sequence.windows is not None # make mypy happy
assert sequence.windows[0] == sample_time_window_1
assert sequence.windows[1] == sample_time_window_2
def test_iterator_protocol(self, sample_sequence):
"""Test that sequence supports iteration."""
windows = list(sample_sequence)
assert len(windows) == 2
assert all(isinstance(window, TimeWindow) for window in windows)
def test_indexing(self, sample_sequence, sample_time_window_1):
"""Test indexing into sequence."""
assert sample_sequence[0] == sample_time_window_1
def test_length(self, sample_sequence):
"""Test len() support."""
assert len(sample_sequence) == 2
def test_contains_empty_sequence(self, reference_date):
"""Test contains() with empty sequence returns False."""
sequence = TimeWindowSequence()
assert not sequence.contains(reference_date)
assert not sequence.contains(reference_date, Duration(hours=1))
def test_contains_datetime_in_window(self, sample_sequence, reference_date):
"""Test contains() finds datetime in one of the windows."""
# 10:00 AM should be in the morning window (9:00 AM - 12:00 PM)
test_time = reference_date.replace(hour=10, minute=0)
assert sample_sequence.contains(test_time)
def test_contains_datetime_not_in_any_window(self, sample_sequence, reference_date):
"""Test contains() returns False when datetime is not in any window."""
# 1:00 PM should not be in any window (gap between morning and afternoon)
test_time = reference_date.replace(hour=13, minute=0)
assert not sample_sequence.contains(test_time)
def test_contains_with_duration_fits(self, sample_sequence, reference_date):
"""Test contains() with duration that fits in a window."""
# 10:00 AM with 1 hour duration should fit in morning window
test_time = reference_date.replace(hour=10, minute=0)
assert sample_sequence.contains(test_time, Duration(hours=1))
def test_contains_with_duration_too_long(self, sample_sequence, reference_date):
"""Test contains() with duration that doesn't fit in any window."""
# 11:00 AM with 2 hours duration won't fit in remaining morning window time
test_time = reference_date.replace(hour=11, minute=0)
assert not sample_sequence.contains(test_time, Duration(hours=2))
def test_earliest_start_time_empty_sequence(self, reference_date):
"""Test earliest_start_time() with empty sequence returns None."""
sequence = TimeWindowSequence()
assert sequence.earliest_start_time(Duration(hours=1), reference_date) is None
def test_earliest_start_time_finds_earliest(self, sample_sequence, reference_date):
"""Test earliest_start_time() finds the earliest time across all windows."""
# Should return 9:00 AM (start of morning window)
earliest = sample_sequence.earliest_start_time(Duration(hours=1), reference_date)
expected = reference_date.replace(hour=9, minute=0, second=0, microsecond=0)
assert earliest == expected
def test_earliest_start_time_duration_too_long(self, sample_sequence, reference_date):
"""Test earliest_start_time() with duration longer than any window."""
# 4 hours won't fit in any 3-hour window
assert sample_sequence.earliest_start_time(Duration(hours=4), reference_date) is None
def test_latest_start_time_empty_sequence(self, reference_date):
"""Test latest_start_time() with empty sequence returns None."""
sequence = TimeWindowSequence()
assert sequence.latest_start_time(Duration(hours=1), reference_date) is None
def test_latest_start_time_finds_latest(self, sample_sequence, reference_date):
"""Test latest_start_time() finds the latest time across all windows."""
# Should return 4:00 PM (latest start for 1 hour in afternoon window)
latest = sample_sequence.latest_start_time(Duration(hours=1), reference_date)
expected = reference_date.replace(hour=16, minute=0, second=0, microsecond=0)
assert latest == expected
def test_can_fit_duration_empty_sequence(self, reference_date):
"""Test can_fit_duration() with empty sequence returns False."""
sequence = TimeWindowSequence()
assert not sequence.can_fit_duration(Duration(hours=1), reference_date)
def test_can_fit_duration_fits_in_one_window(self, sample_sequence, reference_date):
"""Test can_fit_duration() returns True when duration fits in one window."""
assert sample_sequence.can_fit_duration(Duration(hours=2), reference_date)
def test_can_fit_duration_too_long(self, sample_sequence, reference_date):
"""Test can_fit_duration() returns False when duration is too long."""
assert not sample_sequence.can_fit_duration(Duration(hours=4), reference_date)
def test_available_duration_empty_sequence(self, reference_date):
"""Test available_duration() with empty sequence returns None."""
sequence = TimeWindowSequence()
assert sequence.available_duration(reference_date) is None
def test_available_duration_sums_all_windows(self, sample_sequence, reference_date):
"""Test available_duration() sums durations from all applicable windows."""
# 3 hours + 3 hours = 6 hours total
total = sample_sequence.available_duration(reference_date)
assert total == Duration(hours=6)
def test_available_duration_with_day_restriction(self, monday_window, reference_date):
"""Test available_duration() respects day restrictions."""
sequence = TimeWindowSequence(windows=[monday_window])
# Reference date is Wednesday, so Monday window shouldn't apply
assert sequence.available_duration(reference_date) is None
# Monday date should apply
monday_date = pendulum.parse("2025-01-13T08:00:00") # Monday
assert sequence.available_duration(monday_date) == Duration(hours=1)
def test_get_applicable_windows_empty_sequence(self, reference_date):
"""Test get_applicable_windows() with empty sequence."""
sequence = TimeWindowSequence()
assert sequence.get_applicable_windows(reference_date) == []
def test_get_applicable_windows_all_apply(self, sample_sequence, reference_date):
"""Test get_applicable_windows() returns all windows when they all apply."""
applicable = sample_sequence.get_applicable_windows(reference_date)
assert len(applicable) == 2
def test_get_applicable_windows_with_restrictions(self, monday_window, reference_date):
"""Test get_applicable_windows() respects day restrictions."""
sequence = TimeWindowSequence(windows=[monday_window])
# Wednesday - no applicable windows
assert sequence.get_applicable_windows(reference_date) == []
# Monday - one applicable window
monday_date = pendulum.parse("2025-01-13T08:00:00")
applicable = sequence.get_applicable_windows(monday_date)
assert len(applicable) == 1
assert applicable[0] == monday_window
def test_find_windows_for_duration_empty_sequence(self, reference_date):
"""Test find_windows_for_duration() with empty sequence."""
sequence = TimeWindowSequence()
assert sequence.find_windows_for_duration(Duration(hours=1), reference_date) == []
def test_find_windows_for_duration_all_fit(self, sample_sequence, reference_date):
"""Test find_windows_for_duration() when duration fits in all windows."""
fitting = sample_sequence.find_windows_for_duration(Duration(hours=2), reference_date)
assert len(fitting) == 2
def test_find_windows_for_duration_some_fit(self, sample_sequence, reference_date):
"""Test find_windows_for_duration() when duration fits in some windows."""
# Add a short window that can't fit 2.5 hours
short_window = TimeWindow(start_time=Time(18, 0, 0), duration=Duration(hours=1))
sequence = TimeWindowSequence(windows=sample_sequence.windows + [short_window])
fitting = sequence.find_windows_for_duration(Duration(hours=2, minutes=30), reference_date)
assert len(fitting) == 2 # Only the first two windows can fit 2.5 hours
def test_get_all_possible_start_times_empty_sequence(self, reference_date):
"""Test get_all_possible_start_times() with empty sequence."""
sequence = TimeWindowSequence()
assert sequence.get_all_possible_start_times(Duration(hours=1), reference_date) == []
def test_get_all_possible_start_times_multiple_windows(self, sample_sequence, reference_date):
"""Test get_all_possible_start_times() returns ranges for all fitting windows."""
ranges = sample_sequence.get_all_possible_start_times(Duration(hours=1), reference_date)
assert len(ranges) == 2
# Check morning window range
earliest_morning, latest_morning, morning_window = ranges[0]
assert earliest_morning == reference_date.replace(hour=9, minute=0, second=0, microsecond=0)
assert latest_morning == reference_date.replace(hour=11, minute=0, second=0, microsecond=0)
# Check afternoon window range
earliest_afternoon, latest_afternoon, afternoon_window = ranges[1]
assert earliest_afternoon == reference_date.replace(hour=14, minute=0, second=0, microsecond=0)
assert latest_afternoon == reference_date.replace(hour=16, minute=0, second=0, microsecond=0)
def test_add_window(self, sample_time_window_1):
"""Test adding a window to the sequence."""
sequence = TimeWindowSequence()
assert len(sequence) == 0
sequence.add_window(sample_time_window_1)
assert len(sequence) == 1
assert sequence[0] == sample_time_window_1
def test_remove_window(self, sample_sequence, sample_time_window_1):
"""Test removing a window from the sequence."""
assert len(sample_sequence) == 2
removed = sample_sequence.remove_window(0)
assert removed == sample_time_window_1
assert len(sample_sequence) == 1
def test_remove_window_invalid_index(self, sample_sequence):
"""Test removing a window with invalid index raises IndexError."""
with pytest.raises(IndexError):
sample_sequence.remove_window(10)
def test_remove_window_from_empty_sequence(self):
"""Test removing a window from empty sequence raises IndexError."""
sequence = TimeWindowSequence()
with pytest.raises(IndexError):
sequence.remove_window(0)
def test_clear_windows(self, sample_sequence):
"""Test clearing all windows from the sequence."""
assert len(sample_sequence) == 2
sample_sequence.clear_windows()
assert len(sample_sequence) == 0
assert sample_sequence.windows == []
def test_sort_windows_by_start_time(self, reference_date):
"""Test sorting windows by start time."""
# Create windows in reverse chronological order
afternoon_window = TimeWindow(start_time=Time(14, 0, 0), duration=Duration(hours=2))
morning_window = TimeWindow(start_time=Time(9, 0, 0), duration=Duration(hours=2))
evening_window = TimeWindow(start_time=Time(18, 0, 0), duration=Duration(hours=2))
sequence = TimeWindowSequence(windows=[afternoon_window, morning_window, evening_window])
sequence.sort_windows_by_start_time(reference_date)
# Should now be sorted: morning, afternoon, evening
assert sequence[0] == morning_window
assert sequence[1] == afternoon_window
assert sequence[2] == evening_window
def test_sort_windows_with_non_applicable_windows(self, monday_window, reference_date):
"""Test sorting windows with some non-applicable windows."""
daily_window = TimeWindow(start_time=Time(10, 0, 0), duration=Duration(hours=1))
sequence = TimeWindowSequence(windows=[monday_window, daily_window])
sequence.sort_windows_by_start_time(reference_date) # Wednesday
# Daily window should come first (applicable), Monday window last (not applicable)
assert sequence[0] == daily_window
assert sequence[1] == monday_window
def test_sort_windows_empty_sequence(self, reference_date):
"""Test sorting an empty sequence doesn't raise errors."""
sequence = TimeWindowSequence()
sequence.sort_windows_by_start_time(reference_date)
assert len(sequence) == 0
def test_default_reference_date_handling(self, sample_sequence):
"""Test that methods handle default reference date (today) correctly."""
# These should not raise errors and should return reasonable values
assert isinstance(sample_sequence.can_fit_duration(Duration(hours=1)), bool)
assert sample_sequence.available_duration() is not None
assert isinstance(sample_sequence.get_applicable_windows(), list)
def test_specific_date_window_functionality(self, specific_date_window):
"""Test functionality with specific date restrictions."""
sequence = TimeWindowSequence(windows=[specific_date_window])
# Should work on the specific date
specific_date = pendulum.parse("2025-01-15T12:00:00")
assert sequence.can_fit_duration(Duration(hours=1), specific_date)
# Should not work on other dates
other_date = pendulum.parse("2025-01-16T12:00:00")
assert not sequence.can_fit_duration(Duration(hours=1), other_date)
def test_edge_cases_with_zero_duration(self, sample_sequence, reference_date):
"""Test edge cases with zero duration."""
zero_duration = Duration()
# Should be able to fit zero duration
assert sample_sequence.can_fit_duration(zero_duration, reference_date)
# Should find start times for zero duration
earliest = sample_sequence.earliest_start_time(zero_duration, reference_date)
assert earliest is not None
def test_overlapping_windows(self, reference_date):
"""Test behavior with overlapping windows."""
window1 = TimeWindow(start_time=Time(10, 0, 0), duration=Duration(hours=3))
window2 = TimeWindow(start_time=Time(11, 0, 0), duration=Duration(hours=3))
sequence = TimeWindowSequence(windows=[window1, window2])
# Should handle overlapping windows correctly
test_time = reference_date.replace(hour=11, minute=30)
assert sequence.contains(test_time)
# Total duration should be sum of both windows (even though they overlap)
total = sequence.available_duration(reference_date)
assert total == Duration(hours=6)
def test_sequence_model_dump(self, sample_sequence_json):
"""Test that model dump creates the correct json."""
assert sample_sequence_json == json.loads("""
{
"windows": [
{
"start_time": "09:00:00.000000",
"duration": "3 hours",
"day_of_week": null,
"date": null,
"locale": null
},
{
"start_time": "14:00:00.000000",
"duration": "3 hours",
"day_of_week": null,
"date": null,
"locale": null
}
]
}""")
# -----------------------------
# to_datetime
# -----------------------------

View File

@@ -0,0 +1,330 @@
"""Tests for fixed electricity price prediction module."""
import json
from pathlib import Path
from unittest.mock import Mock, patch
import numpy as np
import pandas as pd
import pytest
from akkudoktoreos.config.configabc import ValueTimeWindow, ValueTimeWindowSequence
from akkudoktoreos.core.cache import CacheFileStore
from akkudoktoreos.core.coreabc import get_ems
from akkudoktoreos.prediction.elecpricefixed import (
ElecPriceFixed,
ElecPriceFixedCommonSettings,
)
from akkudoktoreos.utils.datetimeutil import Duration, to_datetime
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
FILE_TESTDATA_ELECPRICEFIXED_CONFIG_JSON = DIR_TESTDATA.joinpath("elecpricefixed_config.json")
class TestElecPriceFixedCommonSettings:
"""Tests for ElecPriceFixedCommonSettings model."""
def test_create_settings_with_windows(self):
"""Test creating settings with time windows."""
settings_dict = {
"time_windows": {
"windows": [
{
"start_time": "00:00",
"duration": "8 hours",
"value": 0.288
},
{
"start_time": "08:00",
"duration": "16 hours",
"value": 0.34
}
]
}
}
settings = ElecPriceFixedCommonSettings(**settings_dict)
assert settings is not None
assert settings.time_windows is not None
assert settings.time_windows.windows is not None
assert len(settings.time_windows.windows) == 2
def test_create_settings_without_windows(self):
"""Test creating settings without time windows."""
settings = ElecPriceFixedCommonSettings()
assert settings.time_windows is not None
assert settings.time_windows.windows == []
@pytest.fixture
def provider(monkeypatch, config_eos):
"""Fixture to create a ElecPriceFixed provider instance."""
# Set environment variables
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceFixed")
# Create time windows
time_windows = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="00:00",
duration="8 hours",
value=0.288
),
ValueTimeWindow(
start_time="08:00",
duration="16 hours",
value=0.34
)
]
)
# Create settings and assign to config
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(time_windows=time_windows)
ElecPriceFixed.reset_instance()
return ElecPriceFixed()
@pytest.fixture
def cache_store():
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
return CacheFileStore()
class TestElecPriceFixed:
"""Tests for ElecPriceFixed provider."""
def test_provider_id(self, provider):
"""Test provider ID returns correct value."""
assert provider.provider_id() == "ElecPriceFixed"
def test_singleton_instance(self, provider):
"""Test that ElecPriceFixed behaves as a singleton."""
another_instance = ElecPriceFixed()
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_update_data_hourly_intervals(self, provider, config_eos):
"""Test updating data with hourly intervals (3600s)."""
# Set start datetime
ems_eos = get_ems()
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start_dt)
# Configure hourly intervals
config_eos.optimization.interval = 3600
config_eos.prediction.hours = 24
# Update data
provider.update_data(force_enable=True, force_update=True)
# Verify data was generated
assert len(provider) == 24 # 24 hours * 1 interval per hour
# Check prices
records = provider.records
# First 8 hours should be night rate (0.288 kWh = 0.000288 Wh)
for i in range(8):
assert abs(records[i].elecprice_marketprice_wh - 0.000288) < 1e-6
# Verify timestamps are on hour boundaries
assert records[i].date_time.minute == 0
assert records[i].date_time.second == 0
# Next 16 hours should be day rate (0.34 kWh = 0.00034 Wh)
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):
"""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")
ems_eos.set_start_datetime(start_dt)
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)
# 10 hours * 4 intervals per hour = 40 intervals
assert len(provider) == 40
records = provider.records
# Check timestamps are on 15-minute boundaries
for record in records:
assert record.date_time.minute in (0, 15, 30, 45)
assert record.date_time.second == 0
# First 32 intervals: 00:0008:00, night rate (8h * 4 = 32)
for i in range(32):
assert abs(records[i].elecprice_marketprice_wh - 0.000288) < 1e-6, (
f"Expected night rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
)
# Remaining 8 intervals: 08:0010:00, day rate (2h * 4 = 8)
for i in range(32, 40):
assert abs(records[i].elecprice_marketprice_wh - 0.00034) < 1e-6, (
f"Expected day rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
)
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")
ems_eos.set_start_datetime(start_dt)
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)
# 10 hours * 2 intervals per hour = 20 intervals
assert len(provider) == 20
records = provider.records
# Check timestamps are on 30-minute boundaries
for record in records:
assert record.date_time.minute in (0, 30)
assert record.date_time.second == 0
# First 16 intervals: 00:0008:00, night rate (8h * 2 = 16)
for i in range(16):
assert abs(records[i].elecprice_marketprice_wh - 0.000288) < 1e-6, (
f"Expected night rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
)
# Remaining 4 intervals: 08:0010:00, day rate (2h * 2 = 4)
for i in range(16, 20):
assert abs(records[i].elecprice_marketprice_wh - 0.00034) < 1e-6, (
f"Expected day rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
)
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)
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)
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()
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start_dt)
config_eos.optimization.interval = 3600
config_eos.prediction.hours = 24
provider.update_data(force_enable=True, force_update=True)
# Get data as hourly array (original)
hourly_array = provider.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24)
)
assert len(hourly_array) == 24
assert abs(hourly_array[0] - 0.000288) < 1e-6 # Night rate
assert abs(hourly_array[8] - 0.00034) < 1e-6 # Day rate
# Resample to 15-minute intervals
quarter_hour_array = provider.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
interval="15 minutes"
)
assert len(quarter_hour_array) == 96 # 24 * 4
# First 4 15-min intervals should be night rate
for i in range(4):
assert abs(quarter_hour_array[i] - 0.000288) < 1e-6
# Resample to 30-minute intervals
half_hour_array = provider.key_to_array(
key="elecprice_marketprice_wh",
start_datetime=start_dt,
end_datetime=start_dt.add(hours=24),
interval="30 minutes"
)
assert len(half_hour_array) == 48 # 24 * 2
# First 2 30-min intervals should be night rate
for i in range(2):
assert abs(half_hour_array[i] - 0.000288) < 1e-6
class TestElecPriceFixedIntegration:
"""Integration tests for ElecPriceFixed."""
@pytest.mark.skip(reason="For development only")
def test_fixed_price_development(self, config_eos):
"""Test fixed price provider with real configuration."""
# Create provider with config
provider = ElecPriceFixed()
# Setup realistic test scenario
ems_eos = get_ems()
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
ems_eos.set_start_datetime(start_dt)
# Configure with realistic German electricity prices (2024)
time_windows = ValueTimeWindowSequence(
windows=[
ValueTimeWindow(
start_time="00:00",
duration="8 hours",
value=0.288 # Night rate
),
ValueTimeWindow(
start_time="08:00",
duration="16 hours",
value=0.34 # Day rate
)
]
)
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(time_windows=time_windows)
config_eos.prediction.hours = 168 # 7 days
config_eos.optimization.interval = 900 # 15 minutes
# Update data
provider.update_data(force_enable=True, force_update=True)
# Verify data
expected_intervals = 168 * 4 # 7 days * 24h * 4 intervals
assert len(provider) == expected_intervals
# Save configuration for documentation
config_data = {
"time_windows": [
{
"start_time": str(window.start_time),
"duration": str(window.duration),
"value": window.value
}
for window in config_eos.elecprice.elecpricefixed.time_windows.windows
]
}
with FILE_TESTDATA_ELECPRICEFIXED_CONFIG_JSON.open("w", encoding="utf-8") as f:
json.dump(config_data, f, indent=4)

View File

@@ -1,6 +1,7 @@
import numpy as np
import pytest
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.devices.genetic.homeappliance import HomeAppliance
from akkudoktoreos.devices.genetic.inverter import Inverter
@@ -16,12 +17,7 @@ from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
)
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSimulationResult
from akkudoktoreos.utils.datetimeutil import (
TimeWindow,
TimeWindowSequence,
to_duration,
to_time,
)
from akkudoktoreos.utils.datetimeutil import to_duration, to_time
start_hour = 1

View File

@@ -1,6 +1,7 @@
import numpy as np
import pytest
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
from akkudoktoreos.devices.genetic.battery import Battery
from akkudoktoreos.devices.genetic.homeappliance import HomeAppliance
from akkudoktoreos.devices.genetic.inverter import Inverter
@@ -16,12 +17,7 @@ from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
)
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSimulationResult
from akkudoktoreos.utils.datetimeutil import (
TimeWindow,
TimeWindowSequence,
to_duration,
to_time,
)
from akkudoktoreos.utils.datetimeutil import to_duration, to_time
start_hour = 0

View File

@@ -4,6 +4,7 @@ from pydantic import ValidationError
from akkudoktoreos.core.coreabc import get_prediction
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
@@ -37,6 +38,7 @@ def forecast_providers():
return [
ElecPriceAkkudoktor(),
ElecPriceEnergyCharts(),
ElecPriceFixed(),
ElecPriceImport(),
FeedInTariffFixed(),
FeedInTariffImport(),
@@ -76,32 +78,34 @@ def test_prediction_common_settings_invalid(field_name, invalid_value, expected_
def test_initialization(prediction, forecast_providers):
"""Test that Prediction is initialized with the correct providers in sequence."""
assert isinstance(prediction, Prediction)
assert prediction.providers == forecast_providers
for idx, provider in enumerate(prediction.providers):
assert provider.provider_id() == forecast_providers[idx].provider_id()
def test_provider_sequence(prediction):
"""Test the provider sequence is maintained in the Prediction instance."""
assert isinstance(prediction.providers[0], ElecPriceAkkudoktor)
assert isinstance(prediction.providers[1], ElecPriceEnergyCharts)
assert isinstance(prediction.providers[2], ElecPriceImport)
assert isinstance(prediction.providers[3], FeedInTariffFixed)
assert isinstance(prediction.providers[4], FeedInTariffImport)
assert isinstance(prediction.providers[5], LoadAkkudoktor)
assert isinstance(prediction.providers[6], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[7], LoadVrm)
assert isinstance(prediction.providers[8], LoadImport)
assert isinstance(prediction.providers[9], PVForecastAkkudoktor)
assert isinstance(prediction.providers[10], PVForecastVrm)
assert isinstance(prediction.providers[11], PVForecastImport)
assert isinstance(prediction.providers[12], WeatherBrightSky)
assert isinstance(prediction.providers[13], WeatherClearOutside)
assert isinstance(prediction.providers[14], WeatherImport)
assert isinstance(prediction.providers[2], ElecPriceFixed)
assert isinstance(prediction.providers[3], ElecPriceImport)
assert isinstance(prediction.providers[4], FeedInTariffFixed)
assert isinstance(prediction.providers[5], FeedInTariffImport)
assert isinstance(prediction.providers[6], LoadAkkudoktor)
assert isinstance(prediction.providers[7], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[8], LoadVrm)
assert isinstance(prediction.providers[9], LoadImport)
assert isinstance(prediction.providers[10], PVForecastAkkudoktor)
assert isinstance(prediction.providers[11], PVForecastVrm)
assert isinstance(prediction.providers[12], PVForecastImport)
assert isinstance(prediction.providers[13], WeatherBrightSky)
assert isinstance(prediction.providers[14], WeatherClearOutside)
assert isinstance(prediction.providers[15], WeatherImport)
def test_provider_by_id(prediction, forecast_providers):
"""Test that provider_by_id method returns the correct provider."""
for provider in forecast_providers:
assert prediction.provider_by_id(provider.provider_id()) == provider
assert prediction.provider_by_id(provider.provider_id()).provider_id() == provider.provider_id()
def test_prediction_repr(prediction):
@@ -110,6 +114,7 @@ def test_prediction_repr(prediction):
assert "Prediction([" in result
assert "ElecPriceAkkudoktor" in result
assert "ElecPriceEnergyCharts" in result
assert "ElecPriceFixed" in result
assert "ElecPriceImport" in result
assert "FeedInTariffFixed" in result
assert "FeedInTariffImport" in result

View File

@@ -166,6 +166,252 @@ class TestSystem:
else:
pass
def test_measurement(self, server_setup_for_class, is_system_test):
"""Test measurement endpoints comprehensively."""
server = server_setup_for_class["server"]
# ----------------------------------------------------------------------
# 1. Setup: Reset config with test measurement keys
# ----------------------------------------------------------------------
with FILE_TESTDATA_EOSSERVER_CONFIG_1.open("r", encoding="utf-8", newline=None) as fd:
config = json.load(fd)
config.setdefault("measurement", {})
config["measurement"]["pv_production_emr_keys"] = ["pv1_emr", "pv2_emr"]
config["measurement"]["load_emr_keys"] = ["load1_emr"]
result = requests.put(f"{server}/v1/config", json=config)
assert result.status_code == HTTPStatus.OK, f"Config update failed: {result.text}"
# ----------------------------------------------------------------------
# 2. GET /v1/measurement/keys
# ----------------------------------------------------------------------
result = requests.get(f"{server}/v1/measurement/keys")
assert result.status_code == HTTPStatus.OK, f"Failed to get measurement keys: {result.text}"
keys = result.json()
assert isinstance(keys, list)
assert "pv1_emr" in keys
assert "pv2_emr" in keys
assert "load1_emr" in keys
# ----------------------------------------------------------------------
# 3. PUT /v1/measurement/value
# ----------------------------------------------------------------------
# Float value
result = requests.put(
f"{server}/v1/measurement/value",
params={
"datetime": "2026-03-08T18:00:00Z",
"key": "pv1_emr",
"value": "1000.0",
},
)
assert result.status_code == HTTPStatus.OK, f"Failed to PUT float value: {result.text}"
series_response = result.json()
# PydanticDateTimeSeries has shape: {"data": {datetime_str: value}, "dtype": str, "tz": str|None}
assert "data" in series_response
assert isinstance(series_response["data"], dict)
assert len(series_response["data"]) >= 1
# String value that converts to float
result = requests.put(
f"{server}/v1/measurement/value",
params={
"datetime": "2026-03-08T19:00:00Z",
"key": "pv1_emr",
"value": "2000.0",
},
)
assert result.status_code == HTTPStatus.OK, f"Failed to PUT string float value: {result.text}"
# Non-numeric string value must be rejected
result = requests.put(
f"{server}/v1/measurement/value",
params={
"datetime": "2026-03-08T20:00:00Z",
"key": "pv1_emr",
"value": "not_a_number",
},
)
assert result.status_code == HTTPStatus.BAD_REQUEST, (
f"Expected 400 for non-numeric string, got {result.status_code}"
)
# Non-existent key must be rejected
result = requests.put(
f"{server}/v1/measurement/value",
params={
"datetime": "2026-03-08T18:00:00Z",
"key": "non_existent_key",
"value": "1000.0",
},
)
assert result.status_code == HTTPStatus.NOT_FOUND, (
f"Expected 404 for unknown key, got {result.status_code}"
)
# Missing required parameter (datetime)
result = requests.put(
f"{server}/v1/measurement/value",
params={"key": "pv1_emr", "value": "1000.0"},
)
assert result.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, (
f"Expected 422 for missing datetime, got {result.status_code}"
)
# ----------------------------------------------------------------------
# 4. GET /v1/measurement/series
# ----------------------------------------------------------------------
result = requests.get(f"{server}/v1/measurement/series", params={"key": "pv1_emr"})
assert result.status_code == HTTPStatus.OK, f"Failed to GET series: {result.text}"
series_response = result.json()
# PydanticDateTimeSeries: {"data": {datetime_str: value, ...}, "dtype": "float64", "tz": ...}
assert "data" in series_response
assert isinstance(series_response["data"], dict)
assert "dtype" in series_response
assert len(series_response["data"]) >= 2 # at least the two values inserted above
# Non-existent key must be rejected
result = requests.get(
f"{server}/v1/measurement/series", params={"key": "non_existent_key"}
)
assert result.status_code == HTTPStatus.NOT_FOUND, (
f"Expected 404 for unknown series key, got {result.status_code}"
)
# ----------------------------------------------------------------------
# 5. PUT /v1/measurement/series
# PydanticDateTimeSeries payload: {"data": {datetime_str: value, ...}, "dtype": "float64", "tz": "UTC"}
# ----------------------------------------------------------------------
series_payload = {
"data": {
"2026-03-08T10:00:00+00:00": 500.0,
"2026-03-08T11:00:00+00:00": 600.0,
"2026-03-08T12:00:00+00:00": 700.0,
},
"dtype": "float64",
"tz": "UTC",
}
result = requests.put(
f"{server}/v1/measurement/series",
params={"key": "pv2_emr"},
json=series_payload,
)
assert result.status_code == HTTPStatus.OK, f"Failed to PUT series: {result.text}"
series_response = result.json()
assert "data" in series_response
assert isinstance(series_response["data"], dict)
assert len(series_response["data"]) >= 3
# Verify the data round-trips correctly
result = requests.get(f"{server}/v1/measurement/series", params={"key": "pv2_emr"})
assert result.status_code == HTTPStatus.OK
fetched = result.json()
fetched_values = list(fetched["data"].values())
assert 500.0 in fetched_values
assert 600.0 in fetched_values
assert 700.0 in fetched_values
# Non-existent key must be rejected
result = requests.put(
f"{server}/v1/measurement/series",
params={"key": "non_existent_key"},
json=series_payload,
)
assert result.status_code == HTTPStatus.NOT_FOUND, (
f"Expected 404 for unknown series PUT key, got {result.status_code}"
)
# ----------------------------------------------------------------------
# 6. PUT /v1/measurement/dataframe
# PydanticDateTimeDataFrame payload:
# {"data": {datetime_str: {"col1": val, ...}, ...}, "dtypes": {}, "tz": ..., "datetime_columns": [...]}
# ----------------------------------------------------------------------
dataframe_payload = {
"data": {
"2026-03-08T00:00:00+00:00": {"pv1_emr": 100.5, "load1_emr": 50.2},
"2026-03-08T01:00:00+00:00": {"pv1_emr": 200.3, "load1_emr": 45.1},
"2026-03-08T02:00:00+00:00": {"pv1_emr": 300.7, "load1_emr": 48.9},
},
"dtypes": {"pv1_emr": "float64", "load1_emr": "float64"},
"tz": "UTC",
"datetime_columns": [],
}
result = requests.put(f"{server}/v1/measurement/dataframe", json=dataframe_payload)
assert result.status_code == HTTPStatus.OK, f"Failed to PUT dataframe: {result.text}"
# Verify data was loaded for both columns
for key in ("pv1_emr", "load1_emr"):
result = requests.get(f"{server}/v1/measurement/series", params={"key": key})
assert result.status_code == HTTPStatus.OK, f"Failed to verify series for {key}"
series_response = result.json()
assert len(series_response["data"]) >= 3, f"Expected >=3 data points for {key}"
# Invalid dataframe structure (row columns inconsistent) must be rejected
invalid_dataframe_payload = {
"data": {
"2026-03-08T00:00:00+00:00": {"pv1_emr": 100.0},
"2026-03-08T01:00:00+00:00": {"pv1_emr": 200.0, "load1_emr": 45.0}, # extra column
},
"dtypes": {},
"tz": "UTC",
"datetime_columns": [],
}
result = requests.put(f"{server}/v1/measurement/dataframe", json=invalid_dataframe_payload)
assert result.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, (
f"Expected 422 for inconsistent dataframe columns, got {result.status_code}"
)
# ----------------------------------------------------------------------
# 7. PUT /v1/measurement/data
# PydanticDateTimeData payload (RootModel):
# Dict[str, Union[str, List[Union[float, int, str, None]]]]
# Columnar format: keys are column names (or special "start_datetime"/"interval"),
# values are flat lists of equal length. Datetime index is given via start_datetime + interval.
# ----------------------------------------------------------------------
data_payload = {
"start_datetime": "2026-03-09T00:00:00+00:00",
"interval": "1 hour",
"pv1_emr": [400.2, 450.1],
"load1_emr": [60.5, 55.3],
"pv2_emr": [150.8, 175.2],
}
result = requests.put(f"{server}/v1/measurement/data", json=data_payload)
assert result.status_code == HTTPStatus.OK, f"Failed to PUT data dict: {result.text}"
# Verify all three keys received the values
for key, expected_values in (
("pv1_emr", [400.2, 450.1]),
("load1_emr", [60.5, 55.3]),
("pv2_emr", [150.8, 175.2]),
):
result = requests.get(f"{server}/v1/measurement/series", params={"key": key})
assert result.status_code == HTTPStatus.OK, f"Failed to verify {key} after data PUT"
fetched = result.json()
fetched_values = list(fetched["data"].values())
for expected in expected_values:
assert expected in fetched_values, (
f"Expected {expected} in {key} series, got {fetched_values}"
)
# ----------------------------------------------------------------------
# 8. Edge case: invalid datetime in value PUT
# ----------------------------------------------------------------------
result = requests.put(
f"{server}/v1/measurement/value",
params={
"datetime": "not-a-datetime",
"key": "pv1_emr",
"value": "1000.0",
},
)
assert result.status_code == HTTPStatus.BAD_REQUEST, (
f"Expected 400 for invalid datetime, got {result.status_code}"
)
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

@@ -40,11 +40,11 @@
"time_windows": {
"windows": [
{
"start_time": "08:00:00.000000 Europe/Berlin",
"start_time": "08:00:00.000000",
"duration": "5 hours"
},
{
"start_time": "15:00:00.000000 Europe/Berlin",
"start_time": "15:00:00.000000",
"duration": "3 hours"
}
]