mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-03-12 09:36:17 +00:00
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
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:
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
# -----------------------------
|
||||
|
||||
330
tests/test_elecpricefixed.py
Normal file
330
tests/test_elecpricefixed.py
Normal 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:00–10: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:00–08: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:00–10: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:00–10: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:00–08: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:00–10: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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
4
tests/testdata/eos_config_stripped.json
vendored
4
tests/testdata/eos_config_stripped.json
vendored
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user