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

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
# -----------------------------